diff --git a/AGENTS.md b/AGENTS.md index 4651acb..5ef0762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,13 @@ src/ ├── app.rs # TUI state machine, mark task queue, Claude session management ├── app/terminal.rs # terminal-v1 message handling for the Claude/Codex sessions ├── app/reauth.rs # re-authentication when Astation sends auth_required over an open socket +├── app/chat.rs # chat-v1 host: adapter polling, conversations, submit, registry status/capabilities +├── app/chat_tests.rs # (test-only) chat-v1 end to end: the App, tests/fixtures/chat/fake-claude.sh in a PTY, a fake Astation +├── chat/ # Agent chat (chat-v1), agent-neutral except the adapters +│ ├── adapter.rs # AgentAdapter trait, AgentEvent, LogItem, Capabilities, LaunchPlan +│ ├── session.rs # per-agent chat state: status, history, draft check, submit/acceptance, request ids +│ ├── wire.rs # agentChat v1 envelopes to and from Astation +│ └── claude/ # Claude Code adapter: hooks.rs (chat dir, settings.json, event files), transcript.rs (session JSONL) ├── cli.rs # CLI command definitions ├── repl.rs # Interactive REPL mode ├── websocket_client.rs # Astation WebSocket protocol (message types + client) @@ -144,6 +151,7 @@ designs/ ├── voice-coding-stages.md # Voice coding implementation stages ├── remote-agent-control.md # Astation → atem → agent (text/voice/control keys, terminal-v1 proposal) ├── local-agent-runtime.md # Launch the original claude/codex CLI in Atem, local and remote access to one session +├── agent-chat-host.md # Agent chat (chat-v1): adapters, Claude hooks + transcript, status, wire ├── terminal-v1-implementation.md # Shared terminal: what Atem built, wire contract, keys, limits ├── atem-identity.md # instance_id + unique relay atem_id ├── vault.md # Shared cross-agent context store (relay + Postgres) @@ -185,11 +193,14 @@ Key methods: - `VisualizeRequest { session_id, topic, relay_url? }` - diagram request from Astation; diagram generation was removed, so atem answers each one at once with `VisualizeResult { success: false, message: "Diagram generation was removed from atem" }` - `VisualizeResult { session_id, success, message, file_path? }` - sent back to Astation (always unsuccessful, no `file_path`) - `AgentInput { agent_id?, kind, text?, key? }` - remote agent control: text or a control key from Astation → focused agent PTY (`handle_agent_input`) +- `AgentChat(ChatEnvelope)` - `{"type":"agentChat","data":{"v":1,"op":…}}` both ways: chat with a launched agent (`handle_agent_chat`, see Agent chat) - Also: project lists, token requests, voice/video toggle, heartbeat, auth flow (`auth_required` → `auth`; the connect path and re-authentication share `auth_proof` to build the proof and `auth_response` to classify replies, so the two can't drift) **Remote Agent Control** (`app.rs`, `websocket_client.rs`): Receives `AgentInput` from Astation and writes it to the focused agent's PTY — `kind:"text"` types a line and submits it (`\n\r`, matching `send_claude_prompt`); `kind:"key"` writes raw control bytes via `agent_key_to_bytes` (enter/esc/ctrl-c/arrows/y/n). Routes to the codex PTY when it's the active chat, else claude; no-ops if no agent session is live (v1 drives an already-running agent). Voice reuses the existing `VoiceRequest` path. While a terminal-v1 viewer is attached, `AgentInput` is refused (a status message says to use terminal-v1) and so is any `agent_id`. A socket alive is not an authenticated Astation: when Astation sends `auth_required` over an already-authenticated socket (e.g. it alone reconnected to the relay), `app/reauth.rs` shows `Astation ✗`, revokes terminal leases and ignores every other Astation request, then answers only the pinned Astation non-interactively (`AstationClient::answer_auth_required`) with the connect path's proof (`local_proof` on loopback, else the saved session); success restores online and re-sends the agent list (viewers subscribe again, view-only), while denial, expiry, a different `astation_id` or no answer within 15 s leaves a notice and closes the socket for the normal reconnect (never pairing). -**Shared terminal** (`src/terminal_share.rs`, `pty_session.rs`, `app/terminal.rs`, `tui/terminal_view.rs`): `atem agent launch [claude|claude-code|codex] [AGENT_ARGS]...` runs `tui::run_agent_tui`, which starts the installed CLI in a PTY (arguments are forwarded as separate values, with or without `--`; `CLAUDE_CLI_*`/`CODEX_CLI_*` env still apply and come first) and shows it full screen with one status row (`Atem | Claude|Codex | Local|Remote | Astation ✓|✗[ | Notice] | then ?:help`, from `tui/draw.rs`: `Remote` while a viewer holds the control lease, `✗` whenever `astation_connected` is false — including during re-authentication — and `Notice` once `terminal_ui.messages` has an entry; prefix-pending, help, history and 4 s feedback replace it). It works without Astation. Over the authenticated Astation link, `{"type":"terminal","data":{"v":1,"op":...}}` messages (`list`, `subscribe`, `unsubscribe`, `ack`, `control`, `input`, `resize`, `stop` in; `sessions`, `attached`, `frame`, `controller`, `written`, `exited`, `error` out) let a viewer see the canonical screen as base64 ANSI frames (16 KiB per frame, 128 KiB unacknowledged, bounded queues) and, holding a control lease, send raw input bytes. Terminal ids are `claude` and `codex`; one Claude and one Codex session, one viewer each; sessions end when Atem quits. Local keys use the prefix Ctrl+] (`terminal_prefix` in config.toml or `ATEM_TERMINAL_PREFIX`, which wins, changes it; an invalid value falls back to Ctrl+] with a notice): `h`/`m` hide, `g` take control back, `x` stop, `r` redraw, `[` history, `c` copy, `?` help and recent notices. Background warnings go through `diagnostics` so they never write over the renderer. See `designs/terminal-v1-implementation.md` (the contract), `designs/local-agent-runtime.md` and `designs/remote-agent-control.md`. +**Shared terminal** (`src/terminal_share.rs`, `pty_session.rs`, `app/terminal.rs`, `tui/terminal_view.rs`): `atem agent launch [claude|claude-code|codex] [AGENT_ARGS]...` runs `tui::run_agent_tui`, which starts the installed CLI in a PTY (arguments are forwarded as separate values, with or without `--`; `CLAUDE_CLI_*`/`CODEX_CLI_*` env still apply and come first, after Claude's chat hooks) and shows it full screen with one status row (`Atem | Claude|Codex | Local|Remote | Astation ✓|✗[ | Notice] | then ?:help`, from `tui/draw.rs`: `Remote` while a viewer holds the control lease, `✗` whenever `astation_connected` is false — including during re-authentication — and `Notice` once `terminal_ui.messages` has an entry; prefix-pending, help, history and 4 s feedback replace it). It works without Astation. Over the authenticated Astation link, `{"type":"terminal","data":{"v":1,"op":...}}` messages (`list`, `subscribe`, `unsubscribe`, `ack`, `control`, `input`, `resize`, `stop` in; `sessions`, `attached`, `frame`, `controller`, `written`, `exited`, `error` out) let a viewer see the canonical screen as base64 ANSI frames (16 KiB per frame, 128 KiB unacknowledged, bounded queues) and, holding a control lease, send raw input bytes. Terminal ids are `claude` and `codex`; one Claude and one Codex session, one viewer each; sessions end when Atem quits. Local keys use the prefix Ctrl+] (`terminal_prefix` in config.toml or `ATEM_TERMINAL_PREFIX`, which wins, changes it; an invalid value falls back to Ctrl+] with a notice): `h`/`m` hide, `g` take control back, `x` stop, `r` redraw, `[` history, `c` copy, `?` help and recent notices. Background warnings go through `diagnostics` so they never write over the renderer. See `designs/terminal-v1-implementation.md` (the contract), `designs/local-agent-runtime.md` and `designs/remote-agent-control.md`. + +**Agent chat** (`src/chat/`, `app/chat.rs`): chat-v1 lets Astation show a launched agent as a chat. Only Claude Code has an adapter. Before each Claude spawn `prepare_claude_chat` creates `chat-/` (0700) under `$XDG_RUNTIME_DIR/atem` (else `~/.config/atem/run`; `App.chat_root`) with `settings.json` (hooks for SessionStart, UserPromptSubmit, Pre/PostToolUse, Notification, Stop, each writing its stdin JSON to its own file in `events/`) and puts `--settings ` and, unless resuming, `--session-id ` before the user's arguments for that spawn only; each spawn is a new generation (UUID). It also writes a `pid` file; the first chat an App prepares sweeps `chat-*` directories whose pid is dead (or, without one, older than an hour). If preparing fails, Claude starts without chat. Every TUI tick `poll_chat` reads the event files (lifecycle only) and the transcript JSONL named by SessionStart (all message content), feeds `ChatSession` (log items before a tick's `Stop`, so `complete` follows the content), times out unaccepted prompts (10 s, never resent), re-sends status to each conversation 15 s after it last got it, and sends the results to each open conversation with its own sequence (at most 8 per agent: a ninth closes the oldest). A SessionStart whose transcript isn't `.jsonl` is ignored with a notice; compaction or a resume on the same transcript keeps the turn, while startup, `/clear` or another transcript resets to ready (the old transcript is read to its end first, one tick later). Resuming another session reads its existing content as history only (no `message`/`complete`) and sends open conversations `error` "the conversation was switched; reopen the chat" and `close`. A user entry starting with `[Request interrupted by user` ends the turn as interrupted (Esc, or deny at a permission prompt). Status: ready/working/waiting/unknown, mapped in the agent registry to Idle/Thinking/WaitingForInput/Unknown; the registry entry is Unknown with no capabilities until SessionStart, then advertises `capabilities: ["chat-v1"]` while the adapter reports it, and the agent list is re-sent to Astation on a change. No SessionStart within 15 s leaves chat unavailable with a notice (a late one still enables it); a transcript format change withdraws chat (open conversations get `error` and `close`); the terminal is unaffected either way. `submit` is pasted as one bracketed paste plus Enter (`PtyInput::send_paste`, not counted as typing) only for the current generation, a new `requestId`, a clean prompt (≤ 32 768 bytes, no control characters except `\n`/`\t`, not starting with `/` or `!`: commands and shell mode are refused), status ready with nothing pending, no typing since the last turn ended (`PtyInput::input_count`; typing during a turn counts as consumed when it ends, so a mid-turn draft is not detected) and no terminal viewer in control; accepted when both the UserPromptSubmit hook and the transcript's user entry arrive. Approvals stay in the terminal. Outgoing text goes through the credential redactor. Conversations are handled only while `astation_connected` (not during re-authentication) and close on disconnect or re-authentication; a new `open` restores status and history (50 messages / 256 KiB, `before` pages). Claude exiting reports `unknown` and `error`, drops the adapter (removing its directory) and keeps history readable (500 messages / 8 MiB); a respawn sends the old conversations `error` "the agent was restarted; reopen the chat" and `close`. Codex is registered with status Unknown and no capabilities (the agent list is sent when it starts). See `designs/agent-chat-host.md`. **Vault** (`vault_client.rs`): Client for the relay-hosted shared cross-agent context store. `atem vault new/list/read/write/set-summary` — a versioned, append-only store that multiple atems read/write to hand off context between their agents. Pure request builders + human/plain renderers + a thin reqwest executor (auth: `Authorization: session ` + `?id=`). The `/api/vault` endpoints + Postgres live in the relay-server (Astation repo). See `designs/vault.md`. diff --git a/README.md b/README.md index 59d7188..659c861 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,15 @@ Ctrl+ other than i or m are accepted. The `CLAUDE_CLI_BIN`/`CLAUDE_CLI_A and `CODEX_CLI_BIN`/`CODEX_CLI_ARGS` variables still override the command. See [`designs/terminal-v1-implementation.md`](designs/terminal-v1-implementation.md). +**Chat from Astation.** Astation can open a chat view of a Claude session +launched with `atem agent launch claude`: your prompts appear as messages and +Claude's replies come from its own session log, with a status of ready, +working, waiting or unknown. Atem adds its hooks with `--settings` for that run +only; your `~/.claude` settings are not changed. Prompts from chat are refused +while you have unsent input in the terminal, and approvals stay in the +terminal. Codex has no chat yet (its status is unknown). See +[`designs/agent-chat-host.md`](designs/agent-chat-host.md). + ### Vault (shared cross-agent context) ```bash diff --git a/designs/agent-chat-host.md b/designs/agent-chat-host.md new file mode 100644 index 0000000..9a76e2a --- /dev/null +++ b/designs/agent-chat-host.md @@ -0,0 +1,392 @@ +# Agent chat (chat-v1): Atem host side + +Status: design approved 2026-10-15; Atem host side built (Claude adapter). +Astation side (browser page, later a mobile app) is the Astation agent's; its +proposed contract is the starting point (atem memory `mem_b3ccce1f`, project +github.com/agora-build/atem) and this document lists the changes Atem needs +(see "Changes to the proposed contract"). + +## Goal + +A chat view of a coding agent that runs in Atem's shared terminal +(`atem agent launch`). Prompts show as outgoing user messages, the agent's +replies as incoming messages, with an honest activity indicator. Web first; +a mobile app later uses the same events, so the host side is client-neutral. + +The host side is **adapter-based**: everything agent-specific (how Atem hooks +in, which events mean what, how the session log is parsed) lives in one +adapter per agent; the chat state, message model, wire protocol, security and +reconnect handling are shared. v1 ships one adapter, **Claude Code**. Codex is +the next adapter (its own, smaller design: rollout JSONL plus its hooks, which +need per-installation trust); other agents (Gemini CLI, OpenCode, Copilot CLI, +Aider, …) get an adapter only once their logs and events are checked. An agent +without an adapter keeps the shared terminal, reports status `unknown` and has +no `chat-v1` capability. + +## Rules + +- **One source of content:** the agent's own session log (Claude's + transcript). Agent events (Claude's hooks) only mark the lifecycle. Atem never scrapes the terminal screen and never infers that a + turn is done or waiting from silence. +- **The original session stays.** Atem keeps using the interactive Claude CLI + it launched; chat never starts another Claude process or session. +- **No approvals from chat.** Tool permission requests are answered in the + terminal; chat only shows `waiting`. +- **Never merge with a local draft.** A chat prompt is refused when anyone + typed into the terminal since Claude's last turn ended. +- **Never resend.** A prompt whose delivery is unknown is reported, not retried. +- **Honest status.** States come from real events; when Atem can't tell, it + says `unknown`. + +## Adapters + +An adapter turns one agent's native signals into neutral inputs for the shared +chat state. It is the only agent-specific code. + +```rust +trait AgentAdapter { + /// Extra launch arguments and environment, given the agent's own args + /// (Claude: --settings, --session-id). Errors leave chat unsupported. + fn prepare_launch(&self, args: &[String], dir: &Path) -> Result; + /// Poll the agent's lifecycle source (Claude: hook event files). + fn poll_events(&mut self) -> Vec; + /// Poll the agent's session log from the saved position (Claude: transcript JSONL). + fn poll_log(&mut self) -> Vec; + /// What this agent supports right now (chat-v1 only once healthy). + fn capabilities(&self) -> Capabilities; +} + +enum AgentEvent { // neutral lifecycle + SessionStarted { session_id, log_path, source, same_log }, // source: startup|resume|clear|compact|""; + // same_log: the adapter keeps reading that log + PromptSubmitted { text }, + ToolStarted, ToolFinished, + WaitingForPermission { detail }, + TurnEnded, +} +enum LogItem { // neutral content, already filtered + UserMessage { id, turn_id, text, created_at }, + AssistantPart { message_id, part, turn_id, created_at, end_of_turn: Option }, + Backfill(LogItem), // already in a log switched to (resume): history only + Problem { detail }, // unreadable line, format change +} +``` + +The shared session (below) owns status, the draft check, prompt submission +and acceptance (`PromptSubmitted` plus the matching `UserMessage`), request-id +records, history and the wire. Adding an agent means writing an adapter and +its tests; the wire and the clients don't change. + +### Units + +| Unit | Responsibility | +|---|---| +| `src/chat/adapter.rs` | `AgentAdapter`, `AgentEvent`, `LogItem`, `Capabilities`: the neutral interface | +| `src/chat/claude/hooks.rs` | Claude: builds the per-session directory and `settings.json`; reads, orders and deletes the event files; maps hook events to `AgentEvent` | +| `src/chat/claude/transcript.rs` | Claude: follows the transcript and turns entries into `LogItem`s (below) | +| `src/chat/session.rs` | Shared per-agent chat state: status, recent messages, the draft tracker, prompt submission and acceptance, request-id records; agent-neutral | +| `src/chat/wire.rs` | `agentChat` v1 messages to and from Astation: encoding, decoding, validation | +| `src/agent_registry.rs` | Status from `session.rs`; `capabilities: ["chat-v1"]` only when the agent's adapter reports it (v1: Claude with active hooks) | + +All of it lives in the Atem process that owns the terminal; there is no new +daemon. Chat state belongs to the agent session, not to a browser connection: +Claude keeps running and Atem keeps reading the transcript whether or not any +chat page is open. + +## Claude Code adapter (v1) + +### Launch + +`atem agent launch claude [args…]`: + +1. Creates a private per-session directory, `$XDG_RUNTIME_DIR/atem/chat-/` + (or `~/.config/atem/run/chat-/` without `XDG_RUNTIME_DIR`), mode 0700, + holding: + - `settings.json` (0600): hooks for `SessionStart`, `UserPromptSubmit`, + `PreToolUse`, `PostToolUse`, `Notification`, `Stop`, each running the + event-file command below; + - `events/` (0700): one file per hook event; + - `pid` (0600): the Atem process id. The first chat an Atem process prepares + removes `chat-*` directories under the same root whose `pid` names a + process that no longer exists, or that have no `pid` file and are older + than an hour (left by a crash or SIGKILL). Nothing else is touched and no + symlink is followed. +2. Starts Claude in the shared terminal with `--settings ` and, + unless the user passed `--continue`/`--resume`, `--session-id `. + With `--continue`/`--resume` the session id is taken from `SessionStart`. +3. Waits for `SessionStart` (fired by Claude at every start and resume, before + any prompt; it carries `session_id` and `transcript_path`). None within 15 s + → chat is withdrawn for this agent, with the reason shown in Atem's notices + ("Claude hooks are not active: disabled, `--bare`, or an unsupported + version"); the terminal keeps working. A late `SessionStart` (e.g. after + Claude's workspace-trust question in a new directory) still enables chat. +4. The directory is removed when the agent process ends or Atem exits. + +Nothing in the user's `~/.claude` settings changes. Hooks the user already has +keep running; Claude merges `--settings` with them. + +### Hook events: one file each + +Claude runs a hook as a shell command and passes the event JSON on stdin. Each +hook command writes that JSON to its own file, atomically: + +```sh +{ f=$(mktemp "/events/ev.XXXXXXXX") && cat > "$f" && mv "$f" "$f.json"; } 2>/dev/null; exit 0 +``` + +(`mktemp` gives a unique name on both Linux and macOS; `date +%N` would not +work on macOS.) + +- One file per event, written then renamed, because hooks can run at the same + time (parallel tool calls) and an event can be large (a `Write` event holds + the whole file being written); a shared append-only file could interleave. +- Atem reads `events/*.json` ordered by modification time (nanosecond + resolution on Linux and APFS), then name; handles each event and deletes + the file. Files without `.json` are still being written and are skipped; + leftovers older than a minute are removed. +- The command prints nothing and always exits 0, even when writing fails (for + Claude, exit code 2 blocks the action and other failures are shown to the + user), so it can never allow, deny or block + anything, and it doesn't depend on Atem running or on the Atem binary's + path: if Atem is gone, the file is simply never read and the directory is + cleaned up with the session. +- No token, socket or helper process is needed: the private directory (0700) + already limits who can write events to the user's own account. + +### Reading the transcript + +- Read from a saved byte offset, only up to the last complete line (a partial + last line waits). If the file doesn't exist yet, wait for it. When + `SessionStart` names a new transcript (`/clear`, resume), switch to it: + - a `SessionStart` whose `transcript_path` file name isn't exactly + `.jsonl` (its own `session_id`) is ignored, with a notice; + - the same path (compaction, or a resume of the session being read) keeps + the reader; + - before switching, the old transcript is read to its end (the switch waits + one poll so those lines are handled normally first); + - a resumed session's transcript (source not `startup`/`clear`) usually + already holds that session's earlier turns: that content is **backfill**, + added to history only (no `message`, `delta` or `complete`, no status + change), and open conversations get `error` "the conversation was + switched; reopen the chat" and `close`, so clients reload history. Later + lines are live. +- Order is file order. Each transcript entry is processed once, keyed by its + entry `uuid` (bounded record of seen uuids per session). +- Entry kinds: + - `user` entries with text → a `user` message (`role: "user"`), `turnId` = + the entry's `promptId`; tool results (`tool_result`) are not shown. + - `assistant` entries → parts of an `assistant` message. Claude writes one + reply as several lines sharing `message.id` (one per content block); the + chat `messageId` is that `message.id`, and each line is one **part** whose + id is the entry `uuid`. Content blocks map to parts: + - `text` → `{kind: "text", markdown}`; + - `tool_use` → `{kind: "tool", name, summary}`: `summary` is a file path + for file tools, the first line of the command for `Bash` (both cut at + 120 characters), or empty; + - `thinking` → not sent. + - A `user` entry whose text (a string, or a text block) starts with + `[Request interrupted by user` ends the current turn as `interrupted` and + is not shown: Esc during a reply (`[Request interrupted by user]`, with + `interruptedMessageId`) and deny/Esc at a permission prompt + (`[Request interrupted by user for tool use]`). + - `isSidechain` (subagent) and `isMeta` entries, attachments, snapshots and + other bookkeeping types → skipped. + - A line that doesn't parse, or an unknown type → skipped, with one notice. + - Required fields missing in a way that suggests a format change → chat is + withdrawn for this agent with the reason. + +### Status (shared state machine, fed by the adapter) + +The session derives status from neutral inputs; the Claude adapter supplies +them as follows: + +| Claude source | Status | +|---|---| +| `UserPromptSubmit`, `PreToolUse` | `working` | +| `Notification` asking for permission | `waiting` ("approve in the terminal") | +| `Stop` | `ready` (and `complete`, below) | +| `SessionStart` `startup`/`clear`, or a different transcript | `ready`; the draft mark is reset | +| `SessionStart` `compact`/`resume` on the transcript being read (or any `SessionStart` on it while a turn is in progress) | unchanged: the turn, a prompt waiting for acceptance and the draft mark stay | +| transcript: assistant entry with `stop_reason: "end_turn"`, or the interrupt marker after Esc | `ready` (and `complete`, outcome `interrupted` for an interrupt), even if `Stop` was missed | +| agent process exits | `unknown`; the generation is retired | +| no `SessionStart`, or chat withdrawn | `unknown`, capability removed | + +Every status carries `since` (time of the event that set it). Every 15 s Atem +re-sends the current state with its original `since`; it never invents a new +state. A long `working` stays `working` and `since` shows how long. +`AgentRegistry` status uses the same state (`ready`→`Idle`, +`working`→`Thinking`, `waiting`→`WaitingForInput`, plus `Unknown`), replacing +today's hard-coded `Idle`. + +`complete` means **Claude's execution turn has ended** (by `Stop`, +`end_turn` or an interrupt). It does not promise that no more transcript +content will arrive: a late entry for the same `turnId` may still come as +`message`/`delta` after `complete`, and clients must accept it. On `Stop`, +Atem reads the transcript to its end and sends what it found before +`complete`. + +### Sending a prompt from chat + +`submit` is refused with an `error` (nothing is typed) unless all hold: + +1. `generation` and `agentId` match the running agent process; +2. `requestId` is new **for this agent session** (not only this conversation); + a repeated `requestId` is never executed again: Atem answers with what it + already knows (`accepted`, `complete`, or the earlier error); +3. text is at most 32 768 UTF-8 bytes and contains only printable characters, + `\n` and `\t` (anything else, including ESC, is refused, not stripped: an + embedded paste-end marker could otherwise inject keystrokes), and its first + non-whitespace character is not `/` or `!` ("commands and shell mode are not + available from chat": a `/` command fires no hook, and `!` runs a shell + command without a permission prompt; `#` is allowed); +4. status is `ready` and no other prompt is waiting for acceptance; +5. **nobody typed into the terminal since the last turn ended** (local or + Astation terminal input both count). Input typed while the agent is working + is treated as consumed when the turn ends; an unfinished draft typed during + a turn is not detected (v1); +6. no Astation viewer holds terminal control. + +Then Atem writes the text as one bracketed paste followed by Enter, and +records the request. It is `accepted` when **both** the `UserPromptSubmit` +hook and the matching `user` entry in the transcript have arrived with the +same text (compared without leading and trailing whitespace). No acceptance within 10 s → `error {requestId, "delivery unknown; +check the terminal"}`; it is never resent. + +Prompts typed in the terminal arrive the same way and appear as user +messages with `origin: "local"`. + +## Message model (client-neutral) + +``` +message { + messageId, // Claude message.id (assistant) or entry uuid (user) + role: "user" | "assistant", + origin?: "local" | "remote", // user messages only + turnId, // the prompt this message belongs to (Claude promptId) + requestId?, // on user messages with origin "remote", and on the + // assistant messages and `complete` of that turn + createdAt, // RFC 3339 / unix ms, from the transcript + parts: [ { id, kind: "text", markdown } | { id, kind: "tool", name, summary } ], + history?: true // backfill on open / page +} +delta { messageId, part: { id, kind, … } } // append one part; a part id + // already present is ignored +``` + +- Markdown is CommonMark without raw HTML, so native apps can render it + without a web view. +- IDs never change, so clients can cache messages and merge updates. +- History never replays deltas: each message is sent whole with all its parts + so far; clients replace what they had. +- `waiting` and `complete` are the events a mobile app can turn into push + notifications (delivery is Astation's/the app's concern). + +## Wire (agentChat v1) + +Carried as `{"type":"agentChat","data":{"v":1,"op":…,"conversationId":…,"agentId":…}}` +on the existing authenticated Astation connection. + +| op | Direction | Fields | +|---|---|---| +| `open` | Astation → Atem | `conversationId`, `agentId`, optional `before` (messageId) | +| `opened` | Atem → Astation | `generation`, `sequence: 0`, `status {state, since}`, then history as `message` events (latest 50 or 256 KB, older pages with `before`) | +| `submit` | Astation → Atem | `generation`, `requestId` (UUID), `text` | +| `accepted` | Atem → Astation | `requestId` | +| `message` / `delta` | Atem → Astation | see the message model | +| `status` | Atem → Astation | `state: ready|working|waiting|unknown`, `since`, optional `detail` | +| `complete` | Atem → Astation | `turnId`, optional `requestId`, `outcome: "done"|"interrupted"` | +| `error` | Atem → Astation | `text` (safe, no secrets), optional `requestId`; an `error` sent before a conversation is open (unknown agent, chat unavailable, bad request) carries no `generation`/`sequence` | +| `close` | either | `conversationId` | + +- `open` with `before` on an open conversation reopens it (`opened`, + sequence 0) and sends that older page. +- Every Atem event in a conversation carries `generation` and the next + `sequence` (1, 2, …; at most 2^53−1). A gap or a different generation means + the client reopens. +- Unknown fields are ignored; new fields stay optional; `v` changes only for + incompatible changes. Size limits: prompt 32 768 bytes, message 131 072 + bytes (larger messages are truncated with a note). +- A conversation belongs to the connection that opened it; it closes when that + connection drops or re-authenticates. Opening again restores status and + history. +- Each conversation gets the status again 15 s after it last got it (on + `opened` or a heartbeat). At most 8 conversations are open per agent: + opening a ninth sends the least recently opened one `close`. +- Atem keeps the newest 500 messages, at most 8 MiB, per agent process. + +## Security + +- `agentChat` is handled only on an authenticated Astation connection, never + while re-authentication is pending, and every request is bound to the + connection, agent and process generation. +- Hook events: the per-session directory is 0700 (event files are written + inside it), so only the user's own account can add events. The trust + boundary is that account: a process running as the user could forge an event + file; that can mislabel status but cannot approve a tool, type input or read + anything, and acceptance additionally needs the transcript's own user entry. + Event files can hold tool input (file contents); they stay in the private + directory and are deleted as soon as they are read. +- Outgoing text and tool summaries pass Atem's credential detector + (`src/memory/secrets.rs`); matches become `[redacted]`. Thinking, tool output + and file contents are never sent. +- Transport: the existing TLS connection through the relay, so the relay can + read chat content. End-to-end encryption of chat (e.g. with the account key) + is a follow-up. + +## Failure handling + +| Case | Behaviour | +|---|---| +| Atem not running when a hook fires | the event file is written and never read; Claude is unaffected; the directory goes with the session | +| Claude exits or crashes | `unknown`, `error` to open conversations, generation retired, prompts refused | +| Agent restarted | open conversations get `error` "the agent was restarted; reopen the chat" and `close`; the new process has a new generation | +| Another session resumed in the same process | its earlier turns become history (no events); open conversations get `error` "the conversation was switched; reopen the chat" and `close`; same generation | +| Bad or unknown transcript line | skipped, one notice; reading continues | +| Transcript format changed (required fields missing) | chat withdrawn for this agent, with the reason (open conversations get `error` and `close`); terminal unaffected | +| No acceptance within 10 s of a paste | `error` "delivery unknown"; never resent | +| Astation disconnects / re-authenticates | conversations close; Claude keeps running; a new `open` restores status and history | +| Atem exits | the terminal and Claude end with it (as today); a new Atem has a new generation | + +## Testing + +- Unit: transcript reader on anonymised real samples (block merging, part + ids, dedupe, partial lines, session switch, interrupt, unknown types); + status table; `submit` checks (generation, duplicate request, size, control + characters, draft, terminal control); redaction; wire encode/decode and + sequence/generation rules. +- Integration (`tests/`, like `terminal_ui_binary`): a fake `claude` script + that runs the hook commands from `settings.json` and writes a transcript, run by the real binary + in a pseudo-terminal against a fake Astation WebSocket: open with history; + submit → accepted → message/delta → complete; refusal after local typing; + duplicate request; process exit → unknown; reconnect keeps state. +- Before building on them, confirm on the installed Claude Code 2.1.x: + `--settings` hooks fire in interactive mode and receive the event JSON on + stdin, `SessionStart` arrives before + the first prompt with `session_id`/`transcript_path`, `--session-id` names + the transcript, and the interrupt marker's exact form. +- Manual acceptance with real Claude Code and Astation's page. + +Verified on Claude Code 2.1.299 (fixtures in tests/fixtures/chat/claude/). + +## Changes to the proposed contract (for the Astation agent) + +Additive to `mem_b3ccce1f`: + +- `message` gains `role`, `origin` (user messages), `turnId`, `createdAt`, + `parts` (typed: `text` with CommonMark-no-HTML, `tool` with name/summary) + instead of a single Markdown `text`, and `history`. +- `delta` appends one typed part with an id; repeated part ids are ignored. +- `complete` gains `turnId` and `outcome`; it means the execution turn ended, + not that no more content can arrive. +- `status` gains `since` and is re-sent every 15 s with the original `since`. +- `open` gains optional `before` for history pages. +- `requestId` deduplication is per agent session (stronger than per + conversation). +- `submit` can be refused because of a local draft, terminal control, status + not `ready`, or control characters; the `error` text says which. + +## Out of scope (v1) + +Adapters other than Claude Code (Codex next, as its own design); approvals from chat; streaming token-by-token (the transcript +holds whole content blocks); end-to-end encryption of chat; push delivery; +attachments/images. diff --git a/designs/plans/2026-10-15-agent-chat-host.md b/designs/plans/2026-10-15-agent-chat-host.md new file mode 100644 index 0000000..c3a6b9b --- /dev/null +++ b/designs/plans/2026-10-15-agent-chat-host.md @@ -0,0 +1,440 @@ +# Agent Chat (chat-v1) Host Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let Astation's browser (later mobile) chat page talk to the Claude Code session running in Atem's shared terminal, through structured `agentChat` v1 events built from Claude's own hooks and transcript. + +**Architecture:** A neutral chat core (`src/chat/session.rs`, `src/chat/wire.rs`) consumes neutral inputs from a per-agent adapter (`src/chat/adapter.rs`). v1 has one adapter, Claude Code (`src/chat/claude/`): per-launch hooks via `--settings` write one JSON file per event into a private directory; Claude's transcript JSONL is the only content source. The App wires the chat core into the existing TUI loop, Astation connection and agent registry. + +**Tech Stack:** Rust 2024 (bin crate `atem`), serde/serde_json, uuid, tokio (existing TUI loop), portable-pty (existing PtySession), tokio-tungstenite (tests' fake Astation, existing pattern). + +**Spec:** `designs/agent-chat-host.md` (branch `docs/agent-chat-host`). Read it first; this plan argues from it. + +## Global Constraints + +- Branch: `feat/agent-chat` created from `docs/agent-chat-host` (it already contains the spec). Commit messages end with the line `🤖 Built with SMT `. Never push. +- Content comes only from the transcript; hooks only mark lifecycle. Never scrape the terminal screen; never infer completion or waiting from silence; never start another Claude process; never approve tools; never resend a prompt. +- A chat prompt is refused (nothing typed) unless: matching `generation` and `agentId`; `requestId` new for the agent session; text ≤ 32 768 UTF-8 bytes containing only printable characters, `\n`, `\t`; status `ready` with no prompt awaiting acceptance; no terminal input since the last turn ended; no Astation viewer holding terminal control. +- Acceptance needs both the `UserPromptSubmit` hook event and the matching transcript user entry; none within 10 s → `error` "delivery unknown; check the terminal". +- Hook health: no `SessionStart` within 15 s of launch → chat withdrawn for that agent with a notice; terminal unaffected. +- Status heartbeat every 15 s re-sends the current state with its original `since`. +- History on `open`: latest 50 messages or 256 KB, older pages via `before`. Message cap 131 072 bytes (truncate with a note). Thinking, tool output and file contents are never sent; outgoing text and tool summaries pass `crate::memory::secrets` redaction (`[redacted]`). +- Markdown parts are CommonMark without raw HTML (sent as-is from Claude; clients render without HTML). +- Hook command (per event), exactly: `{ f=$(mktemp "/events/ev.XXXXXXXX") && cat > "$f" && mv "$f" "$f.json"; } 2>/dev/null; exit 0` with `` the per-session directory (shell-quoted). +- Per-session directory: `$XDG_RUNTIME_DIR/atem/chat-/` else `~/.config/atem/run/chat-/`; mode 0700; `settings.json` 0600; `events/` 0700; removed when the agent ends or Atem exits. +- Never rustfmt `src/cli.rs`, `src/websocket_client.rs`, `src/main.rs`, `src/app.rs`, `src/memory/crypto.rs`, `src/memory/mod.rs`; new files under `src/chat/` may be formatted with `rustfmt --edition 2024`; never project-wide `cargo fmt`. +- Every task: `cargo test` reading EVERY `test result` line (unit + `tests/*` binaries), and `cargo clippy --all-targets` with no new warnings. +- Never run the installed `atem`, never touch `~/.config/atem`, the user's running atem processes or key agent. Task 1 may run the real `claude` binary in a temporary directory. + +## File Structure + +| File | Responsibility | +|---|---| +| `src/chat/mod.rs` | module declarations only | +| `src/chat/adapter.rs` | `AgentAdapter` trait, `AgentEvent`, `LogItem`, `Part`, `Outcome`, `Capabilities`, `LaunchPlan` | +| `src/chat/claude/mod.rs` | `ClaudeAdapter` (implements `AgentAdapter`) | +| `src/chat/claude/hooks.rs` | per-session directory, `settings.json`, event-file reader, hook JSON → `AgentEvent` | +| `src/chat/claude/transcript.rs` | transcript follower, entry → `LogItem` | +| `src/chat/session.rs` | agent-neutral chat state: status, history, submit checks, acceptance, request records, outgoing events | +| `src/chat/wire.rs` | `agentChat` v1 types and validation | +| `src/pty_session.rs` | add an input counter to `PtyInput` and `send_paste` | +| `src/agent_client.rs` | `AgentStatus::Unknown`, `AgentInfo.capabilities` | +| `src/websocket_client.rs` | `AstationMessage::AgentChat` | +| `src/app.rs`, `src/app/chat.rs` (new) | wiring: launch, polling, message handling, registry status, closing conversations | +| `tests/fixtures/chat/claude/` | anonymised transcripts and hook events recorded in Task 1 | +| `tests/agent_chat_binary.rs` | end-to-end test with a fake `claude` and a fake Astation | + +--- + +### Task 1: Verify Claude Code's hooks and transcript, record fixtures + +**Files:** +- Create: `tests/fixtures/chat/claude/README.md`, `tests/fixtures/chat/claude/session-*.jsonl`, `tests/fixtures/chat/claude/events/*.json` +- Modify: `designs/agent-chat-host.md` (only if a finding contradicts it; add a short "Verified on Claude Code " note either way) + +**Interfaces:** +- Produces: fixture files with the exact formats later tasks parse; a README listing, for each hook event, the JSON fields observed (`hook_event_name`, `session_id`, `transcript_path`, `prompt`, `source`, `message`, `notification_type` if present, `tool_name`, `tool_input` keys), and the transcript entry shapes for: a user prompt, a meta entry, a slash-command entry, assistant `text`/`tool_use`/`thinking` blocks (one block per line sharing `message.id`, `message.stop_reason`), a tool result, an interrupt (user entry with `interruptedMessageId` and text `[Request interrupted by user]`), a `system`/`stop_hook_summary` entry. + +- [ ] **Step 1: Create the branch** + +```bash +git checkout docs/agent-chat-host && git checkout -b feat/agent-chat +``` + +- [ ] **Step 2: Run real Claude with per-launch hooks in a temp project** + +```bash +T=$(mktemp -d); mkdir -p "$T/proj" "$T/chat/events"; chmod 700 "$T/chat" "$T/chat/events" +CMD='{ f=$(mktemp "'"$T"'/chat/events/ev.XXXXXXXX") && cat > "$f" && mv "$f" "$f.json"; } 2>/dev/null; exit 0' +python3 - "$T" "$CMD" <<'EOF' +import json,sys +t,cmd=sys.argv[1],sys.argv[2] +ev=["SessionStart","UserPromptSubmit","PreToolUse","PostToolUse","Notification","Stop"] +h={e:[{"matcher":"","hooks":[{"type":"command","command":cmd}]}] if e in("PreToolUse","PostToolUse") else [{"hooks":[{"type":"command","command":cmd}]}] for e in ev} +json.dump({"hooks":h},open(f"{t}/chat/settings.json","w"),indent=1) +EOF +SID=$(python3 -c 'import uuid;print(uuid.uuid4())'); echo "$T $SID" +``` + +Then, interactively in a real terminal (or `script -q -c` / a PTY): `cd "$T/proj" && claude --settings "$T/chat/settings.json" --session-id "$SID"`. Before typing anything, check `ls "$T/chat/events"` shows one `SessionStart` file. Then: send a prompt that makes Claude run one harmless Bash command (e.g. "run `echo hi` with Bash then say done"), approve when asked (to see `Notification`), let it finish (`Stop`); send a second prompt and press Esc mid-answer (interrupt); `/clear` once (new `SessionStart`, new transcript); exit. + +Expected: every event file parses as JSON; `SessionStart` arrived before the first prompt with `session_id` = `$SID` and a `transcript_path` under `~/.claude/projects/` ending in `$SID.jsonl`. + +- [ ] **Step 3: Record anonymised fixtures** + +Copy the event files to `tests/fixtures/chat/claude/events/` (named `01-session-start.json`, `02-user-prompt-submit.json`, …, in arrival order) and the transcript(s) to `tests/fixtures/chat/claude/session-basic.jsonl` (and `session-after-clear.jsonl` if created). Anonymise: replace home paths with `/home/user`, cwd with `/home/user/proj`, hostnames/emails/tokens if any, keep all ids and structure. Remove `signature` values from thinking blocks (replace with `"sig"`). Delete the temp project and its `~/.claude/projects/` directory afterwards. + +- [ ] **Step 4: Write `tests/fixtures/chat/claude/README.md`** with the field lists described under Interfaces, the Claude Code version (`claude --version`), and any deviation from the spec. If a deviation affects the design (e.g. hooks do not fire in interactive mode, `SessionStart` comes after the first prompt, no event JSON on stdin), stop and report BLOCKED with the details instead of continuing. + +- [ ] **Step 5: Commit** + +```bash +git add tests/fixtures/chat designs/agent-chat-host.md +git commit -m "test(chat): Claude Code hook and transcript fixtures (verified on Claude Code ) + +🤖 Built with SMT " +``` + +--- + +### Task 2: Neutral adapter types and the Claude transcript reader + +**Files:** +- Create: `src/chat/mod.rs`, `src/chat/adapter.rs`, `src/chat/claude/mod.rs` (declarations only in this task), `src/chat/claude/transcript.rs` +- Modify: `src/main.rs` (add `mod chat;`) + +**Interfaces:** +- Consumes: fixtures from Task 1. +- Produces: + +```rust +// src/chat/adapter.rs +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Part { + Text { id: String, markdown: String }, + Tool { id: String, name: String, summary: String }, +} +impl Part { + pub fn id(&self) -> &str { match self { Part::Text { id, .. } | Part::Tool { id, .. } => id } } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { Done, Interrupted } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentEvent { + SessionStarted { session_id: String, log_path: PathBuf }, + PromptSubmitted { text: String }, + ToolStarted, + ToolFinished, + WaitingForPermission { detail: String }, + TurnEnded, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogItem { + /// A prompt as recorded in the log. `id` is the entry uuid. + UserMessage { id: String, turn_id: String, text: String, created_at_ms: i64 }, + /// One part of an assistant message (`message_id` groups parts). + AssistantPart { message_id: String, part: Part, turn_id: String, created_at_ms: i64 }, + /// The turn ended according to the log (end_turn or interrupt). + TurnEnded { turn_id: String, outcome: Outcome }, + /// Something could not be read; `fatal` = format changed, withdraw chat. + Problem { detail: String, fatal: bool }, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Capabilities { pub chat_v1: bool, pub reason: Option } + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LaunchPlan { pub extra_args: Vec, pub session_id: Option } + +pub trait AgentAdapter: Send { + fn prepare_launch(&mut self, args: &[String], dir: &Path) -> anyhow::Result; + fn poll_events(&mut self) -> Vec; + fn poll_log(&mut self) -> Vec; + fn capabilities(&self) -> Capabilities; +} +``` + +```rust +// src/chat/claude/transcript.rs +pub struct TranscriptReader { /* path, offset: u64, pending: Vec, seen: HashSet (bounded 10_000), turn_id: String */ } +impl TranscriptReader { + pub fn new(path: PathBuf) -> Self; + /// Read new complete lines since the last call and map them to LogItems. + pub fn poll(&mut self) -> Vec; + pub fn path(&self) -> &Path; +} +pub fn summarize_tool(name: &str, input: &serde_json::Value) -> String; // ≤120 chars +``` + +Mapping rules (from the spec and Task 1's README): +- Skip non-`user`/`assistant` types except `TurnEnded` signals below; skip `isSidechain: true` and `isMeta: true`. +- `user` with string content or `text` blocks: if text starts with ` PathBuf; // $XDG_RUNTIME_DIR/atem or ~/.config/atem/run +pub fn create_session_dir(root: &Path) -> anyhow::Result; // root/chat-, 0700, events/ 0700 +pub fn hook_command(dir: &Path) -> String; // the exact Global Constraints command, dir shell-quoted +pub fn write_settings(dir: &Path) -> anyhow::Result; // dir/settings.json, 0600 +pub struct EventReader { /* dir */ } +impl EventReader { + pub fn new(dir: PathBuf) -> Self; + /// Read events/*.json ordered by (mtime ns, name), delete each, map to AgentEvent. + /// Skips names without .json; removes non-.json leftovers older than 60 s. + pub fn poll(&mut self) -> Vec; +} +pub fn map_hook(json: &serde_json::Value) -> Option; + +// mod.rs +pub struct ClaudeAdapter { /* dir: Option, events: Option, transcript: Option, started_at: Instant, session_started: bool, withdrawn: Option */ } +impl ClaudeAdapter { pub fn new() -> Self; pub fn dir(&self) -> Option<&Path>; } +impl AgentAdapter for ClaudeAdapter { … } +impl Drop for ClaudeAdapter { fn drop(&mut self) { /* remove dir */ } } +``` + +Behaviour: +- `map_hook`: by `hook_event_name`: `SessionStart` → `SessionStarted { session_id, log_path: transcript_path }`; `UserPromptSubmit` → `PromptSubmitted { text: prompt }`; `PreToolUse` → `ToolStarted`; `PostToolUse` → `ToolFinished`; `Notification` → `WaitingForPermission { detail: message }` only when the notification is a permission request (use `notification_type` if Task 1 found it, else message text containing "permission"; idle reminders map to nothing); `Stop` → `TurnEnded`; anything else / missing fields → `None`. +- `prepare_launch(args, dir)`: write settings; `extra_args = ["--settings", ]`; unless `args` contains `--continue`, `-c`, `--resume`, `-r` or `--session-id`, mint a UUID and add `["--session-id", uuid]` (`session_id: Some(uuid)`). +- `poll_events`: read events; on `SessionStarted`, (re)open the `TranscriptReader` for `log_path` and set `session_started`. +- `poll_log`: transcript items; a `Problem { fatal: true }` sets `withdrawn`. +- `capabilities`: `chat_v1 = session_started && withdrawn.is_none()`; if not started and `started_at.elapsed() >= 15 s` → `reason: Some("Claude hooks are not active: disabled, --bare, or an unsupported version")`; `withdrawn` → that reason. +- Settings JSON shape: `{"hooks": {Event: [{"hooks":[{"type":"command","command": CMD}]}]}}`, with `"matcher": ""` added for `PreToolUse`/`PostToolUse` (as in Task 1's working file). + +- [ ] **Step 1: Failing tests** (`hooks.rs`, `mod.rs`): + - `hook_command_matches_the_spec_and_quotes_the_dir` (dir with a space). + - `settings_register_every_event_with_the_command` (parse the written file). + - `session_dir_and_files_are_private` (modes 0700/0600/0700). + - `hook_command_really_writes_one_event_file` — run `sh -c ""` with JSON on stdin (twice, concurrently via two threads) → two `.json` files, both parse; exit status 0; and with a read-only `events/` dir → still exit 0, no output. + - `events_are_read_in_order_and_deleted` (create files with increasing mtimes via `filetime`-free approach: write with small sleeps), `tmp_files_are_skipped_then_cleaned`. + - `map_hook_fixtures` — every fixture event maps as expected; unknown event → `None`; idle notification → `None`. + - `prepare_launch_pins_session_id_unless_resuming`. + - `capabilities_wait_for_session_start_then_withdraw_after_15s` (inject `started_at` via a test constructor). + - `session_start_opens_the_transcript` (end-to-end inside the adapter with fixture files). +- [ ] **Step 2:** run → fail. **Step 3:** implement. **Step 4:** `cargo test` (all binaries), clippy. **Step 5: Commit** `feat(chat): Claude hooks via event files and the Claude adapter`. + +--- + +### Task 4: The agent-neutral chat session + +**Files:** +- Create: `src/chat/session.rs` +- Modify: `src/pty_session.rs` (input counter + `send_paste`) + +**Interfaces:** +- Consumes: `AgentEvent`, `LogItem`, `Part`, `Outcome`. +- Produces: + +```rust +// pty_session.rs additions +impl PtyInput { + /// Count of send()/send_remote() calls (terminal input), not respond(). + pub fn input_count(&self) -> u64; + /// Bracketed paste + Enter; does not count as terminal input. + pub fn send_paste(&self, text: &str) -> anyhow::Result<()>; // "\x1b[200~" + text + "\x1b[201~" + "\r" +} + +// session.rs +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { Ready, Working, Waiting, Unknown } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Status { pub state: State, pub since_ms: i64, pub detail: Option } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatMessage { + pub message_id: String, pub role: Role, pub origin: Option, + pub turn_id: String, pub request_id: Option, pub created_at_ms: i64, + pub parts: Vec, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Role { User, Assistant } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Origin { Local, Remote } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Out { // what the App broadcasts to open conversations + Message(ChatMessage), + Delta { message_id: String, part: Part }, + Status(Status), + Accepted { request_id: String }, + Complete { turn_id: String, request_id: Option, outcome: Outcome }, + Error { text: String, request_id: Option }, +} + +pub enum SubmitCheck<'a> { /* inputs for checks, see submit() */ } + +pub struct ChatSession { /* generation: String, status, history: VecDeque (bounded 500 msgs), request records: HashMap (bounded 1000, oldest evicted), pending: Option, input_mark: u64, current_turn: Option<(String, Option)> */ } + +impl ChatSession { + pub fn new(generation: String, now_ms: i64) -> Self; // status Unknown until SessionStarted + pub fn generation(&self) -> &str; + pub fn status(&self) -> &Status; + pub fn on_event(&mut self, event: AgentEvent, now_ms: i64, input_count: u64) -> Vec; + pub fn on_log(&mut self, item: LogItem, now_ms: i64, input_count: u64) -> Vec; + /// Validate and, if allowed, return the text to paste (caller pastes with send_paste). + pub fn submit(&mut self, request_id: &str, generation: &str, text: &str, + input_count: u64, remote_controlled: bool) -> Result>; + pub fn tick(&mut self, now: std::time::Instant) -> Vec; // 10 s acceptance timeout + pub fn heartbeat(&self) -> Out; // Status(current) + pub fn history(&self, before: Option<&str>, max_messages: usize, max_bytes: usize) -> Vec; + pub fn on_process_exit(&mut self, now_ms: i64) -> Vec; // Unknown + Error, refuse further submits +} +pub fn redact(text: &str) -> String; // crate::memory::secrets based; replaces each detected value with [redacted] +pub fn clean_prompt(text: &str) -> Result<&str, &'static str>; // size and character rules +``` + +Behaviour: +- Status: `SessionStarted` → Ready (input_mark = input_count); `PromptSubmitted`/`ToolStarted` → Working; `WaitingForPermission` → Waiting (detail "approve in the terminal"); `TurnEnded` (event or log) → Ready, set input_mark = input_count, emit `Complete` once per turn (dedupe: the hook `TurnEnded` and the log `TurnEnded` for the same turn emit one `Complete`; the log's outcome wins when both arrive); `on_process_exit` → Unknown. Every change emits `Out::Status` with `since_ms = now_ms`. +- Messages: `LogItem::UserMessage` → `ChatMessage { role: User, origin: Remote + request_id if it matches the pending prompt's text (the pending prompt is then `log_seen`), else Local }`, set `current_turn = (turn_id, request_id)`; `AssistantPart` → first part of a `message_id` emits `Message` (role Assistant, `request_id` from `current_turn` if its turn matches), later parts emit `Delta`; a part id already in that message is ignored. All text passes `redact`; a message whose total text exceeds 131 072 bytes stops growing and gets a final text part "… (truncated)". +- Acceptance: when `pending` has both `hook_seen` (a `PromptSubmitted` whose text equals the pending text) and `log_seen` → `Accepted { request_id }`, record `RequestState::Accepted`; `TurnEnded` for that turn → record `Completed(outcome)`. +- `submit` order of checks and error texts (exact): + 1. generation mismatch → "the agent was restarted; reopen the chat" + 2. request seen → return the recorded outcome as `Out`s (`Accepted`, `Complete`, or the earlier `Error`), no paste + 3. `clean_prompt` failure → "prompt too long (max 32768 bytes)" / "prompt contains control characters" + 4. state != Ready or pending exists → "Claude is busy; wait for the current turn to finish" + 5. input_count != input_mark → "the terminal has unsent input; finish or clear it there" + 6. remote_controlled → "a terminal viewer has control; release it first" + On success: record `pending`, record request as `Pending`, return `Ok(text)`. +- `tick`: pending older than 10 s and not accepted → `Error { request_id, "delivery unknown; check the terminal" }`, record it, clear pending (never resend). +- `history`: newest-last list of whole messages (parts merged), at most `max_messages`/`max_bytes`, older than `before` when given. + +- [ ] **Step 1: Failing tests** (`session.rs`, plus `pty_session.rs` for the counter/paste using `PtyInput::test_channel()`): + - status table: each event/log item → expected state, `since` set, Status out emitted only on change. + - `turn_end_from_hook_and_log_completes_once`, `interrupt_outcome_wins`. + - `remote_prompt_is_accepted_only_after_hook_and_log` (either order), `local_prompt_is_origin_local_without_request_id`. + - `assistant_parts_become_message_then_deltas_and_repeats_are_ignored`, `assistant_reply_to_remote_prompt_carries_request_id`. + - submit checks 1–6 each with its exact error; `duplicate_request_returns_recorded_state_and_never_pastes`. + - `typing_after_turn_end_blocks_submit_and_next_turn_end_clears_it`. + - `delivery_unknown_after_10s_and_no_resend` (inject time via `tick(now)`). + - `history_respects_counts_bytes_and_before`. + - `redaction_masks_secret_values_in_text_and_tool_summaries` (use a value `crate::memory::secrets` detects, e.g. an `sk-ant-…`-style key used in that module's own tests). + - `process_exit_goes_unknown_and_refuses_submits`. + - `input_count_counts_send_and_send_remote_not_respond_or_paste`, `send_paste_wraps_in_bracketed_paste`. +- [ ] **Step 2:** fail. **Step 3:** implement. **Step 4:** `cargo test` (all), clippy. **Step 5: Commit** `feat(chat): agent-neutral chat session (status, acceptance, submit checks, history)`. + +--- + +### Task 5: The agentChat v1 wire + +**Files:** +- Create: `src/chat/wire.rs` +- Modify: `src/websocket_client.rs` (add the variant only; no rustfmt), `src/agent_client.rs` (`AgentStatus::Unknown`, `capabilities`) + +**Interfaces:** +- Consumes: `session::{Out, ChatMessage, Status, State, Role, Origin}`, `adapter::{Part, Outcome}`. +- Produces: + +```rust +// wire.rs — JSON shapes per the spec's "Wire" and "Message model" +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ChatEnvelope { + pub v: u32, pub op: String, pub conversation_id: String, pub agent_id: String, + #[serde(flatten)] pub body: serde_json::Map, +} +pub enum Inbound { Open { before: Option }, Submit { generation: String, request_id: String, text: String }, Close } +pub fn parse_inbound(env: &ChatEnvelope) -> Result; // v must be 1; unknown op → Err("unsupported op") +pub fn opened(conv: &str, agent: &str, generation: &str, status: &Status) -> ChatEnvelope; // sequence 0 +pub fn event(conv: &str, agent: &str, generation: &str, sequence: u64, out: &Out) -> ChatEnvelope; +pub fn history_message(conv: &str, agent: &str, generation: &str, sequence: u64, m: &ChatMessage) -> ChatEnvelope; // history: true +pub fn error(conv: &str, agent: &str, text: &str, request_id: Option<&str>) -> ChatEnvelope; // for pre-session errors (no generation) +``` + +JSON field names (camelCase): `messageId`, `role` ("user"/"assistant"), `origin` ("local"/"remote", user only), `turnId`, `requestId`, `createdAt` (unix ms), `parts` (`{"id","kind":"text","markdown"}` / `{"id","kind":"tool","name","summary"}`), `history`, `part` (delta), `state` ("ready"/"working"/"waiting"/"unknown"), `since` (unix ms), `detail`, `outcome` ("done"/"interrupted"), `text`, `generation`, `sequence`. + +```rust +// websocket_client.rs (inside AstationMessage) +#[serde(rename = "agentChat")] +AgentChat(crate::chat::wire::ChatEnvelope), + +// agent_client.rs +pub enum AgentStatus { Idle, Thinking, WaitingForInput, Disconnected, Unknown } +pub struct AgentInfo { …, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub capabilities: Vec } +``` + +- [ ] **Step 1: Failing tests**: round-trip every op; exact JSON for one of each `Out` (field names above); `parse_inbound` rejects `v != 1`, unknown op, missing fields, and over-long `requestId`/`conversationId` (> 128 chars); `AstationMessage::AgentChat` serialises as `{"type":"agentChat","data":{…}}` and parses back; the pinned agent-list JSON test (`test_agent_info_wire_bytes_for_astation`) stays byte-identical when `capabilities` is empty; a second test pins the shape with `capabilities: ["chat-v1"]`; `AgentStatus::Unknown` serialises as `"Unknown"`. +- [ ] **Step 2:** fail. **Step 3:** implement (`#[serde(rename_all…)]` structs for each body; build the `Map` from them). **Step 4:** `cargo test` (all), clippy. **Step 5: Commit** `feat(chat): agentChat v1 wire`. + +--- + +### Task 6: Wire chat into the App; end-to-end test; docs + +**Files:** +- Create: `src/app/chat.rs`, `tests/agent_chat_binary.rs`, `tests/fixtures/chat/fake-claude.sh` +- Modify: `src/app.rs` (fields, launch hook, message arm, loop call; no rustfmt), `src/tui/mod.rs` (call `poll_chat` each tick), `src/app/reauth.rs` (close conversations on suspend), AGENTS.md, README.md, npm/README.md (one short "Chat from Astation" paragraph) + +**Interfaces:** +- Consumes: everything above. +- Produces (App methods in `src/app/chat.rs`): + +```rust +pub struct AgentChat { pub agent_id: String, pub adapter: Box, pub session: ChatSession, + pub conversations: HashMap, pub last_heartbeat: Instant } +impl App { + /// Called from ensure_claude_session before spawning: prepares the adapter and returns extra args. + pub(crate) fn prepare_claude_chat(&mut self, args: &[String]) -> Vec; + /// Each TUI tick: poll adapter events/log, feed the session, tick timeouts/heartbeat, broadcast Outs, + /// update the registry status/capabilities, handle process exit. + pub async fn poll_chat(&mut self); + /// AstationMessage::AgentChat arm. + pub async fn handle_agent_chat(&mut self, env: ChatEnvelope); + /// On Astation disconnect / re-auth suspend: drop all conversations (Claude keeps running). + pub fn close_chat_conversations(&mut self); +} +``` + +Behaviour: +- Launch: in `ensure_claude_session`, before `start_session_sized`, create the session dir under `hooks::session_dir_root()`, call `prepare_claude_chat` and append its args to the spawn (do not mutate `claude_client`'s stored args permanently; pass them for this spawn). Generation = a new UUID per spawned process. On adapter preparation failure: log a notice, launch Claude without chat (unchanged behaviour). +- Registry: status mapping Ready→Idle, Working→Thinking, Waiting→WaitingForInput, Unknown→Unknown; `capabilities = ["chat-v1"]` exactly when `adapter.capabilities().chat_v1`; send an updated agent list to Astation when status or capabilities change (existing `send_agent_list`). +- `handle_agent_chat`: ignore while not authenticated or re-auth pending (already gated by the re-auth interceptor); unknown agent or no chat → `error` "chat is not available for this agent" (+ capability reason if any); `Open` → `opened` then history (`history(before, 50, 256*1024)`) as `history: true` messages with increasing sequence; register the conversation; `Submit` → `session.submit(..., claude_sender.input_count(), claude_sender.remote_controlled())`, on `Ok(text)` → `claude_sender.send_paste(&text)` (on write failure → `error` and clear pending); on `Err(outs)` → send them; `Close` → drop the conversation. +- `poll_chat` broadcasts every `Out` to every open conversation of that agent with its own next sequence; heartbeat `Status` every 15 s; process exit (the existing PTY receiver closing) → `on_process_exit`. +- Disconnect/re-auth: `close_chat_conversations` from the existing disconnect path and `suspend_astation`. +- Docs: AGENTS.md (source tree `src/chat/…`, a "Agent chat" paragraph: hooks via `--settings`, event files, transcript, statuses, the wire, capability; message list entry `AgentChat`); README.md and npm/README.md: one paragraph under AI Agents ("Astation can open a chat view of a Claude session launched with `atem agent launch claude`; prompts from chat are refused while you have unsent input in the terminal; approvals stay in the terminal"). + +`tests/fixtures/chat/fake-claude.sh` (POSIX sh, executable): parses `--settings ` and `--session-id `; extracts the hook command from the settings (`python3 -c` is acceptable in tests); emulates: on start write a transcript path `$TMPDIR/…/.jsonl` and run the hook with a `SessionStart` JSON; read lines from stdin (the PTY); on a bracketed-paste prompt: run `UserPromptSubmit` hook, append the user entry, `PreToolUse`/`PostToolUse` hooks, append an assistant `tool_use` line and an assistant `text` line with `stop_reason: end_turn` (same `message.id`), run `Stop`; on `exit` quit. + +- [ ] **Step 1: Failing end-to-end test** `tests/agent_chat_binary.rs` (pattern: `tests/terminal_ui_binary.rs` for running the binary in a PTY with `CLAUDE_CLI_BIN=`, `XDG_RUNTIME_DIR` and `HOME` pointed at temp dirs; and the existing fake Astation WebSocket pattern from `src/websocket_client.rs` tests for a local peer). Cases: + - `chat_open_submit_reply_complete` — open → `opened` (+ empty history), status ready; submit → `accepted`, user `message` (origin remote, requestId), assistant `message` with a tool part then `delta` text part, `complete {outcome: done, requestId}`, status ready; sequences contiguous. + - `submit_refused_after_local_typing` — type a character into the PTY, submit → error "the terminal has unsent input…". + - `duplicate_request_is_not_executed_twice`. + - `reconnect_restores_status_and_history` — close, reopen → history contains the exchange as `history: true`. + - `process_exit_reports_unknown`. + - `agent_list_advertises_chat_v1_after_session_start`. +- [ ] **Step 2:** fail. **Step 3:** implement `src/app/chat.rs` and the wiring; remove Task 2–5 `#[allow(dead_code)]` markers. **Step 4:** `cargo test` (read every `test result` line incl. the new binary), `cargo clippy --all-targets` (no new warnings), `./scripts/run-local-dev-tests.sh` (only the 3 known `atem list` failures). **Step 5: Commit** `feat(chat): chat-v1 host wired into the TUI, Astation and agent registry` and a separate `docs(chat): …` commit. diff --git a/npm/README.md b/npm/README.md index 6ecd2b0..f50bd8e 100644 --- a/npm/README.md +++ b/npm/README.md @@ -309,6 +309,15 @@ Ctrl+ other than i or m are accepted. The `CLAUDE_CLI_BIN`/`CLAUDE_CLI_A and `CODEX_CLI_BIN`/`CODEX_CLI_ARGS` variables still override the command. See [`designs/terminal-v1-implementation.md`](https://github.com/Agora-Build/Atem/blob/main/designs/terminal-v1-implementation.md). +**Chat from Astation.** Astation can open a chat view of a Claude session +launched with `atem agent launch claude`: your prompts appear as messages and +Claude's replies come from its own session log, with a status of ready, +working, waiting or unknown. Atem adds its hooks with `--settings` for that run +only; your `~/.claude` settings are not changed. Prompts from chat are refused +while you have unsent input in the terminal, and approvals stay in the +terminal. Codex has no chat yet (its status is unknown). See +[`designs/agent-chat-host.md`](https://github.com/Agora-Build/Atem/blob/main/designs/agent-chat-host.md). + ## TUI Modes | Mode | Description | diff --git a/src/agent_client.rs b/src/agent_client.rs index c48c646..6ef4fe5 100644 --- a/src/agent_client.rs +++ b/src/agent_client.rs @@ -50,6 +50,8 @@ pub enum AgentStatus { Thinking, WaitingForInput, Disconnected, + /// Activity cannot be detected. + Unknown, } // ── AgentInfo ───────────────────────────────────────────────────────────── @@ -69,6 +71,9 @@ pub struct AgentInfo { pub acp_url: Option, /// Present for PTY agents. pub pty_pid: Option, + /// Advertised features, e.g. `chat-v1`; omitted from the wire when empty. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, } // ── Tests ───────────────────────────────────────────────────────────────── @@ -98,6 +103,7 @@ mod tests { session_ids: vec!["sess-1".to_string()], acp_url: None, pty_pid: Some(4242), + capabilities: vec![], }; let json = serde_json::to_string(&info).unwrap(); @@ -122,10 +128,33 @@ mod tests { session_ids: vec![], acp_url: None, pty_pid: None, + capabilities: vec![], }; assert_eq!( serde_json::to_string(&info).unwrap(), r#"{"id":"claude-pty-1","name":"claude-code","kind":"ClaudeCode","protocol":"Pty","origin":"Launched","status":"Idle","session_ids":[],"acp_url":null,"pty_pid":null}"# ); } + + #[test] + fn test_agent_info_wire_with_capabilities_and_unknown() { + let info = AgentInfo { + id: "claude-pty-1".to_string(), + name: "claude-code".to_string(), + kind: AgentKind::ClaudeCode, + protocol: AgentProtocol::Pty, + origin: AgentOrigin::Launched, + status: AgentStatus::Unknown, + session_ids: vec![], + acp_url: None, + pty_pid: None, + capabilities: vec!["chat-v1".to_string()], + }; + assert_eq!( + serde_json::to_string(&info).unwrap(), + r#"{"id":"claude-pty-1","name":"claude-code","kind":"ClaudeCode","protocol":"Pty","origin":"Launched","status":"Unknown","session_ids":[],"acp_url":null,"pty_pid":null,"capabilities":["chat-v1"]}"# + ); + let back: AgentInfo = serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap(); + assert_eq!(back.capabilities, vec!["chat-v1".to_string()]); + } } diff --git a/src/agent_registry.rs b/src/agent_registry.rs index 7d27579..1ce4afb 100644 --- a/src/agent_registry.rs +++ b/src/agent_registry.rs @@ -114,6 +114,7 @@ mod tests { session_ids: vec![], acp_url: None, pty_pid: None, + capabilities: vec![], } } diff --git a/src/app.rs b/src/app.rs index 3acf400..9049b22 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,6 @@ +mod chat; +#[cfg(test)] +mod chat_tests; mod reauth; mod terminal; @@ -154,6 +157,12 @@ pub struct App { pub pending_astation_command: Option, /// Pending voice coding request waiting for Claude to finish. pub pending_voice_request: Option, + /// Chat (chat-v1) for the Claude session launched in Atem, when its adapter is prepared. + pub claude_chat: Option, + /// Where per-session chat directories are created (`chat-`). + pub chat_root: PathBuf, + /// Stale chat directories under `chat_root` were swept (once per App). + pub chat_swept: bool, } /// Tracks a command sent from Astation that needs output relayed back. @@ -270,6 +279,9 @@ impl App { astation_reauth: None, pending_astation_command: None, pending_voice_request: None, + claude_chat: None, + chat_root: crate::chat::claude::hooks::session_dir_root(), + chat_swept: false, }; app.rebuild_menu(); app @@ -583,6 +595,29 @@ impl App { self.codex_view_cols = cols; self.codex_terminal = VtParser::new(rows, cols, crate::pty_session::SCROLLBACK_ROWS); self.codex_waiting_exit = false; + + // Codex has no chat adapter yet: its activity can't be detected (Unknown) + // and it advertises no chat capability. + let agent_id = self + .agent_registry + .all() + .into_iter() + .find(|a| a.kind == AgentKind::Codex && a.origin == AgentOrigin::Launched) + .map(|a| a.id) + .unwrap_or_else(|| format!("codex-pty-{}", uuid::Uuid::new_v4())); + self.agent_registry.register(AgentInfo { + id: agent_id, + name: "codex".to_string(), + kind: AgentKind::Codex, + protocol: AgentProtocol::Pty, + origin: AgentOrigin::Launched, + status: AgentStatus::Unknown, + session_ids: vec![], + acp_url: None, + pty_pid: None, + capabilities: vec![], + }); + self.send_agent_list_if_connected().await; Ok(true) } @@ -931,7 +966,28 @@ impl App { let rows = if self.claude_view_rows == 0 { crate::pty_session::DEFAULT_ROWS } else { self.claude_view_rows }; let cols = if self.claude_view_cols == 0 { crate::pty_session::DEFAULT_COLS } else { self.claude_view_cols }; - let session = self.claude_client.start_session_sized(rows, cols).await?; + // A stable agent ID across respawns: reuse the launched Claude PTY's entry. + let existing_id = self + .agent_registry + .all() + .into_iter() + .find(|a| a.kind == AgentKind::ClaudeCode && a.origin == AgentOrigin::Launched) + .map(|a| a.id); + let agent_id = existing_id.unwrap_or_else(|| { + format!("claude-pty-{}", uuid::Uuid::new_v4()) + }); + // The previous process's conversations are told to reopen. + self.retire_claude_chat().await; + // Chat hooks for this process (a new generation); empty when chat could not be prepared. + let launch_args = self.claude_client.launch_args().to_vec(); + let chat_args = self.prepare_claude_chat(&agent_id, &launch_args); + let session = match self.claude_client.start_session_sized(rows, cols, &chat_args).await { + Ok(session) => session, + Err(error) => { + self.claude_chat = None; + return Err(error); + } + }; self.replace_terminal("claude"); self.claude_sender = Some(session.sender); self.claude_receiver = Some(session.receiver); @@ -948,27 +1004,19 @@ impl App { self.claude_waiting_exit = false; // Register (or re-register) the Claude PTY session in the agent registry. - // We generate a stable ID by checking if one already exists for a Launched - // Claude PTY; if not, mint a new one. - let existing_id = self - .agent_registry - .all() - .into_iter() - .find(|a| a.kind == AgentKind::ClaudeCode && a.origin == AgentOrigin::Launched) - .map(|a| a.id); - let agent_id = existing_id.unwrap_or_else(|| { - format!("claude-pty-{}", uuid::Uuid::new_v4()) - }); + // Status stays Unknown and chat-v1 unadvertised until Claude's hooks report + // the session (`poll_chat`). self.agent_registry.register(AgentInfo { id: agent_id.clone(), name: "claude-code".to_string(), kind: AgentKind::ClaudeCode, protocol: AgentProtocol::Pty, origin: AgentOrigin::Launched, - status: AgentStatus::Idle, + status: AgentStatus::Unknown, session_ids: vec![], acp_url: None, pty_pid: None, + capabilities: vec![], }); Ok(true) @@ -1528,6 +1576,9 @@ impl App { AstationMessage::Terminal(envelope) => { self.handle_terminal_message(envelope); } + AstationMessage::AgentChat(envelope) => { + self.handle_agent_chat(envelope).await; + } AstationMessage::AgentInput { agent_id, kind, @@ -1584,6 +1635,7 @@ impl App { /// so the grace-period logic in `CredentialStore::resolve` can take effect. pub fn on_astation_disconnect(&mut self) { self.disconnect_terminals(); + self.close_chat_conversations(); if let Some(aid) = self.connected_astation_id.take() { let mut store = crate::credentials::CredentialStore::load(); if let Some(entry) = store.find_paired_mut(&aid) { diff --git a/src/app/chat.rs b/src/app/chat.rs new file mode 100644 index 0000000..165dab7 --- /dev/null +++ b/src/app/chat.rs @@ -0,0 +1,469 @@ +//! chat-v1 host: per-agent chat state wired to the PTY, the agent registry and +//! Astation (see designs/agent-chat-host.md). v1 has one adapter, Claude Code. + +use super::App; +use crate::agent_client::AgentStatus; +use crate::chat::adapter::{AgentAdapter, AgentEvent, LogItem}; +use crate::chat::claude::{ClaudeAdapter, hooks}; +use crate::chat::session::{ChatSession, Out, State}; +use crate::chat::wire::{self, ChatEnvelope, Inbound}; +use crate::websocket_client::AstationMessage; +use std::collections::HashMap; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const HEARTBEAT: Duration = Duration::from_secs(15); +const HISTORY_MESSAGES: usize = 50; +const HISTORY_BYTES: usize = 256 * 1024; +const NOT_AVAILABLE: &str = "chat is not available for this agent"; +/// Sent instead of the OS error, which stays local. +const PASTE_FAILED: &str = "could not write to the terminal"; +const RESTARTED: &str = "the agent was restarted; reopen the chat"; +const SWITCHED: &str = "the conversation was switched; reopen the chat"; +/// Open conversations per agent; opening one more closes the oldest. +const MAX_CONVERSATIONS: usize = 8; +pub const CHAT_V1: &str = "chat-v1"; + +/// Chat for one agent process. A respawn gets a new one (new generation); the +/// adapter is dropped when the process ends, which removes its directory. +pub struct AgentChat { + pub agent_id: String, + /// `None` once the agent process has exited. + pub adapter: Option>, + pub session: ChatSession, + /// Open conversations by conversationId. + pub conversations: HashMap, + /// Counter for `Conversation::opened` (newest open has the largest). + opens: u64, + /// chat-v1 as of the last poll (what the registry advertises). + chat_v1: bool, + /// The adapter's "not available" reason was shown as a notice. + reason_noticed: bool, +} + +/// One open conversation (one client page). +pub struct Conversation { + /// Sequence of the next event. + pub next: u64, + /// The last time this conversation got the status (opened or heartbeat). + pub last_heartbeat: Instant, + /// Order of the last open, to close the oldest past `MAX_CONVERSATIONS`. + opened: u64, +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn registry_status(state: State) -> AgentStatus { + match state { + State::Ready => AgentStatus::Idle, + State::Working => AgentStatus::Thinking, + State::Waiting => AgentStatus::WaitingForInput, + State::Unknown => AgentStatus::Unknown, + } +} + +fn not_available(reason: Option<&str>) -> String { + match reason { + Some(reason) => format!("{NOT_AVAILABLE}: {reason}"), + None => NOT_AVAILABLE.to_string(), + } +} + +impl AgentChat { + /// Number `outs` for one conversation (its own sequence). + fn for_conversation(&mut self, conversation: &str, outs: &[Out]) -> Vec { + let Some(c) = self.conversations.get_mut(conversation) else { + return Vec::new(); + }; + let generation = self.session.generation(); + outs.iter() + .map(|out| { + let env = wire::event(conversation, &self.agent_id, generation, c.next, out); + c.next += 1; + env + }) + .collect() + } + + /// Tell every open conversation why it ends (`error`, then `close`) and drop them. + fn end_conversations(&mut self, text: &str) -> Vec { + let error = [Out::Error { + text: text.to_string(), + request_id: None, + }]; + let ids: Vec = self.conversations.keys().cloned().collect(); + let mut envelopes = Vec::new(); + for id in &ids { + envelopes.extend(self.for_conversation(id, &error)); + envelopes.push(wire::close(id, &self.agent_id)); + } + self.conversations.clear(); + envelopes + } + + /// Re-send the status to each conversation that has not had it for 15 s. + fn heartbeats(&mut self) -> Vec { + let due: Vec = self + .conversations + .iter() + .filter(|(_, c)| c.last_heartbeat.elapsed() >= HEARTBEAT) + .map(|(id, _)| id.clone()) + .collect(); + let status = [self.session.heartbeat()]; + let mut envelopes = Vec::new(); + for id in due { + envelopes.extend(self.for_conversation(&id, &status)); + if let Some(c) = self.conversations.get_mut(&id) { + c.last_heartbeat = Instant::now(); + } + } + envelopes + } + + /// Record an open (or reopen) of `id`, whose history ends at `next - 1`. + /// A new conversation past `MAX_CONVERSATIONS` closes the oldest one; its + /// `close` is returned. + fn open_conversation(&mut self, id: String, next: u64) -> Vec { + let mut envelopes = Vec::new(); + if !self.conversations.contains_key(&id) && self.conversations.len() >= MAX_CONVERSATIONS { + let oldest = self + .conversations + .iter() + .min_by_key(|(_, c)| c.opened) + .map(|(id, _)| id.clone()); + if let Some(oldest) = oldest { + self.conversations.remove(&oldest); + envelopes.push(wire::close(&oldest, &self.agent_id)); + } + } + self.opens += 1; + self.conversations.insert( + id, + Conversation { + next, + // The client just got the status; its next heartbeat is in 15 s. + last_heartbeat: Instant::now(), + opened: self.opens, + }, + ); + envelopes + } + + /// Number `outs` for every open conversation. + fn broadcast(&mut self, outs: &[Out]) -> Vec { + if outs.is_empty() { + return Vec::new(); + } + let ids: Vec = self.conversations.keys().cloned().collect(); + ids.iter() + .flat_map(|c| self.for_conversation(c, outs)) + .collect() + } +} + +impl App { + /// Before a Claude respawn: report the old process's last state (its exit), tell + /// its open conversations to reopen, and drop it (removing its directory). + pub(crate) async fn retire_claude_chat(&mut self) { + if self.claude_chat.is_none() { + return; + } + self.poll_chat().await; + if let Some(mut chat) = self.claude_chat.take() { + let envelopes = chat.end_conversations(RESTARTED); + self.send_chat(envelopes).await; + } + } + + /// Prepares chat for the Claude process about to be spawned and returns the + /// arguments to put first on its command line. Any earlier chat (an older + /// process) is dropped. On failure Claude starts without chat. + pub(crate) fn prepare_claude_chat(&mut self, agent_id: &str, args: &[String]) -> Vec { + self.claude_chat = None; + if !self.chat_swept { + // Directories of Atem processes that died without cleaning up. + self.chat_swept = true; + hooks::sweep_stale(&self.chat_root); + } + let dir = match hooks::create_session_dir(&self.chat_root) { + Ok(dir) => dir, + Err(error) => { + crate::diagnostics::warn(format!("Chat is off for Claude: {error}")); + return Vec::new(); + } + }; + let mut adapter = ClaudeAdapter::new(); + match adapter.prepare_launch(args, &dir) { + Ok(plan) => { + self.claude_chat = Some(AgentChat { + agent_id: agent_id.to_string(), + adapter: Some(Box::new(adapter)), + session: ChatSession::new(uuid::Uuid::new_v4().to_string(), now_ms()), + conversations: HashMap::new(), + opens: 0, + chat_v1: false, + reason_noticed: false, + }); + plan.extra_args + } + Err(error) => { + let _ = std::fs::remove_dir_all(&dir); + crate::diagnostics::warn(format!("Chat is off for Claude: {error}")); + Vec::new() + } + } + } + + /// Each tick: read the agent's events and log, update the session, send what + /// changed to open conversations, and keep the registry in step. + pub async fn poll_chat(&mut self) { + let input_count = self.claude_sender.as_ref().map_or(0, |s| s.input_count()); + // The PTY receiver is dropped when the process exits (finalize_claude_session). + let exited = self.claude_receiver.is_none(); + let Some(chat) = self.claude_chat.as_mut() else { + return; + }; + let mut outs = Vec::new(); + let mut notice = None; + // History from a log switched to (resume): clients must reload it. + let mut backfilled = false; + if let Some(adapter) = chat.adapter.as_mut() { + let now = now_ms(); + // Events first (SessionStarted opens the log), then the log, then turn + // ends, so `complete` follows the turn's content. + let mut ended = Vec::new(); + for event in adapter.poll_events() { + if matches!(event, AgentEvent::TurnEnded) { + ended.push(event); + } else { + outs.extend(chat.session.on_event(event, now, input_count)); + } + } + for item in adapter.poll_log() { + if let LogItem::Problem { detail, fatal } = &item { + // Fatal problems withdraw chat through capabilities() below. + if !fatal { + notice.get_or_insert_with(|| detail.clone()); + } + continue; + } + backfilled |= matches!(item, LogItem::Backfill(_)); + outs.extend(chat.session.on_log(item, now, input_count)); + } + for event in ended { + outs.extend(chat.session.on_event(event, now, input_count)); + } + } + if exited && chat.adapter.is_some() { + outs.extend(chat.session.on_process_exit(now_ms())); + chat.adapter = None; + } + outs.extend(chat.session.tick(Instant::now())); + + let caps = chat + .adapter + .as_ref() + .map(|a| a.capabilities()) + .unwrap_or_default(); + let withdrawn = chat.chat_v1 && !caps.chat_v1 && chat.adapter.is_some(); + if let Some(reason) = &caps.reason + && !chat.reason_noticed + { + chat.reason_noticed = true; + crate::diagnostics::warn(format!("Chat is off for Claude: {reason}")); + } + chat.chat_v1 = caps.chat_v1; + + let mut envelopes = chat.broadcast(&outs); + envelopes.extend(chat.heartbeats()); + if withdrawn { + envelopes.extend(chat.end_conversations(¬_available(caps.reason.as_deref()))); + } else if backfilled { + envelopes.extend(chat.end_conversations(SWITCHED)); + } + if let Some(detail) = notice { + crate::diagnostics::warn(format!("Chat: {detail}")); + } + self.send_chat(envelopes).await; + self.sync_chat_registry().await; + } + + /// Registry status and capabilities from the chat; tells Astation when they change. + async fn sync_chat_registry(&mut self) { + let Some(chat) = &self.claude_chat else { + return; + }; + let (status, capabilities) = if chat.chat_v1 { + ( + registry_status(chat.session.status().state), + vec![CHAT_V1.to_string()], + ) + } else { + (AgentStatus::Unknown, Vec::new()) + }; + let Some(mut info) = self.agent_registry.get(&chat.agent_id) else { + return; + }; + if info.status == status && info.capabilities == capabilities { + return; + } + info.status = status; + info.capabilities = capabilities; + self.agent_registry.register(info); + self.send_agent_list_if_connected().await; + } + + /// The agent list to Astation, when the link is authenticated. + pub(crate) async fn send_agent_list_if_connected(&self) { + if self.astation_connected { + let agents = self.agent_registry.all(); + let _ = self.astation_client.send_agent_list(agents).await; + } + } + + async fn send_chat(&mut self, envelopes: Vec) { + if !self.astation_connected { + return; + } + for env in envelopes { + let _ = self + .astation_client + .send_message(AstationMessage::AgentChat(env)) + .await; + } + } + + /// `agentChat` from Astation: open, submit or close a conversation. + pub async fn handle_agent_chat(&mut self, env: ChatEnvelope) { + // Only on an authenticated link (cleared while re-authenticating). + if !self.astation_connected || self.astation_client.is_ws_closed() { + return; + } + let conversation = env.conversation_id.clone(); + let agent = env.agent_id.clone(); + let inbound = match wire::parse_inbound(&env) { + Ok(inbound) => inbound, + Err(text) => { + let reply = wire::error(&conversation, &agent, &text, None); + return self.send_chat(vec![reply]).await; + } + }; + let request_id = match &inbound { + Inbound::Submit { request_id, .. } => Some(request_id.as_str()), + _ => None, + }; + let available = match &self.claude_chat { + Some(chat) if chat.agent_id == agent => match &chat.adapter { + // Exited: history and status stay readable; prompts are refused. + None => Ok(()), + Some(adapter) => { + let caps = adapter.capabilities(); + if caps.chat_v1 { + Ok(()) + } else { + Err(caps.reason) + } + } + }, + _ => Err(None), + }; + if let Err(reason) = available { + let text = not_available(reason.as_deref()); + let reply = wire::error(&conversation, &agent, &text, request_id); + return self.send_chat(vec![reply]).await; + } + let chat = self.claude_chat.as_mut().expect("checked above"); + let envelopes = match inbound { + Inbound::Open { before } => { + let generation = chat.session.generation().to_string(); + let mut envs = Vec::new(); + let mut history_envs = vec![wire::opened( + &conversation, + &agent, + &generation, + chat.session.status(), + )]; + let history = + chat.session + .history(before.as_deref(), HISTORY_MESSAGES, HISTORY_BYTES); + let mut sequence = 1; + for message in &history { + history_envs.push(wire::history_message( + &conversation, + &agent, + &generation, + sequence, + message, + )); + sequence += 1; + } + envs.extend(chat.open_conversation(conversation, sequence)); + envs.extend(history_envs); + envs + } + Inbound::Close => { + chat.conversations.remove(&conversation); + Vec::new() + } + Inbound::Submit { + generation, + request_id, + text, + } => { + if !chat.conversations.contains_key(&conversation) { + vec![wire::error( + &conversation, + &agent, + "open the conversation first", + Some(&request_id), + )] + } else { + let sender = self.claude_sender.as_ref(); + let input_count = sender.map_or(0, |s| s.input_count()); + let remote = sender.is_some_and(|s| s.remote_controlled()); + let outs = match chat.session.submit( + &request_id, + &generation, + &text, + input_count, + remote, + ) { + Ok(text) => { + let written = match sender { + Some(sender) => sender.send_paste(&text), + None => Err(anyhow::anyhow!("no terminal")), + }; + match written { + Ok(()) => { + chat.session.mark_pasted(&request_id, Instant::now()); + Vec::new() + } + Err(error) => { + crate::diagnostics::warn(format!( + "Chat prompt was not written to Claude: {error}" + )); + chat.session.paste_failed(&request_id, PASTE_FAILED) + } + } + } + Err(outs) => outs, + }; + chat.for_conversation(&conversation, &outs) + } + } + }; + self.send_chat(envelopes).await; + } + + /// Astation disconnected or is re-authenticating: drop every conversation + /// (nothing is sent). The agent keeps running; a new `open` restores state. + pub fn close_chat_conversations(&mut self) { + if let Some(chat) = self.claude_chat.as_mut() { + chat.conversations.clear(); + } + } +} diff --git a/src/app/chat_tests.rs b/src/app/chat_tests.rs new file mode 100644 index 0000000..183d585 --- /dev/null +++ b/src/app/chat_tests.rs @@ -0,0 +1,925 @@ +//! chat-v1 end to end, in process: the real App, a fake `claude` in a PTY +//! (tests/fixtures/chat/fake-claude.sh, which runs Atem's hook command and +//! writes a transcript) and a fake Astation WebSocket peer. +use super::App; +use crate::agent_client::{AgentKind, AgentStatus}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +const WAIT: Duration = Duration::from_secs(20); + +struct Harness { + app: App, + to_atem: mpsc::UnboundedSender, + from_atem: mpsc::UnboundedReceiver, + /// Everything Atem sent so far, in order. + seen: Vec, + root: tempfile::TempDir, +} + +fn fake_claude() -> PathBuf { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/chat/fake-claude.sh"); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + if perms.mode() & 0o111 != 0o111 { + perms.set_mode(0o755); + std::fs::set_permissions(&path, perms).unwrap(); + } + path +} + +async fn start() -> Harness { + let root = tempfile::tempdir().unwrap(); + let mut app = App::new(); + // Never the real $XDG_RUNTIME_DIR/atem or ~/.config/atem/run. + app.chat_root = root.path().join("run"); + app.claude_client + .set_command_for_test(fake_claude().to_string_lossy().into_owned(), Vec::new()); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (to_atem, mut outgoing) = mpsc::unbounded_channel::(); + let (incoming, from_atem) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + loop { + tokio::select! { + message = outgoing.recv() => { + let Some(text) = message else { break }; + if socket.send(WsMessage::Text(text)).await.is_err() { break; } + } + received = socket.next() => { + let Some(Ok(received)) = received else { break }; + if let WsMessage::Text(text) = received + && let Ok(value) = serde_json::from_str::(&text) + { + let _ = incoming.send(value); + } + } + } + } + }); + app.astation_client + .connect_raw(&format!("ws://{address}")) + .await + .unwrap(); + // This stand-in tests chat transport; authentication has separate tests. + app.astation_connected = true; + app.ensure_claude_session().await.unwrap(); + Harness { + app, + to_atem, + from_atem, + seen: Vec::new(), + root, + } +} + +fn is_chat(v: &Value, op: &str) -> bool { + v["type"] == "agentChat" && v["data"]["op"] == op +} + +impl Harness { + fn agent_id(&self) -> String { + self.app + .agent_registry + .all() + .into_iter() + .find(|a| a.kind == AgentKind::ClaudeCode) + .unwrap() + .id + } + + fn send_chat(&self, op: &str, conversation: &str, extra: Value) { + let mut data = json!({ + "v": 1, "op": op, "conversationId": conversation, "agentId": self.agent_id(), + }); + if let Value::Object(extra) = extra { + data.as_object_mut().unwrap().extend(extra); + } + let message = json!({ "type": "agentChat", "data": data }); + self.to_atem.send(message.to_string()).unwrap(); + } + + /// Run the App's loop until `done` matches a message Atem sent (searching + /// from `from`); returns that message's index in `seen`. + async fn pump_until(&mut self, from: usize, done: impl Fn(&Value) -> bool) -> usize { + let deadline = Instant::now() + WAIT; + let mut checked = from; + loop { + while let Some(message) = self.app.astation_client.recv_message() { + self.app.handle_astation_message(message).await; + } + self.app.process_claude_output(); + self.app.poll_chat().await; + while let Ok(value) = self.from_atem.try_recv() { + self.seen.push(value); + } + while checked < self.seen.len() { + if done(&self.seen[checked]) { + return checked; + } + checked += 1; + } + assert!( + Instant::now() < deadline, + "timed out; Atem sent: {:#?}", + &self.seen[from..] + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + /// Keep the loop running for `time` (to show nothing more happens). + async fn pump_for(&mut self, time: Duration) { + let deadline = Instant::now() + time; + while Instant::now() < deadline { + while let Some(message) = self.app.astation_client.recv_message() { + self.app.handle_astation_message(message).await; + } + self.app.process_claude_output(); + self.app.poll_chat().await; + while let Ok(value) = self.from_atem.try_recv() { + self.seen.push(value); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + async fn wait_for_chat(&mut self) { + let from = self.seen.len(); + self.pump_until(from, |v| { + v["type"] == "agentListResponse" + && v["data"]["agents"] + .as_array() + .unwrap() + .iter() + .any(|a| a["capabilities"] == json!(["chat-v1"])) + }) + .await; + } + + /// Open `conversation` and wait for `history` history messages; returns the + /// `opened` message and the history after it. + async fn open(&mut self, conversation: &str, history: u64) -> (Value, Vec) { + let from = self.seen.len(); + self.send_chat("open", conversation, json!({})); + let at = self + .pump_until(from, |v| { + is_chat(v, "opened") && v["data"]["conversationId"] == conversation + }) + .await; + if history > 0 { + let conversation = conversation.to_string(); + self.pump_until(at + 1, move |v| { + is_chat(v, "message") + && v["data"]["conversationId"] == conversation.as_str() + && v["data"]["history"] == true + && v["data"]["sequence"] == history + }) + .await; + } + let history = self.seen[at + 1..] + .iter() + .filter(|v| v["data"]["conversationId"] == conversation && v["data"]["history"] == true) + .cloned() + .collect(); + (self.seen[at].clone(), history) + } + + /// Submit and run until `complete` for it; returns the conversation's events from the submit on. + async fn submit_until_complete( + &mut self, + conversation: &str, + generation: &str, + request_id: &str, + text: &str, + ) -> Vec { + let from = self.seen.len(); + self.send_chat( + "submit", + conversation, + json!({"generation": generation, "requestId": request_id, "text": text}), + ); + let rid = request_id.to_string(); + let at = self + .pump_until(from, move |v| { + is_chat(v, "complete") && v["data"]["requestId"] == rid.as_str() + }) + .await; + // The turn's end also sets status ready, right after `complete`. + let conv = conversation.to_string(); + self.pump_until(at + 1, move |v| { + is_chat(v, "status") + && v["data"]["conversationId"] == conv.as_str() + && v["data"]["state"] == "ready" + }) + .await; + self.seen[from..] + .iter() + .filter(|v| v["type"] == "agentChat" && v["data"]["conversationId"] == conversation) + .cloned() + .collect() + } + + fn chat_dirs(&self) -> Vec { + let Ok(rd) = std::fs::read_dir(self.root.path().join("run")) else { + return Vec::new(); + }; + rd.flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .unwrap() + .to_string_lossy() + .starts_with("chat-") + }) + .collect() + } + + fn transcript_lines(&self) -> Vec { + let dir = self.root.path().join("run/transcripts"); + let mut lines = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let text = std::fs::read_to_string(entry.path()).unwrap(); + lines.extend(text.lines().map(str::to_string)); + } + lines + } + + async fn stop(mut self) { + // Ctrl+U first: clears a partly typed line in the fake's cooked-mode terminal. + self.app.send_claude_data("\x15exit\r"); + let deadline = Instant::now() + WAIT; + while self.app.claude_receiver.is_some() && Instant::now() < deadline { + self.app.process_claude_output(); + self.app.poll_chat().await; + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + self.app.claude_receiver.is_none(), + "fake claude did not exit" + ); + } +} + +impl Drop for Harness { + fn drop(&mut self) { + // Session logs Atem saves in the working directory when a session with output ends. + for path in [&self.app.claude_log_file, &self.app.claude_summary_file] + .into_iter() + .flatten() + { + let _ = std::fs::remove_file(path); + } + } +} + +fn age_heartbeat(h: &mut Harness, conversation: &str) { + let chat = h.app.claude_chat.as_mut().unwrap(); + chat.conversations + .get_mut(conversation) + .unwrap() + .last_heartbeat = Instant::now() - Duration::from_secs(16); +} + +fn sequences(events: &[Value]) -> Vec { + events + .iter() + .map(|v| v["data"]["sequence"].as_u64().unwrap()) + .collect() +} + +fn position(events: &[Value], f: impl Fn(&Value) -> bool) -> usize { + events + .iter() + .position(f) + .unwrap_or_else(|| panic!("not found in {events:#?}")) +} + +#[tokio::test] +async fn agent_list_advertises_chat_v1_after_session_start() { + let mut h = start().await; + let agent = h.app.agent_registry.get(&h.agent_id()).unwrap(); + // Registered before Claude's SessionStart could have been read. + assert!(agent.capabilities.is_empty()); + assert_eq!(agent.status, AgentStatus::Unknown); + h.wait_for_chat().await; + let list = h + .seen + .iter() + .rev() + .find(|v| v["type"] == "agentListResponse") + .unwrap(); + let claude = &list["data"]["agents"][0]; + assert_eq!(claude["status"], "Idle"); + assert_eq!(claude["capabilities"], json!(["chat-v1"])); + let agent = h.app.agent_registry.get(&h.agent_id()).unwrap(); + assert_eq!(agent.capabilities, vec!["chat-v1".to_string()]); + assert_eq!(h.chat_dirs().len(), 1); + h.stop().await; +} + +#[tokio::test] +async fn chat_open_submit_reply_complete() { + let mut h = start().await; + h.wait_for_chat().await; + let (opened, history) = h.open("c1", 0).await; + assert_eq!(opened["data"]["sequence"], 0); + assert_eq!(opened["data"]["status"]["state"], "ready"); + assert!(history.is_empty()); + let generation = opened["data"]["generation"].as_str().unwrap().to_string(); + + let events = h + .submit_until_complete("c1", &generation, "r1", "hello there") + .await; + for e in &events { + assert_eq!(e["data"]["generation"], generation.as_str(), "{e}"); + } + let seq = sequences(&events); + assert_eq!( + seq, + (1..=seq.len() as u64).collect::>(), + "{events:#?}" + ); + + let accepted = position(&events, |v| { + is_chat(v, "accepted") && v["data"]["requestId"] == "r1" + }); + let user = position(&events, |v| { + is_chat(v, "message") && v["data"]["role"] == "user" + }); + let u = &events[user]["data"]; + assert_eq!(u["origin"], "remote"); + assert_eq!(u["requestId"], "r1"); + assert_eq!(u["parts"][0]["markdown"], "hello there"); + let tool = position(&events, |v| { + is_chat(v, "message") && v["data"]["role"] == "assistant" + }); + let a = &events[tool]["data"]; + assert_eq!(a["messageId"], "msg-1"); + assert_eq!(a["requestId"], "r1"); + assert_eq!(a["parts"][0]["kind"], "tool"); + assert_eq!(a["parts"][0]["name"], "Bash"); + assert_eq!(a["parts"][0]["summary"], "echo hi"); + let delta = position(&events, |v| is_chat(v, "delta")); + assert_eq!(events[delta]["data"]["messageId"], "msg-1"); + assert_eq!(events[delta]["data"]["part"]["kind"], "text"); + assert_eq!(events[delta]["data"]["part"]["markdown"], "reply 1"); + let complete = position(&events, |v| is_chat(v, "complete")); + let c = &events[complete]["data"]; + assert_eq!(c["outcome"], "done"); + assert_eq!(c["requestId"], "r1"); + assert_eq!(c["turnId"], "p-1"); + assert!(user < tool && tool < delta && delta < complete); + assert!(accepted < complete); + let last_status = events.iter().rev().find(|v| is_chat(v, "status")).unwrap(); + assert_eq!(last_status["data"]["state"], "ready"); + assert!( + events + .iter() + .any(|v| is_chat(v, "status") && v["data"]["state"] == "working"), + "{events:#?}" + ); + h.stop().await; +} + +#[tokio::test] +async fn submit_refused_after_local_typing() { + let mut h = start().await; + h.wait_for_chat().await; + let (opened, _) = h.open("c1", 0).await; + let generation = opened["data"]["generation"].as_str().unwrap().to_string(); + // A key typed in Atem's own terminal view (the local input path). + h.app.send_claude_data("x"); + let from = h.seen.len(); + h.send_chat( + "submit", + "c1", + json!({"generation": generation, "requestId": "r1", "text": "hello"}), + ); + let at = h.pump_until(from, |v| is_chat(v, "error")).await; + let e = &h.seen[at]["data"]; + assert_eq!(e["requestId"], "r1"); + assert!( + e["text"] + .as_str() + .unwrap() + .starts_with("the terminal has unsent input"), + "{e}" + ); + assert_eq!(e["sequence"], 1); + h.pump_for(Duration::from_millis(300)).await; + assert!( + !h.transcript_lines() + .iter() + .any(|l| l.contains("\"type\":\"user\"")), + "nothing was typed" + ); + h.stop().await; +} + +#[tokio::test] +async fn duplicate_request_is_not_executed_twice() { + let mut h = start().await; + h.wait_for_chat().await; + let (opened, _) = h.open("c1", 0).await; + let generation = opened["data"]["generation"].as_str().unwrap().to_string(); + h.submit_until_complete("c1", &generation, "r1", "only once") + .await; + let from = h.seen.len(); + h.send_chat( + "submit", + "c1", + json!({"generation": generation, "requestId": "r1", "text": "only once"}), + ); + let at = h + .pump_until(from, |v| { + is_chat(v, "complete") && v["data"]["requestId"] == "r1" + }) + .await; + assert!(h.seen[from..at].iter().any(|v| is_chat(v, "accepted"))); + h.pump_for(Duration::from_millis(500)).await; + let prompts = h + .transcript_lines() + .iter() + .filter(|l| l.contains("\"content\":\"only once\"")) + .count(); + assert_eq!(prompts, 1, "the prompt was typed once"); + assert!( + !h.seen[from..] + .iter() + .any(|v| is_chat(v, "message") && v["data"]["role"] == "user"), + "no new user message" + ); + h.stop().await; +} + +#[tokio::test] +async fn reconnect_restores_status_and_history() { + let mut h = start().await; + h.wait_for_chat().await; + let (opened, _) = h.open("c1", 0).await; + let generation = opened["data"]["generation"].as_str().unwrap().to_string(); + h.submit_until_complete("c1", &generation, "r1", "remember me") + .await; + h.send_chat("close", "c1", json!({})); + + let (opened, history) = h.open("c1", 2).await; + assert_eq!(opened["data"]["generation"], generation.as_str()); + assert_eq!(opened["data"]["sequence"], 0); + assert_eq!(opened["data"]["status"]["state"], "ready"); + assert_eq!(sequences(&history), vec![1, 2]); + let user = &history[0]["data"]; + assert_eq!(user["role"], "user"); + assert_eq!(user["origin"], "remote"); + assert_eq!(user["requestId"], "r1"); + assert_eq!(user["parts"][0]["markdown"], "remember me"); + let reply = &history[1]["data"]; + assert_eq!(reply["role"], "assistant"); + assert_eq!(reply["messageId"], "msg-1"); + assert_eq!(reply["parts"].as_array().unwrap().len(), 2); + assert_eq!(reply["parts"][1]["markdown"], "reply 1"); + h.stop().await; +} + +#[tokio::test] +async fn process_exit_reports_unknown() { + let mut h = start().await; + h.wait_for_chat().await; + let (opened, _) = h.open("c1", 0).await; + let generation = opened["data"]["generation"].as_str().unwrap().to_string(); + assert_eq!(h.chat_dirs().len(), 1); + let from = h.seen.len(); + h.app.send_claude_data("exit\r"); + let at = h + .pump_until(from, |v| { + is_chat(v, "status") && v["data"]["state"] == "unknown" + }) + .await; + assert_eq!(h.seen[at]["data"]["generation"], generation.as_str()); + let at = h + .pump_until(from, |v| { + v["type"] == "agentListResponse" && v["data"]["agents"][0]["status"] == "Unknown" + }) + .await; + assert!( + h.seen[at]["data"]["agents"][0] + .get("capabilities") + .is_none() + ); + h.pump_until(from, |v| { + is_chat(v, "error") && v["data"]["text"] == "the agent exited" + }) + .await; + assert!(h.chat_dirs().is_empty(), "the chat directory is removed"); + // A prompt for the retired process is refused. + let from = h.seen.len(); + h.send_chat( + "submit", + "c1", + json!({"generation": generation, "requestId": "r9", "text": "hi"}), + ); + let at = h.pump_until(from, |v| is_chat(v, "error")).await; + assert_eq!(h.seen[at]["data"]["requestId"], "r9"); +} + +#[tokio::test] +async fn unknown_agent_and_bad_requests_get_errors() { + let mut h = start().await; + h.wait_for_chat().await; + let from = h.seen.len(); + let message = json!({"type": "agentChat", "data": { + "v": 1, "op": "open", "conversationId": "c1", "agentId": "nobody"}}); + h.to_atem.send(message.to_string()).unwrap(); + let at = h.pump_until(from, |v| is_chat(v, "error")).await; + assert_eq!( + h.seen[at]["data"]["text"], + "chat is not available for this agent" + ); + let from = h.seen.len(); + h.send_chat("submit", "c1", json!({"generation": "g"})); + let at = h.pump_until(from, |v| is_chat(v, "error")).await; + assert_eq!(h.seen[at]["data"]["text"], "missing field requestId"); + h.stop().await; +} + +#[tokio::test] +async fn disconnect_closes_conversations() { + let mut h = start().await; + h.wait_for_chat().await; + h.open("c1", 0).await; + assert_eq!(h.app.claude_chat.as_ref().unwrap().conversations.len(), 1); + h.app.on_astation_disconnect(); + assert!(h.app.claude_chat.as_ref().unwrap().conversations.is_empty()); + h.stop().await; +} + +#[tokio::test] +async fn heartbeat_resends_status_with_its_original_since() { + let mut h = start().await; + h.wait_for_chat().await; + let (opened, _) = h.open("c1", 0).await; + let since = opened["data"]["status"]["since"].clone(); + age_heartbeat(&mut h, "c1"); + let from = h.seen.len(); + let at = h.pump_until(from, |v| is_chat(v, "status")).await; + let status = &h.seen[at]["data"]; + assert_eq!(status["state"], "ready"); + assert_eq!(status["since"], since); + assert_eq!(status["sequence"], 1); + h.stop().await; +} + +#[tokio::test] +async fn fatal_log_problem_withdraws_chat() { + let mut h = start().await; + h.wait_for_chat().await; + h.open("c1", 0).await; + // The fake creates its (empty) transcript at start. A user entry without a + // uuid means the transcript format changed. + let transcript = std::fs::read_dir(h.root.path().join("run/transcripts")) + .unwrap() + .flatten() + .next() + .unwrap() + .path(); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&transcript) + .unwrap(); + std::io::Write::write_all( + &mut file, + b"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"}}\n", + ) + .unwrap(); + let from = h.seen.len(); + let at = h.pump_until(from, |v| is_chat(v, "error")).await; + assert_eq!( + h.seen[at]["data"]["text"], + "chat is not available for this agent: user transcript entry has no uuid" + ); + assert_eq!(h.seen[at]["data"]["conversationId"], "c1"); + let close = h.pump_until(from, |v| is_chat(v, "close")).await; + assert!(at < close); + assert_eq!(h.seen[close]["data"]["conversationId"], "c1"); + let at = h + .pump_until(from, |v| { + v["type"] == "agentListResponse" && v["data"]["agents"][0]["status"] == "Unknown" + }) + .await; + assert!( + h.seen[at]["data"]["agents"][0] + .get("capabilities") + .is_none() + ); + assert!(h.app.claude_chat.as_ref().unwrap().conversations.is_empty()); + // A new open is refused with the reason; the terminal keeps working. + let from = h.seen.len(); + h.send_chat("open", "c2", json!({})); + let at = h.pump_until(from, |v| is_chat(v, "error")).await; + assert_eq!( + h.seen[at]["data"]["text"], + "chat is not available for this agent: user transcript entry has no uuid" + ); + h.stop().await; +} + +#[tokio::test] +async fn codex_without_an_adapter_registers_unknown_without_chat() { + let mut app = App::new(); + let root = tempfile::tempdir().unwrap(); + app.chat_root = root.path().join("run"); + app.codex_client + .set_command_for_test("cat".into(), Vec::new()); + assert!(app.ensure_codex_session().await.unwrap()); + let codex = app + .agent_registry + .all() + .into_iter() + .find(|a| a.kind == AgentKind::Codex) + .unwrap(); + assert_eq!(codex.status, AgentStatus::Unknown); + assert!(codex.capabilities.is_empty()); + assert!(app.claude_chat.is_none()); + app.poll_chat().await; + assert_eq!( + app.agent_registry.get(&codex.id).unwrap().status, + AgentStatus::Unknown + ); + assert!( + !root.path().join("run").exists(), + "no chat directory for Codex" + ); +} + +#[tokio::test] +async fn claude_without_chat_registers_unknown() { + let mut app = App::new(); + let root = tempfile::tempdir().unwrap(); + // A file where the chat root's parent should be: preparing chat fails. + std::fs::write(root.path().join("file"), b"").unwrap(); + app.chat_root = root.path().join("file/run"); + app.claude_client + .set_command_for_test(fake_claude().to_string_lossy().into_owned(), Vec::new()); + assert!(app.ensure_claude_session().await.unwrap()); + assert!(app.claude_chat.is_none(), "Claude started without chat"); + let claude = app + .agent_registry + .all() + .into_iter() + .find(|a| a.kind == AgentKind::ClaudeCode) + .unwrap(); + assert_eq!(claude.status, AgentStatus::Unknown); + assert!(claude.capabilities.is_empty()); + // Without --settings the fake is plain `cat`: end its input. + app.send_claude_data("\x04"); + let deadline = Instant::now() + WAIT; + while app.claude_receiver.is_some() && Instant::now() < deadline { + app.process_claude_output(); + app.poll_chat().await; + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(app.claude_receiver.is_none(), "fake claude did not exit"); +} + +#[tokio::test] +async fn respawn_tells_open_conversations_to_reopen() { + let mut h = start().await; + h.wait_for_chat().await; + let (opened, _) = h.open("c1", 0).await; + let old_generation = opened["data"]["generation"].as_str().unwrap().to_string(); + let from = h.seen.len(); + h.app.send_claude_data("exit\r"); + h.pump_until(from, |v| { + is_chat(v, "status") && v["data"]["state"] == "unknown" + }) + .await; + assert!(h.app.claude_receiver.is_none()); + let from = h.seen.len(); + assert!(h.app.ensure_claude_session().await.unwrap()); + let error = h + .pump_until(from, |v| { + is_chat(v, "error") && v["data"]["text"] == "the agent was restarted; reopen the chat" + }) + .await; + assert_eq!(h.seen[error]["data"]["conversationId"], "c1"); + assert_eq!(h.seen[error]["data"]["generation"], old_generation.as_str()); + let close = h.pump_until(from, |v| is_chat(v, "close")).await; + assert!(error < close); + assert_eq!(h.seen[close]["data"]["conversationId"], "c1"); + assert!(h.app.claude_chat.as_ref().unwrap().conversations.is_empty()); + h.wait_for_chat().await; + let (opened, _) = h.open("c1", 0).await; + assert_ne!(opened["data"]["generation"], old_generation.as_str()); + assert_eq!(h.chat_dirs().len(), 1, "the old chat directory is gone"); + h.stop().await; +} + +#[tokio::test] +async fn opening_restarts_the_heartbeat_clock() { + let mut h = start().await; + h.wait_for_chat().await; + h.open("c1", 0).await; + age_heartbeat(&mut h, "c1"); + // reopening the same conversation + h.open("c1", 0).await; + let from = h.seen.len(); + h.pump_for(Duration::from_millis(200)).await; + assert!( + !h.seen[from..].iter().any(|v| is_chat(v, "status")), + "no heartbeat right after opened" + ); + h.stop().await; +} + +#[tokio::test] +async fn chat_is_ignored_while_offline_or_suspended() { + let mut h = start().await; + h.wait_for_chat().await; + h.app.astation_connected = false; + let from = h.seen.len(); + h.send_chat("open", "c1", json!({})); + h.pump_for(Duration::from_millis(300)).await; + assert!(!h.seen[from..].iter().any(|v| v["type"] == "agentChat")); + assert!(h.app.claude_chat.as_ref().unwrap().conversations.is_empty()); + + h.app.astation_connected = true; + h.open("c1", 0).await; + h.app.suspend_astation(); + assert!(h.app.claude_chat.as_ref().unwrap().conversations.is_empty()); + let from = h.seen.len(); + h.send_chat("open", "c2", json!({})); + h.pump_for(Duration::from_millis(300)).await; + assert!(!h.seen[from..].iter().any(|v| v["type"] == "agentChat")); + assert!(h.app.claude_chat.as_ref().unwrap().conversations.is_empty()); + h.stop().await; +} + +#[tokio::test] +async fn launching_codex_sends_the_agent_list() { + let mut h = start().await; + h.wait_for_chat().await; + h.app + .codex_client + .set_command_for_test("cat".into(), Vec::new()); + let from = h.seen.len(); + assert!(h.app.ensure_codex_session().await.unwrap()); + let at = h + .pump_until(from, |v| { + v["type"] == "agentListResponse" + && v["data"]["agents"] + .as_array() + .unwrap() + .iter() + .any(|a| a["kind"] == "Codex") + }) + .await; + let codex = h.seen[at]["data"]["agents"] + .as_array() + .unwrap() + .iter() + .find(|a| a["kind"] == "Codex") + .unwrap() + .clone(); + assert_eq!(codex["status"], "Unknown"); + assert!(codex.get("capabilities").is_none()); + h.stop().await; +} + +#[tokio::test] +async fn heartbeat_is_per_conversation() { + let mut h = start().await; + h.wait_for_chat().await; + h.open("c1", 0).await; + h.open("c2", 0).await; + age_heartbeat(&mut h, "c1"); + let from = h.seen.len(); + let at = h.pump_until(from, |v| is_chat(v, "status")).await; + assert_eq!(h.seen[at]["data"]["conversationId"], "c1"); + h.pump_for(Duration::from_millis(200)).await; + let statuses: Vec<&Value> = h.seen[from..] + .iter() + .filter(|v| is_chat(v, "status")) + .collect(); + assert_eq!(statuses.len(), 1, "only c1 was due: {statuses:#?}"); + h.stop().await; +} + +#[tokio::test] +async fn a_ninth_conversation_closes_the_oldest() { + let mut h = start().await; + h.wait_for_chat().await; + for i in 1..=8 { + h.open(&format!("c{i}"), 0).await; + } + // reopening c1 makes it the newest + h.open("c1", 0).await; + let from = h.seen.len(); + h.open("c9", 0).await; + let closes: Vec<&Value> = h.seen[from..] + .iter() + .filter(|v| is_chat(v, "close")) + .collect(); + assert_eq!(closes.len(), 1, "{closes:#?}"); + assert_eq!(closes[0]["data"]["conversationId"], "c2"); + assert!(!h.seen[from..].iter().any(|v| is_chat(v, "error"))); + let conversations = &h.app.claude_chat.as_ref().unwrap().conversations; + assert_eq!(conversations.len(), 8); + assert!(conversations.contains_key("c9") && conversations.contains_key("c1")); + assert!(!conversations.contains_key("c2")); + h.stop().await; +} + +#[tokio::test] +async fn resuming_another_session_closes_conversations_and_keeps_its_history() { + let mut h = start().await; + h.wait_for_chat().await; + let (opened, _) = h.open("c1", 0).await; + let generation = opened["data"]["generation"].as_str().unwrap().to_string(); + // Claude's /resume: a SessionStart naming another session's transcript. + let session = "0b5f3a3c-6e86-4a8c-9a59-4b7c1f0f2d11"; + let transcript = h + .root + .path() + .join("run/transcripts") + .join(format!("{session}.jsonl")); + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/chat/claude/session-basic.jsonl"); + std::fs::copy(fixture, &transcript).unwrap(); + let dir = h.chat_dirs().pop().unwrap(); + let start = json!({ + "hook_event_name": "SessionStart", "session_id": session, + "transcript_path": transcript, "source": "resume", + }); + std::fs::write(dir.join("events/ev.resume.json"), start.to_string()).unwrap(); + let from = h.seen.len(); + let error = h + .pump_until(from, |v| { + is_chat(v, "error") + && v["data"]["text"] == "the conversation was switched; reopen the chat" + }) + .await; + assert_eq!(h.seen[error]["data"]["conversationId"], "c1"); + let close = h.pump_until(error, |v| is_chat(v, "close")).await; + assert_eq!(h.seen[close]["data"]["conversationId"], "c1"); + // the old session's turns are history: no message, delta or complete was sent + assert!( + !h.seen[from..close] + .iter() + .any(|v| is_chat(v, "message") || is_chat(v, "delta") || is_chat(v, "complete")), + "{:#?}", + &h.seen[from..close] + ); + assert!(h.app.claude_chat.as_ref().unwrap().conversations.is_empty()); + // reopening shows the resumed session's history, same process generation + let (opened, _) = h.open("c1", 3).await; + assert_eq!(opened["data"]["generation"], generation.as_str()); + let history: Vec = h.seen[from..] + .iter() + .filter(|v| v["data"]["history"] == true && v["data"]["role"] == "user") + .cloned() + .collect(); + assert!( + history.iter().any(|v| v["data"]["parts"][0]["markdown"] + .as_str() + .unwrap() + .starts_with("run `echo hi`")), + "{history:#?}" + ); + h.stop().await; +} + +#[tokio::test] +async fn first_chat_sweeps_stale_directories_once() { + let root = tempfile::tempdir().unwrap(); + let run = root.path().join("run"); + let dead = || { + let mut child = std::process::Command::new("true").spawn().unwrap(); + let pid = child.id(); + child.wait().unwrap(); + pid.to_string() + }; + let stale = |name: &str| { + let dir = run.join(name); + std::fs::create_dir_all(dir.join("events")).unwrap(); + std::fs::write(dir.join("pid"), dead()).unwrap(); + dir + }; + let first = stale("chat-first"); + let unrelated = run.join("transcripts"); + std::fs::create_dir_all(&unrelated).unwrap(); + let mut app = App::new(); + app.chat_root = run.clone(); + assert!(!app.prepare_claude_chat("claude-1", &[]).is_empty()); + assert!(!first.exists()); + assert!(unrelated.exists()); + let second = stale("chat-second"); + assert!(!app.prepare_claude_chat("claude-1", &[]).is_empty()); + assert!(second.exists(), "swept once per App"); +} diff --git a/src/app/reauth.rs b/src/app/reauth.rs index 2492c0b..0f29148 100644 --- a/src/app/reauth.rs +++ b/src/app/reauth.rs @@ -92,11 +92,12 @@ impl App { } /// The socket is open but no longer authenticated: offline, remote - /// terminals revoked, Astation-routed work dropped. The local agent PTY - /// and anything typed into it are left alone. - fn suspend_astation(&mut self) { + /// terminals revoked, chat conversations closed, Astation-routed work + /// dropped. The local agent PTY and anything typed into it are left alone. + pub(super) fn suspend_astation(&mut self) { self.astation_connected = false; self.disconnect_terminals(); + self.close_chat_conversations(); self.pending_astation_command = None; self.pending_voice_request = None; self.rebuild_menu(); diff --git a/src/app/terminal/live_tests.rs b/src/app/terminal/live_tests.rs index 1bbe47d..1278018 100644 --- a/src/app/terminal/live_tests.rs +++ b/src/app/terminal/live_tests.rs @@ -26,7 +26,10 @@ async fn live_claude_websocket_conversation() { } async fn conversation(provider: &'static str) { + // Never the real $XDG_RUNTIME_DIR/atem or ~/.config/atem/run; dropped after the App. + let chat_root = tempfile::tempdir().unwrap(); let mut app = App::new(); + app.chat_root = chat_root.path().join("run"); if provider == "codex" { app.ensure_codex_session().await.unwrap(); app.mode = AppMode::CodexChat; diff --git a/src/chat/adapter.rs b/src/chat/adapter.rs new file mode 100644 index 0000000..bd46dbd --- /dev/null +++ b/src/chat/adapter.rs @@ -0,0 +1,94 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Part { + Text { + id: String, + markdown: String, + }, + Tool { + id: String, + name: String, + summary: String, + }, +} +impl Part { + pub fn id(&self) -> &str { + match self { + Part::Text { id, .. } | Part::Tool { id, .. } => id, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Done, + Interrupted, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentEvent { + SessionStarted { + session_id: String, + log_path: PathBuf, + /// Why the session (re)started, as the agent names it (Claude: `startup`, + /// `resume`, `clear`, `compact`); empty when not given. + source: String, + /// The adapter keeps reading the same log (compaction, or a resume of the + /// session already shown): the turn in progress continues. + same_log: bool, + }, + PromptSubmitted { + text: String, + }, + ToolStarted, + ToolFinished, + WaitingForPermission { + detail: String, + }, + TurnEnded, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogItem { + /// A prompt as recorded in the log. `id` is the entry uuid. + UserMessage { + id: String, + turn_id: String, + text: String, + created_at_ms: i64, + }, + /// One part of an assistant message (`message_id` groups parts). + AssistantPart { + message_id: String, + part: Part, + turn_id: String, + created_at_ms: i64, + }, + /// The turn ended according to the log (end_turn or interrupt). + TurnEnded { turn_id: String, outcome: Outcome }, + /// Content that was already in a log the adapter switched to (a resume of + /// another session): history only, never live events. Never nested. + Backfill(Box), + /// Something could not be read; `fatal` = format changed, withdraw chat. + Problem { detail: String, fatal: bool }, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Capabilities { + pub chat_v1: bool, + pub reason: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LaunchPlan { + pub extra_args: Vec, + pub session_id: Option, +} + +pub trait AgentAdapter: Send { + fn prepare_launch(&mut self, args: &[String], dir: &Path) -> anyhow::Result; + fn poll_events(&mut self) -> Vec; + fn poll_log(&mut self) -> Vec; + fn capabilities(&self) -> Capabilities; +} diff --git a/src/chat/claude/hooks.rs b/src/chat/claude/hooks.rs new file mode 100644 index 0000000..830551d --- /dev/null +++ b/src/chat/claude/hooks.rs @@ -0,0 +1,625 @@ +//! Claude Code hooks: settings file, hook command, and the event-file reader. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::chat::adapter::AgentEvent; + +pub const HOOK_EVENTS: [&str; 6] = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Notification", + "Stop", +]; + +/// `$XDG_RUNTIME_DIR/atem` when set and non-empty, else `~/.config/atem/run`. +pub fn session_dir_root() -> PathBuf { + match std::env::var_os("XDG_RUNTIME_DIR") { + Some(v) if !v.is_empty() => PathBuf::from(v).join("atem"), + _ => crate::config::AtemConfig::config_dir().join("run"), + } +} + +fn private_dir(path: &Path) -> anyhow::Result<()> { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +/// A chat directory without a `pid` file is stale after this long. +const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(3600); + +/// `root/chat-` (0700) with an `events/` subdirectory (0700) and a `pid` +/// file (0600) naming this process and host, for `sweep_stale`. +pub fn create_session_dir(root: &Path) -> anyhow::Result { + private_dir(root)?; + let dir = root.join(format!("chat-{}", uuid::Uuid::new_v4())); + private_dir(&dir)?; + private_dir(&dir.join("events"))?; + let owner = format!("{}\n{}\n", std::process::id(), crate::auth::get_hostname()); + write_private(&dir.join("pid"), owner.as_bytes())?; + Ok(dir) +} + +/// Create or replace `path` (0600) with `body`. +fn write_private(path: &Path, body: &[u8]) -> anyhow::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(body)?; + Ok(()) +} + +/// Remove chat directories left by Atem processes that are gone (crash, +/// SIGKILL): `root/chat-*` whose `pid` file names a dead process on this host, +/// or that have no readable `pid` file and are older than an hour. A directory +/// owned by another host (a shared home) is never removed. Nothing else under +/// `root` is touched and no symlink is followed. +pub fn sweep_stale(root: &Path) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + let now = std::time::SystemTime::now(); + for entry in entries.flatten() { + if !entry.file_name().to_string_lossy().starts_with("chat-") { + continue; + } + let path = entry.path(); + // symlink_metadata: a symlink is not a directory here, so it is skipped. + let Ok(meta) = std::fs::symlink_metadata(&path) else { + continue; + }; + if !meta.is_dir() { + continue; + } + let stale = match read_pid(&path.join("pid")) { + Some((_, Some(host))) if host != crate::auth::get_hostname() => false, + Some((pid, _)) => !process_alive(pid), + None => meta + .modified() + .ok() + .and_then(|t| now.duration_since(t).ok()) + .is_some_and(|age| age > STALE_AFTER), + }; + if stale { + // remove_dir_all does not follow symlinks inside the directory. + let _ = std::fs::remove_dir_all(&path); + } + } +} + +/// The pid and host in a regular `pid` file (never through a symlink). +fn read_pid(path: &Path) -> Option<(libc::pid_t, Option)> { + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + let mut text = String::new(); + file.by_ref().take(512).read_to_string(&mut text).ok()?; + let mut lines = text.lines(); + let pid: libc::pid_t = lines.next()?.trim().parse().ok()?; + let host = lines + .next() + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()); + (pid > 0).then_some((pid, host)) +} + +/// False only when the process is known to be gone (ESRCH). +fn process_alive(pid: libc::pid_t) -> bool { + // SAFETY: signal 0 only checks that the process exists; nothing is sent. + let rc = unsafe { libc::kill(pid, 0) }; + rc == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +} + +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// The hook command: one event file per event, silent, always exit 0. +pub fn hook_command(dir: &Path) -> String { + let template = format!("{}/events/ev.XXXXXXXX", dir.display()); + format!( + "{{ f=$(mktemp {}) && cat > \"$f\" && mv \"$f\" \"$f.json\"; }} 2>/dev/null; exit 0", + shell_quote(&template) + ) +} + +/// Write `dir/settings.json` (0600) registering the hook for every event. +pub fn write_settings(dir: &Path) -> anyhow::Result { + let cmd = hook_command(dir); + let mut hooks = serde_json::Map::new(); + for ev in HOOK_EVENTS { + let mut entry = serde_json::Map::new(); + if ev == "PreToolUse" || ev == "PostToolUse" { + entry.insert("matcher".into(), "".into()); + } + entry.insert( + "hooks".into(), + serde_json::json!([{ "type": "command", "command": cmd }]), + ); + hooks.insert(ev.into(), Value::Array(vec![Value::Object(entry)])); + } + let body = serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks }))?; + let path = dir.join("settings.json"); + write_private(&path, body.as_bytes())?; + Ok(path) +} + +pub struct EventReader { + dir: PathBuf, +} + +impl EventReader { + pub fn new(dir: PathBuf) -> Self { + Self { dir } + } + + /// Read events/*.json ordered by (mtime ns, name), delete each, map to AgentEvent. + /// Skips names without .json; removes non-.json leftovers older than 60 s. + pub fn poll(&mut self) -> Vec { + let Ok(rd) = std::fs::read_dir(self.dir.join("events")) else { + return Vec::new(); + }; + let now = std::time::SystemTime::now(); + let mut files = Vec::new(); + for e in rd.flatten() { + let path = e.path(); + let Ok(meta) = e.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH); + if path.extension().is_some_and(|x| x == "json") { + files.push((mtime, e.file_name(), path)); + } else if now + .duration_since(mtime) + .is_ok_and(|age| age > std::time::Duration::from_secs(60)) + { + let _ = std::fs::remove_file(&path); + } + } + files.sort(); + let mut out = Vec::new(); + for (mtime, _, path) in files { + match std::fs::read_to_string(&path) { + Ok(text) => { + let _ = std::fs::remove_file(&path); + if let Ok(v) = serde_json::from_str::(&text) { + out.extend(map_hook(&v)); + } + } + Err(_) => { + // Unreadable: keep it for now, drop it once it is stale. + if now + .duration_since(mtime) + .is_ok_and(|age| age > std::time::Duration::from_secs(60)) + { + let _ = std::fs::remove_file(&path); + } + } + } + } + out + } +} + +pub fn map_hook(json: &Value) -> Option { + let s = |k: &str| json.get(k).and_then(Value::as_str); + match s("hook_event_name")? { + "SessionStart" => Some(AgentEvent::SessionStarted { + session_id: s("session_id")?.to_string(), + log_path: PathBuf::from(s("transcript_path")?), + source: s("source").unwrap_or_default().to_string(), + // The adapter decides; it knows which log it is reading. + same_log: false, + }), + "UserPromptSubmit" => Some(AgentEvent::PromptSubmitted { + text: s("prompt")?.to_string(), + }), + "PreToolUse" => Some(AgentEvent::ToolStarted), + "PostToolUse" => Some(AgentEvent::ToolFinished), + "Notification" => { + let message = s("message").unwrap_or_default(); + let permission = match s("notification_type") { + Some(t) => t == "permission_prompt", + None => message.to_lowercase().contains("permission"), + }; + permission.then(|| AgentEvent::WaitingForPermission { + detail: message.to_string(), + }) + } + "Stop" => Some(AgentEvent::TurnEnded), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::process::{Command, Stdio}; + use std::time::{Duration, SystemTime}; + + fn mode(p: &Path) -> u32 { + fs::metadata(p).unwrap().permissions().mode() & 0o777 + } + + fn fixture(name: &str) -> Value { + let p = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/chat/claude/events") + .join(name); + serde_json::from_str(&fs::read_to_string(p).unwrap()).unwrap() + } + + #[test] + fn hook_command_matches_the_spec_and_quotes_the_dir() { + let cmd = hook_command(Path::new("/tmp/a b/it's")); + assert_eq!( + cmd, + "{ f=$(mktemp '/tmp/a b/it'\\''s/events/ev.XXXXXXXX') && cat > \"$f\" && mv \"$f\" \"$f.json\"; } 2>/dev/null; exit 0" + ); + } + + #[test] + fn settings_register_every_event_with_the_command() { + let root = tempfile::tempdir().unwrap(); + let dir = create_session_dir(root.path()).unwrap(); + let path = write_settings(&dir).unwrap(); + assert_eq!(path, dir.join("settings.json")); + let v: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let cmd = hook_command(&dir); + for ev in HOOK_EVENTS { + let entry = &v["hooks"][ev][0]; + assert_eq!(entry["hooks"][0]["type"], "command", "{ev}"); + assert_eq!(entry["hooks"][0]["command"], cmd.as_str(), "{ev}"); + if ev == "PreToolUse" || ev == "PostToolUse" { + assert_eq!(entry["matcher"], "", "{ev}"); + } else { + assert!(entry.get("matcher").is_none(), "{ev}"); + } + } + assert_eq!(v["hooks"].as_object().unwrap().len(), 6); + } + + fn dead_pid() -> u32 { + let mut child = Command::new("true").spawn().unwrap(); + let pid = child.id(); + child.wait().unwrap(); + pid + } + + fn age_dir(path: &Path, secs: u64) { + let t = SystemTime::now() - Duration::from_secs(secs); + fs::File::open(path).unwrap().set_modified(t).unwrap(); + } + + #[test] + fn session_dir_records_this_process() { + let root = tempfile::tempdir().unwrap(); + let dir = create_session_dir(root.path()).unwrap(); + let pid = dir.join("pid"); + assert_eq!( + fs::read_to_string(&pid).unwrap(), + format!("{}\n{}\n", std::process::id(), crate::auth::get_hostname()) + ); + assert_eq!(mode(&pid), 0o600); + } + + #[test] + fn sweep_keeps_dirs_owned_by_another_host() { + let root = tempfile::tempdir().unwrap(); + let other = create_session_dir(root.path()).unwrap(); + let dead = dead_pid(); + fs::write(other.join("pid"), format!("{dead}\nsome-other-host\n")).unwrap(); + let here = create_session_dir(root.path()).unwrap(); + let host = crate::auth::get_hostname(); + fs::write(here.join("pid"), format!("{dead}\n{host}\n")).unwrap(); + sweep_stale(root.path()); + assert!(other.exists(), "another host's live atem may own it"); + assert!(!here.exists()); + } + + #[test] + fn sweep_removes_only_stale_chat_dirs() { + let root = tempfile::tempdir().unwrap(); + let r = root.path(); + let mine = create_session_dir(r).unwrap(); + let dead = create_session_dir(r).unwrap(); + fs::write(dead.join("pid"), dead_pid().to_string()).unwrap(); + // no pid file: removed only once older than an hour + let old = r.join("chat-old"); + let young = r.join("chat-young"); + for d in [&old, &young] { + fs::create_dir_all(d.join("events")).unwrap(); + } + age_dir(&old, 2 * 3600); + // not ours to touch + let other = r.join("transcripts"); + fs::create_dir_all(&other).unwrap(); + fs::write(other.join("pid"), dead_pid().to_string()).unwrap(); + age_dir(&other, 2 * 3600); + let file = r.join("chat-file"); + fs::write(&file, dead_pid().to_string()).unwrap(); + // a symlink named chat-* to a stale-looking dir is never followed + let target = tempfile::tempdir().unwrap(); + fs::write(target.path().join("pid"), dead_pid().to_string()).unwrap(); + let link = r.join("chat-link"); + std::os::unix::fs::symlink(target.path(), &link).unwrap(); + // a pid file that is a symlink is not read either + let linked_pid = r.join("chat-linkedpid"); + fs::create_dir_all(&linked_pid).unwrap(); + let pid_target = target.path().join("pidfile"); + fs::write(&pid_target, dead_pid().to_string()).unwrap(); + std::os::unix::fs::symlink(&pid_target, linked_pid.join("pid")).unwrap(); + + sweep_stale(r); + assert!(mine.exists()); + assert!(!dead.exists()); + assert!(!old.exists()); + assert!(young.exists()); + assert!(other.exists()); + assert!(file.exists()); + assert!(fs::symlink_metadata(&link).is_ok()); + assert!(target.path().join("pid").exists()); + assert!(linked_pid.exists()); + assert!(pid_target.exists()); + // a missing root is fine + sweep_stale(&r.join("missing")); + } + + #[test] + fn session_dir_and_files_are_private() { + let root = tempfile::tempdir().unwrap(); + let dir = create_session_dir(root.path()).unwrap(); + assert!(dir.starts_with(root.path())); + assert!( + dir.file_name() + .unwrap() + .to_string_lossy() + .starts_with("chat-") + ); + assert_eq!(mode(&dir), 0o700); + assert_eq!(mode(&dir.join("events")), 0o700); + let s = write_settings(&dir).unwrap(); + assert_eq!(mode(&s), 0o600); + } + + fn run_hook(cmd: &str, input: &str) -> std::process::Output { + use std::io::Write; + let mut child = Command::new("sh") + .arg("-c") + .arg(cmd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() + } + + #[test] + fn hook_command_really_writes_one_event_file() { + let root = tempfile::tempdir().unwrap(); + let dir = create_session_dir(root.path()).unwrap(); + let cmd = hook_command(&dir); + let handles: Vec<_> = (0..2) + .map(|i| { + let cmd = cmd.clone(); + std::thread::spawn(move || run_hook(&cmd, &format!("{{\"n\":{i}}}"))) + }) + .collect(); + for h in handles { + let out = h.join().unwrap(); + assert!(out.status.success()); + assert!(out.stdout.is_empty() && out.stderr.is_empty()); + } + let files: Vec<_> = fs::read_dir(dir.join("events")).unwrap().collect(); + assert_eq!(files.len(), 2); + for f in files { + let p = f.unwrap().path(); + assert_eq!(p.extension().unwrap(), "json"); + let _: Value = serde_json::from_str(&fs::read_to_string(p).unwrap()).unwrap(); + } + // Unwritable events dir: still exit 0 and silent. + fs::set_permissions(dir.join("events"), fs::Permissions::from_mode(0o500)).unwrap(); + let out = run_hook(&cmd, "{}"); + fs::set_permissions(dir.join("events"), fs::Permissions::from_mode(0o700)).unwrap(); + assert!(out.status.success()); + assert!(out.stdout.is_empty() && out.stderr.is_empty()); + } + + fn put(dir: &Path, name: &str, body: &str, age_secs: u64) { + let p = dir.join("events").join(name); + fs::write(&p, body).unwrap(); + let t = SystemTime::now() - Duration::from_secs(age_secs); + fs::File::options() + .write(true) + .open(&p) + .unwrap() + .set_modified(t) + .unwrap(); + } + + #[test] + fn events_are_read_in_order_and_deleted() { + let root = tempfile::tempdir().unwrap(); + let dir = create_session_dir(root.path()).unwrap(); + let stop = r#"{"hook_event_name":"Stop"}"#; + let tool = r#"{"hook_event_name":"PreToolUse"}"#; + let prompt = r#"{"hook_event_name":"UserPromptSubmit","prompt":"hi"}"#; + put(&dir, "ev.a.json", stop, 1); + put(&dir, "ev.b.json", prompt, 3); + put(&dir, "ev.c.json", tool, 2); + let mut r = EventReader::new(dir.clone()); + assert_eq!( + r.poll(), + vec![ + AgentEvent::PromptSubmitted { text: "hi".into() }, + AgentEvent::ToolStarted, + AgentEvent::TurnEnded + ] + ); + assert_eq!(fs::read_dir(dir.join("events")).unwrap().count(), 0); + assert!(r.poll().is_empty()); + } + + #[test] + fn same_mtime_orders_by_name_and_bad_json_is_dropped() { + let root = tempfile::tempdir().unwrap(); + let dir = create_session_dir(root.path()).unwrap(); + let t = SystemTime::now() - Duration::from_secs(5); + for (n, body) in [ + ("ev.2.json", r#"{"hook_event_name":"Stop"}"#), + ("ev.1.json", r#"{"hook_event_name":"PreToolUse"}"#), + ("ev.0.json", "not json"), + ] { + let p = dir.join("events").join(n); + fs::write(&p, body).unwrap(); + fs::File::options() + .write(true) + .open(&p) + .unwrap() + .set_modified(t) + .unwrap(); + } + let mut r = EventReader::new(dir.clone()); + assert_eq!( + r.poll(), + vec![AgentEvent::ToolStarted, AgentEvent::TurnEnded] + ); + assert_eq!(fs::read_dir(dir.join("events")).unwrap().count(), 0); + } + + #[test] + fn unreadable_event_files_stay_until_stale() { + let root = tempfile::tempdir().unwrap(); + let dir = create_session_dir(root.path()).unwrap(); + put(&dir, "ev.young.json", "{}", 1); + put(&dir, "ev.old.json", "{}", 120); + for n in ["ev.young.json", "ev.old.json"] { + fs::set_permissions( + dir.join("events").join(n), + fs::Permissions::from_mode(0o000), + ) + .unwrap(); + } + if fs::read_to_string(dir.join("events/ev.young.json")).is_ok() { + return; // running as root: cannot make a file unreadable + } + let mut r = EventReader::new(dir.clone()); + assert!(r.poll().is_empty()); + assert!(dir.join("events/ev.young.json").exists()); + assert!(!dir.join("events/ev.old.json").exists()); + } + + #[test] + fn tmp_files_are_skipped_then_cleaned() { + let root = tempfile::tempdir().unwrap(); + let dir = create_session_dir(root.path()).unwrap(); + put(&dir, "ev.young", "{", 1); + put(&dir, "ev.old", "{", 120); + let mut r = EventReader::new(dir.clone()); + assert!(r.poll().is_empty()); + assert!(dir.join("events/ev.young").exists()); + assert!(!dir.join("events/ev.old").exists()); + } + + #[test] + fn map_hook_fixtures() { + assert_eq!( + map_hook(&fixture("01-session-start.json")), + Some(AgentEvent::SessionStarted { + session_id: "c142b440-2f9a-4f2e-bf30-d99335ab2e7e".into(), + log_path: PathBuf::from( + "/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl" + ), + source: "startup".into(), + same_log: false, + }) + ); + assert!(matches!( + map_hook(&fixture("02-user-prompt-submit.json")), + Some(AgentEvent::PromptSubmitted { text }) if !text.is_empty() + )); + assert_eq!( + map_hook(&fixture("03-pre-tool-use.json")), + Some(AgentEvent::ToolStarted) + ); + assert_eq!( + map_hook(&fixture("04-post-tool-use.json")), + Some(AgentEvent::ToolFinished) + ); + assert_eq!( + map_hook(&fixture("05-stop.json")), + Some(AgentEvent::TurnEnded) + ); + assert_eq!( + map_hook(&fixture("08-notification.json")), + Some(AgentEvent::WaitingForPermission { + detail: "Claude needs your permission".into() + }) + ); + let clear = map_hook(&fixture("12-session-start.json")); + assert!(matches!(clear, Some(AgentEvent::SessionStarted { .. }))); + } + + #[test] + fn map_hook_edge_cases() { + let j = |s: &str| serde_json::from_str::(s).unwrap(); + assert_eq!(map_hook(&j(r#"{"hook_event_name":"Nope"}"#)), None); + assert_eq!(map_hook(&j(r#"{}"#)), None); + assert_eq!(map_hook(&j(r#"{"hook_event_name":"SessionStart"}"#)), None); + assert_eq!( + map_hook(&j(r#"{"hook_event_name":"UserPromptSubmit"}"#)), + None + ); + // idle reminder + assert_eq!( + map_hook(&j( + r#"{"hook_event_name":"Notification","notification_type":"idle_prompt","message":"Claude is waiting for your permission"}"# + )), + None + ); + // no notification_type: fall back to the message + assert_eq!( + map_hook(&j( + r#"{"hook_event_name":"Notification","message":"Claude needs your permission"}"# + )), + Some(AgentEvent::WaitingForPermission { + detail: "Claude needs your permission".into() + }) + ); + assert_eq!( + map_hook(&j( + r#"{"hook_event_name":"Notification","message":"Claude is waiting for your input"}"# + )), + None + ); + } +} diff --git a/src/chat/claude/mod.rs b/src/chat/claude/mod.rs new file mode 100644 index 0000000..7e0d695 --- /dev/null +++ b/src/chat/claude/mod.rs @@ -0,0 +1,564 @@ +//! Claude Code adapter: launch settings, hook events and transcript reading. + +pub mod hooks; +pub mod transcript; + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::chat::adapter::{AgentAdapter, AgentEvent, Capabilities, LaunchPlan, LogItem}; +use hooks::EventReader; +use transcript::TranscriptReader; + +const HOOKS_WAIT: Duration = Duration::from_secs(15); +const HOOKS_INACTIVE: &str = + "Claude hooks are not active: disabled, --bare, or an unsupported version"; + +pub struct ClaudeAdapter { + dir: Option, + events: Option, + transcript: Option, + /// Events not yet returned: a switch to another log waits one poll so the + /// old log is read to its end first. + held: VecDeque, + /// The switch at the front of `held` already waited its poll. + switch_waited: bool, + /// Log items returned before the reader's own at the next `poll_log`. + queued: Vec, + started_at: Instant, + session_started: bool, + withdrawn: Option, +} + +impl ClaudeAdapter { + pub fn new() -> Self { + Self { + dir: None, + events: None, + transcript: None, + held: VecDeque::new(), + switch_waited: false, + queued: Vec::new(), + started_at: Instant::now(), + session_started: false, + withdrawn: None, + } + } + + #[cfg(test)] + pub fn dir(&self) -> Option<&Path> { + self.dir.as_deref() + } +} + +fn resumes_or_pins(args: &[String]) -> bool { + args.iter().any(|a| { + matches!( + a.as_str(), + "--continue" | "-c" | "--resume" | "-r" | "--session-id" + ) || a.starts_with("--resume=") + || a.starts_with("--session-id=") + }) +} + +impl ClaudeAdapter { + /// Read the old log to its end (normal items), then open `path`. Content + /// already there is backfill when the session was resumed rather than + /// started or cleared (a new or cleared session's log is live from its + /// first line). + fn switch_to(&mut self, path: PathBuf, source: &str) { + if let Some(old) = self.transcript.as_mut() { + self.queued.extend(old.poll()); + } + let mut reader = TranscriptReader::new(path); + if !matches!(source, "startup" | "clear") { + for item in reader.poll() { + self.queued.push(match item { + LogItem::Problem { .. } => item, + item => LogItem::Backfill(Box::new(item)), + }); + } + } + self.transcript = Some(reader); + } +} + +impl AgentAdapter for ClaudeAdapter { + fn prepare_launch(&mut self, args: &[String], dir: &Path) -> anyhow::Result { + let named = dir + .file_name() + .is_some_and(|n| n.to_string_lossy().starts_with("chat-")); + if !named || !dir.join("events").is_dir() { + anyhow::bail!("not a chat session directory: {}", dir.display()); + } + let settings = hooks::write_settings(dir)?; + self.dir = Some(dir.to_path_buf()); + self.events = Some(EventReader::new(dir.to_path_buf())); + self.started_at = Instant::now(); + let mut extra_args = vec![ + "--settings".to_string(), + settings.to_string_lossy().into_owned(), + ]; + let mut session_id = None; + if !resumes_or_pins(args) { + let id = uuid::Uuid::new_v4().to_string(); + extra_args.extend(["--session-id".to_string(), id.clone()]); + session_id = Some(id); + } + Ok(LaunchPlan { + extra_args, + session_id, + }) + } + + fn poll_events(&mut self) -> Vec { + let Some(reader) = self.events.as_mut() else { + return Vec::new(); + }; + let mut incoming = std::mem::take(&mut self.held); + incoming.extend(reader.poll()); + let mut out = Vec::new(); + while let Some(mut event) = incoming.pop_front() { + if let AgentEvent::SessionStarted { + session_id, + log_path, + source, + same_log, + } = &mut event + { + // Claude names the transcript after the session; anything else is + // not a start of this session's log. + let expected = format!("{session_id}.jsonl"); + if log_path.file_name() != Some(std::ffi::OsStr::new(&expected)) { + self.queued.push(LogItem::Problem { + detail: "ignored a session start whose transcript is not named after its session" + .into(), + fatal: false, + }); + continue; + } + let current = self.transcript.as_ref().map(|r| r.path().to_path_buf()); + *same_log = current.as_deref() == Some(log_path.as_path()); + if !*same_log { + if current.is_some() && !self.switch_waited { + // Give this tick's poll_log the old log's last lines first. + self.switch_waited = true; + incoming.push_front(event); + self.held = incoming; + break; + } + self.switch_waited = false; + self.switch_to(log_path.clone(), source); + } + self.session_started = true; + } + out.push(event); + } + out + } + + fn poll_log(&mut self) -> Vec { + let mut items = std::mem::take(&mut self.queued); + if let Some(reader) = self.transcript.as_mut() { + items.extend(reader.poll()); + } + for item in &items { + if let LogItem::Problem { + detail, + fatal: true, + } = item + { + self.withdrawn.get_or_insert_with(|| detail.clone()); + } + } + items + } + + fn capabilities(&self) -> Capabilities { + if let Some(reason) = &self.withdrawn { + return Capabilities { + chat_v1: false, + reason: Some(reason.clone()), + }; + } + if self.session_started { + return Capabilities { + chat_v1: true, + reason: None, + }; + } + let reason = (self.started_at.elapsed() >= HOOKS_WAIT).then(|| HOOKS_INACTIVE.to_string()); + Capabilities { + chat_v1: false, + reason, + } + } +} + +impl Drop for ClaudeAdapter { + fn drop(&mut self) { + if let Some(dir) = self.dir.take() { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn s(v: &[&str]) -> Vec { + v.iter().map(|x| x.to_string()).collect() + } + + fn launch(args: &[&str]) -> (tempfile::TempDir, ClaudeAdapter, LaunchPlan) { + let root = tempfile::tempdir().unwrap(); + let dir = hooks::create_session_dir(root.path()).unwrap(); + let mut a = ClaudeAdapter::new(); + let plan = a.prepare_launch(&s(args), &dir).unwrap(); + (root, a, plan) + } + + #[test] + fn prepare_launch_pins_session_id_unless_resuming() { + let (_r, a, plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + assert_eq!(plan.extra_args[0], "--settings"); + assert_eq!( + plan.extra_args[1], + dir.join("settings.json").to_string_lossy() + ); + assert!(dir.join("settings.json").exists()); + let id = plan.session_id.clone().unwrap(); + assert_eq!( + plan.extra_args[2..], + ["--session-id".to_string(), id.clone()] + ); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + + for args in [ + &["--continue"][..], + &["-c"], + &["--resume"], + &["--resume=abc"], + &["-r", "abc"], + &["--session-id", "x"], + &["--session-id=x"], + &["--model", "haiku", "-c"], + ] { + let (_r, _a, plan) = launch(args); + assert_eq!(plan.extra_args.len(), 2, "{args:?}"); + assert_eq!(plan.session_id, None, "{args:?}"); + } + } + + #[test] + fn capabilities_wait_for_session_start_then_withdraw_after_15s() { + let mut a = ClaudeAdapter::new(); + assert_eq!( + a.capabilities(), + Capabilities { + chat_v1: false, + reason: None + } + ); + a.started_at = Instant::now() - Duration::from_secs(16); + let c = a.capabilities(); + assert!(!c.chat_v1); + assert_eq!(c.reason.as_deref(), Some(HOOKS_INACTIVE)); + // a late SessionStart still enables chat + a.session_started = true; + assert_eq!( + a.capabilities(), + Capabilities { + chat_v1: true, + reason: None + } + ); + a.withdrawn = Some("format changed".into()); + let c = a.capabilities(); + assert!(!c.chat_v1); + assert_eq!(c.reason.as_deref(), Some("format changed")); + } + + fn event(dir: &Path, name: &str, body: &str) { + fs::write(dir.join("events").join(name), body).unwrap(); + } + + #[test] + fn session_start_opens_the_transcript_and_clear_switches_it() { + let (root, mut a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + let fx = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/chat/claude"); + let t1 = root.path().join("s1.jsonl"); + let t2 = root.path().join("s2.jsonl"); + fs::copy(fx.join("session-basic.jsonl"), &t1).unwrap(); + fs::copy(fx.join("session-after-clear.jsonl"), &t2).unwrap(); + assert!(a.poll_log().is_empty()); + + event( + &dir, + "ev.1.json", + &format!( + r#"{{"hook_event_name":"SessionStart","session_id":"s1","transcript_path":"{}","source":"startup"}}"#, + t1.display() + ), + ); + let evs = a.poll_events(); + assert!(matches!(evs[0], AgentEvent::SessionStarted { .. })); + assert!(a.capabilities().chat_v1); + let items = a.poll_log(); + assert!( + items + .iter() + .any(|i| matches!(i, LogItem::UserMessage { .. })) + ); + + event( + &dir, + "ev.2.json", + &format!( + r#"{{"hook_event_name":"SessionStart","session_id":"s2","transcript_path":"{}","source":"clear"}}"#, + t2.display() + ), + ); + // held one poll so the old log is read to its end first + assert!(a.poll_events().is_empty()); + assert!(a.poll_log().is_empty()); + let evs = a.poll_events(); + assert!( + matches!(&evs[0], AgentEvent::SessionStarted { source, same_log: false, .. } + if source == "clear") + ); + assert_eq!(a.transcript.as_ref().unwrap().path(), t2); + // /clear content is live (never backfill) + let items = a.poll_log(); + assert!( + items + .iter() + .any(|i| matches!(i, LogItem::UserMessage { text, .. } if text == "say hi")) + ); + assert!(!items.iter().any(|i| matches!(i, LogItem::Backfill(_)))); + } + + fn start_event(dir: &Path, name: &str, session: &str, path: &Path, source: &str) { + event( + dir, + name, + &format!( + r#"{{"hook_event_name":"SessionStart","session_id":"{session}","transcript_path":"{}","source":"{source}"}}"#, + path.display() + ), + ); + } + + fn user_line(uuid: &str, text: &str) -> String { + format!( + r#"{{"type":"user","uuid":"{uuid}","promptId":"p-{uuid}","message":{{"role":"user","content":"{text}"}}}}"# + ) + "\n" + } + + fn append(path: &Path, text: &str) { + use std::io::Write; + let mut f = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + f.write_all(text.as_bytes()).unwrap(); + } + + #[test] + fn same_path_start_says_same_log_and_passes_the_source() { + let (root, mut a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + let t = root.path().join("s1.jsonl"); + start_event(&dir, "ev.1.json", "s1", &t, "startup"); + let evs = a.poll_events(); + assert!( + matches!(&evs[..], [AgentEvent::SessionStarted { source, same_log: false, .. }] + if source == "startup") + ); + start_event(&dir, "ev.2.json", "s1", &t, "compact"); + let evs = a.poll_events(); + assert!( + matches!(&evs[..], [AgentEvent::SessionStarted { source, same_log: true, .. }] + if source == "compact") + ); + } + + #[test] + fn switching_logs_reads_the_old_one_to_its_end_first() { + let (root, mut a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + let t1 = root.path().join("s1.jsonl"); + let t2 = root.path().join("s2.jsonl"); + append(&t1, &user_line("u1", "one")); + start_event(&dir, "ev.1.json", "s1", &t1, "startup"); + a.poll_events(); + assert_eq!(a.poll_log().len(), 1); + // old-log lines written before the switch, and one more after the hold + append(&t1, &user_line("u2", "two")); + start_event(&dir, "ev.2.json", "s2", &t2, "clear"); + event(&dir, "ev.3.json", r#"{"hook_event_name":"Stop"}"#); + assert!(a.poll_events().is_empty(), "held until the old log is read"); + let items = a.poll_log(); + assert!(matches!(&items[..], [LogItem::UserMessage { id, .. }] if id == "u2")); + append(&t1, &user_line("u3", "three")); + let evs = a.poll_events(); + assert!(matches!(evs[0], AgentEvent::SessionStarted { .. })); + assert_eq!(evs[1], AgentEvent::TurnEnded, "later events follow"); + let items = a.poll_log(); + assert!(matches!(&items[..], [LogItem::UserMessage { id, .. }] if id == "u3")); + assert_eq!(a.transcript.as_ref().unwrap().path(), t2); + } + + #[test] + fn resuming_another_session_reads_its_content_as_backfill() { + let (root, mut a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + let t1 = root.path().join("s1.jsonl"); + let t2 = root.path().join("s2.jsonl"); + start_event(&dir, "ev.1.json", "s1", &t1, "startup"); + a.poll_events(); + let fx = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/chat/claude"); + fs::copy(fx.join("session-basic.jsonl"), &t2).unwrap(); + start_event(&dir, "ev.2.json", "s2", &t2, "resume"); + assert!(a.poll_events().is_empty()); + a.poll_log(); + let evs = a.poll_events(); + assert!(matches!( + &evs[..], + [AgentEvent::SessionStarted { + same_log: false, + .. + }] + )); + let items = a.poll_log(); + assert!(!items.is_empty()); + assert!( + items.iter().all(|i| matches!(i, LogItem::Backfill(_))), + "{items:?}" + ); + assert!(items.iter().any(|i| matches!(i, + LogItem::Backfill(b) if matches!(**b, LogItem::TurnEnded { .. })))); + // what is written after the resume is live + append(&t2, &user_line("new", "next")); + let items = a.poll_log(); + assert!(matches!(&items[..], [LogItem::UserMessage { id, .. }] if id == "new")); + } + + #[test] + fn session_start_whose_log_does_not_match_its_id_is_ignored() { + let (root, mut a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + let good = root.path().join("s1.jsonl"); + start_event(&dir, "ev.1.json", "s1", &good, "startup"); + a.poll_events(); + for (i, path) in [ + root.path().join("other.jsonl"), + root.path().join("s1.jsonl.bak"), + root.path().join("xs1.jsonl"), + ] + .iter() + .enumerate() + { + start_event(&dir, &format!("ev.{}.json", i + 2), "s1", path, "resume"); + assert!(a.poll_events().is_empty(), "{path:?}"); + assert_eq!(a.transcript.as_ref().unwrap().path(), good); + let items = a.poll_log(); + assert!( + matches!(&items[..], [LogItem::Problem { fatal: false, .. }]), + "{items:?}" + ); + } + assert!(a.capabilities().chat_v1); + // before any valid start, a mismatched one does not enable chat + let (root, mut a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + start_event( + &dir, + "ev.1.json", + "s1", + &root.path().join("s2.jsonl"), + "startup", + ); + assert!(a.poll_events().is_empty()); + assert!(!a.capabilities().chat_v1); + } + + #[test] + fn same_path_session_start_keeps_the_reader() { + let (root, mut a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + let fx = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/chat/claude"); + let t1 = root.path().join("s1.jsonl"); + fs::copy(fx.join("session-basic.jsonl"), &t1).unwrap(); + let start = format!( + r#"{{"hook_event_name":"SessionStart","session_id":"s1","transcript_path":"{}"}}"#, + t1.display() + ); + event(&dir, "ev.1.json", &start); + a.poll_events(); + assert!(!a.poll_log().is_empty()); + event(&dir, "ev.2.json", &start); + a.poll_events(); + assert!(a.poll_log().is_empty(), "no replay after same-path start"); + } + + #[test] + fn prepare_launch_refuses_foreign_dirs_and_drop_leaves_them() { + let root = tempfile::tempdir().unwrap(); + let plain = root.path().join("other"); + fs::create_dir_all(plain.join("events")).unwrap(); + let mut a = ClaudeAdapter::new(); + assert!(a.prepare_launch(&[], &plain).is_err()); + let noevents = root.path().join("chat-x"); + fs::create_dir_all(&noevents).unwrap(); + assert!(a.prepare_launch(&[], &noevents).is_err()); + assert!(a.dir().is_none()); + drop(a); + assert!(plain.exists() && noevents.exists()); + } + + #[test] + fn fatal_problem_withdraws_chat() { + let (root, mut a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + let t = root.path().join("s.jsonl"); + fs::write( + &t, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"}}\n", + ) + .unwrap(); + event( + &dir, + "ev.1.json", + &format!( + r#"{{"hook_event_name":"SessionStart","session_id":"s","transcript_path":"{}"}}"#, + t.display() + ), + ); + a.poll_events(); + let items = a.poll_log(); + assert!( + items + .iter() + .any(|i| matches!(i, LogItem::Problem { fatal: true, .. })) + ); + let c = a.capabilities(); + assert!(!c.chat_v1); + assert!(c.reason.is_some()); + } + + #[test] + fn drop_removes_only_the_session_dir() { + let (root, a, _plan) = launch(&[]); + let dir = a.dir().unwrap().to_path_buf(); + drop(a); + assert!(!dir.exists()); + assert!(root.path().exists()); + } +} diff --git a/src/chat/claude/transcript.rs b/src/chat/claude/transcript.rs new file mode 100644 index 0000000..b32d5ae --- /dev/null +++ b/src/chat/claude/transcript.rs @@ -0,0 +1,718 @@ +//! Incremental reader for Claude Code's session transcript (JSONL). + +use std::collections::{HashSet, VecDeque}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::chat::adapter::{LogItem, Outcome, Part}; + +const SEEN_CAP: usize = 10_000; +/// Text of the user entry Claude writes when the user interrupts a turn. +const INTERRUPT_MARKER: &str = "[Request interrupted by user"; + +pub struct TranscriptReader { + path: PathBuf, + offset: u64, + pending: Vec, + seen: HashSet, + seen_order: VecDeque, + turn_id: String, + seen_cap: usize, +} + +impl TranscriptReader { + pub fn new(path: PathBuf) -> Self { + Self { + path, + offset: 0, + pending: Vec::new(), + seen: HashSet::new(), + seen_order: VecDeque::new(), + turn_id: String::new(), + seen_cap: SEEN_CAP, + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + #[cfg(test)] + fn reread_from_start(&mut self) { + self.offset = 0; + self.pending.clear(); + } + + /// Read new complete lines since the last call and map them to LogItems. + pub fn poll(&mut self) -> Vec { + let mut out = Vec::new(); + let Ok(mut file) = std::fs::File::open(&self.path) else { + return out; + }; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + if len < self.offset { + // truncated or replaced: start over (the seen set prevents duplicates) + self.offset = 0; + self.pending.clear(); + } + if file.seek(SeekFrom::Start(self.offset)).is_err() { + return out; + } + let mut buf = Vec::new(); + if file.read_to_end(&mut buf).is_err() { + return out; + } + self.offset += buf.len() as u64; + self.pending.extend_from_slice(&buf); + + let mut garbage = false; + let Some(last_nl) = self.pending.iter().rposition(|b| *b == b'\n') else { + return out; + }; + let rest = self.pending.split_off(last_nl + 1); + let complete = std::mem::replace(&mut self.pending, rest); + for line in complete[..last_nl].split(|b| *b == b'\n') { + if line.iter().all(|b| b.is_ascii_whitespace()) { + continue; + } + match serde_json::from_slice::(line) { + Ok(v) => self.map_entry(&v, &mut out), + Err(_) => { + if !garbage { + garbage = true; + out.push(LogItem::Problem { + detail: "transcript line is not valid JSON".into(), + fatal: false, + }); + } + } + } + } + out + } + + fn remember(&mut self, uuid: &str) -> bool { + if self.seen.contains(uuid) { + return false; + } + self.seen.insert(uuid.to_string()); + self.seen_order.push_back(uuid.to_string()); + if self.seen_order.len() > self.seen_cap + && let Some(old) = self.seen_order.pop_front() + { + self.seen.remove(&old); + } + true + } + + fn map_entry(&mut self, v: &Value, out: &mut Vec) { + let kind = v.get("type").and_then(Value::as_str).unwrap_or(""); + if kind != "user" && kind != "assistant" { + return; + } + let Some(uuid) = v.get("uuid").and_then(Value::as_str) else { + out.push(LogItem::Problem { + detail: format!("{kind} transcript entry has no uuid"), + fatal: true, + }); + return; + }; + if v.get("isSidechain").and_then(Value::as_bool) == Some(true) + || v.get("isMeta").and_then(Value::as_bool) == Some(true) + { + return; + } + let ts = created_at_ms(v); + let prompt_id = v.get("promptId").and_then(Value::as_str); + + if kind == "user" { + let content = v.pointer("/message/content"); + let text = match content { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Array(blocks)) => { + let texts: Vec<&str> = blocks + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect(); + if texts.is_empty() { + None + } else { + Some(texts.join("\n")) + } + } + _ => None, + }; + let Some(text) = text else { + // tool_result only (or unknown): no message + if !self.remember(uuid) { + return; + } + if let Some(p) = prompt_id { + self.turn_id = p.to_string(); + } + return; + }; + if text.starts_with(" 1; + for (i, b) in blocks.iter().enumerate() { + let id = if multi { + format!("{uuid}:{i}") + } else { + uuid.to_string() + }; + let part = match b.get("type").and_then(Value::as_str) { + Some("text") => Part::Text { + id, + markdown: b + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + }, + Some("tool_use") => { + let name = b + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let summary = summarize_tool(&name, b.get("input").unwrap_or(&Value::Null)); + Part::Tool { id, name, summary } + } + _ => continue, + }; + out.push(LogItem::AssistantPart { + message_id: message_id.to_string(), + part, + turn_id: self.turn_id.clone(), + created_at_ms: ts, + }); + } + if v.pointer("/message/stop_reason").and_then(Value::as_str) == Some("end_turn") { + out.push(LogItem::TurnEnded { + turn_id: self.turn_id.clone(), + outcome: Outcome::Done, + }); + } + } +} + +fn created_at_ms(v: &Value) -> i64 { + v.get("timestamp") + .and_then(Value::as_str) + .and_then(parse_rfc3339_ms) + .unwrap_or(0) +} + +/// Minimal RFC 3339 parser (`YYYY-MM-DDTHH:MM:SS[.fff](Z|±HH:MM)`) to unix ms. +fn parse_rfc3339_ms(s: &str) -> Option { + let b = s.as_bytes(); + if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || !(b[10] == b'T' || b[10] == b't') { + return None; + } + if b[13] != b':' || b[16] != b':' { + return None; + } + let num = |r: std::ops::Range| -> Option { + let d = b.get(r)?; + if d.is_empty() || !d.iter().all(u8::is_ascii_digit) { + return None; + } + d.iter() + .try_fold(0i64, |a, c| Some(a * 10 + i64::from(c - b'0'))) + }; + let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); + let (h, mi, sec) = (num(11..13)?, num(14..16)?, num(17..19)?); + let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; + let dim = match mo { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => 28 + i64::from(leap), + _ => return None, + }; + if !(1..=dim).contains(&d) || h > 23 || mi > 59 || sec > 60 { + return None; + } + let mut i = 19; + let mut ms = 0i64; + if b[i] == b'.' { + i += 1; + let start = i; + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + let frac = &b[start..i]; + if frac.is_empty() { + return None; + } + for k in 0..3 { + ms = ms * 10 + i64::from(frac.get(k).map_or(0, |c| c - b'0')); + } + } + let offset_min = match b.get(i..)? { + b"Z" | b"z" => 0, + t if t.len() == 6 && (t[0] == b'+' || t[0] == b'-') && t[3] == b':' => { + let hh = num(i + 1..i + 3)?; + let mm = num(i + 4..i + 6)?; + let v = hh * 60 + mm; + if t[0] == b'-' { -v } else { v } + } + _ => return None, + }; + // days from civil (Howard Hinnant) + let yy = if mo <= 2 { y - 1 } else { y }; + let era = yy.div_euclid(400); + let yoe = yy - era * 400; + let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + Some(((days * 86_400 + h * 3600 + mi * 60 + sec - offset_min * 60) * 1000) + ms) +} + +/// One-line, bounded description of a tool call (≤120 chars). +pub fn summarize_tool(name: &str, input: &Value) -> String { + let s = |k: &str| input.get(k).and_then(Value::as_str); + let raw = match name { + "Bash" => s("command").map(|c| c.lines().next().unwrap_or("")), + "Read" | "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => { + s("file_path").or_else(|| s("notebook_path")) + } + "Glob" | "Grep" => s("pattern"), + "WebFetch" => s("url"), + "WebSearch" => s("query"), + _ => None, + } + .unwrap_or(""); + truncate(raw, 120) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut t: String = s.chars().take(max - 1).collect(); + t.push('…'); + t +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::adapter::LogItem as L; + use std::io::Write; + + const BASIC: &str = include_str!("../../../tests/fixtures/chat/claude/session-basic.jsonl"); + + fn reader_with(content: &str) -> (tempfile::TempDir, TranscriptReader) { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("t.jsonl"); + std::fs::write(&p, content).unwrap(); + (dir, TranscriptReader::new(p)) + } + + fn user_line(uuid: &str, extra: &str, content: &str) -> String { + format!( + r#"{{"type":"user","uuid":"{uuid}","timestamp":"2026-10-15T01:02:03.456Z",{extra}"message":{{"role":"user","content":{content}}}}}"# + ) + "\n" + } + + #[test] + fn basic_session_yields_user_messages_parts_and_turn_end() { + let (_d, mut r) = reader_with(BASIC); + let items = r.poll(); + assert!( + !items.iter().any(|i| matches!(i, L::Problem { .. })), + "{items:?}" + ); + let L::UserMessage { + text, + turn_id, + created_at_ms, + .. + } = &items[0] + else { + panic!("first item: {:?}", items[0]); + }; + assert!(text.starts_with("run `echo hi`")); + assert_eq!(turn_id, "fa1cc4a0-50ce-4785-aab5-0355d2a806db"); + assert!(*created_at_ms > 1_700_000_000_000); + let first_end = items + .iter() + .position(|i| { + matches!( + i, + L::TurnEnded { + outcome: Outcome::Done, + .. + } + ) + }) + .unwrap(); + let first = &items[..first_end]; + assert!(first.iter().any(|i| matches!(i, L::AssistantPart { + part: Part::Tool { name, summary, .. }, turn_id, .. + } if name == "Bash" && summary == "echo hi" && turn_id == "fa1cc4a0-50ce-4785-aab5-0355d2a806db"))); + assert!(first.iter().any(|i| matches!( + i, + L::AssistantPart { + part: Part::Text { .. }, + .. + } + ))); + let L::TurnEnded { turn_id, .. } = &items[first_end] else { + unreachable!() + }; + assert_eq!(turn_id, "fa1cc4a0-50ce-4785-aab5-0355d2a806db"); + // user messages: three prompts, none from tool results + let users = items + .iter() + .filter(|i| matches!(i, L::UserMessage { .. })) + .count(); + assert_eq!(users, 3); + // no thinking + assert!(!format!("{items:?}").contains("thinking")); + } + + #[test] + fn interrupt_entry_ends_the_turn_as_interrupted() { + let (_d, mut r) = reader_with(BASIC); + let items = r.poll(); + let last = items.last().unwrap(); + assert_eq!( + last, + &L::TurnEnded { + turn_id: "64707c1e-bb7f-4bb8-9065-1514059dcb0d".into(), + outcome: Outcome::Interrupted + } + ); + // the aborted partial text still produced a part + assert!(matches!( + &items[items.len() - 2], + L::AssistantPart { + part: Part::Text { .. }, + .. + } + )); + assert!(!items.iter().any(|i| matches!(i, + L::UserMessage { text, .. } if text.contains("Request interrupted")))); + } + + #[test] + fn interrupt_marker_without_message_id_ends_the_turn() { + // "… for tool use" (deny/Esc at a permission prompt), as a string and as an + // array, with no interruptedMessageId. + let mut s = user_line("u1", r#""promptId":"p1","#, r#""run it""#); + s += &user_line( + "u2", + r#""promptId":"p1","#, + r#""[Request interrupted by user for tool use]""#, + ); + s += &user_line("u3", r#""promptId":"p3","#, r#""again""#); + s += &user_line( + "u4", + r#""promptId":"p3","#, + r#"[{"type":"text","text":"[Request interrupted by user]"}]"#, + ); + let (_d, mut r) = reader_with(&s); + let items = r.poll(); + assert_eq!(items.len(), 4, "{items:?}"); + assert_eq!( + items[1], + L::TurnEnded { + turn_id: "p1".into(), + outcome: Outcome::Interrupted + } + ); + assert_eq!( + items[3], + L::TurnEnded { + turn_id: "p3".into(), + outcome: Outcome::Interrupted + } + ); + } + + #[test] + fn meta_sidechain_and_command_entries_are_skipped() { + let mut s = String::new(); + s += &user_line("u1", r#""isMeta":true,"#, r#""x""#); + s += &user_line("u2", r#""isSidechain":true,"#, r#""side""#); + s += &user_line("u3", "", r#""/clear""#); + s += &user_line("u4", "", r#""ok""#); + s += &user_line("u5", r#""promptId":"p5","#, r#""real""#); + s += "{\"type\":\"attachment\",\"attachment\":{}}\n{\"type\":\"system\"}\n"; + let (_d, mut r) = reader_with(&s); + let items = r.poll(); + assert_eq!(items.len(), 1, "{items:?}"); + assert!(matches!(&items[0], L::UserMessage { id, turn_id, text, .. } + if id == "u5" && turn_id == "p5" && text == "real")); + } + + #[test] + fn array_content_prompt_is_a_user_message() { + let l = user_line( + "u1", + "", + r#"[{"type":"text","text":"hello"},{"type":"text","text":"world"}]"#, + ); + let (_d, mut r) = reader_with(&l); + let items = r.poll(); + assert!(matches!(&items[0], L::UserMessage { turn_id, text, .. } + if turn_id == "u1" && text == "hello\nworld")); + } + + #[test] + fn partial_last_line_waits_for_the_rest() { + let l = user_line("u1", r#""promptId":"p","#, r#""hi""#); + let (half, rest) = l.split_at(l.len() / 2); + let (_d, mut r) = reader_with(half); + assert!(r.poll().is_empty()); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(r.path()) + .unwrap(); + f.write_all(rest.as_bytes()).unwrap(); + drop(f); + let items = r.poll(); + assert_eq!(items.len(), 1); + assert!(r.poll().is_empty()); + } + + #[test] + fn lines_are_processed_once_even_if_reread() { + let (_d, mut r) = reader_with(BASIC); + assert!(!r.poll().is_empty()); + r.reread_from_start(); + assert!(r.poll().is_empty()); + } + + #[test] + fn missing_file_is_retried() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("later.jsonl"); + let mut r = TranscriptReader::new(p.clone()); + assert!(r.poll().is_empty()); + std::fs::write(&p, user_line("u1", "", r#""hi""#)).unwrap(); + assert_eq!(r.poll().len(), 1); + } + + #[test] + fn garbage_line_is_a_problem_not_a_stop() { + let s = format!("not json\nalso bad\n{}", user_line("u1", "", r#""hi""#)); + let (_d, mut r) = reader_with(&s); + let items = r.poll(); + assert_eq!(items.len(), 2); + assert!(matches!(&items[0], L::Problem { fatal: false, .. })); + assert!(matches!(&items[1], L::UserMessage { .. })); + } + + #[test] + fn assistant_without_message_id_is_fatal() { + let s = + r#"{"type":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"x"}]}}"# + .to_string() + + "\n" + + r#"{"type":"user","message":{"content":"x"}}"# + "\n"; + let (_d, mut r) = reader_with(&s); + let items = r.poll(); + assert_eq!(items.len(), 2); + assert!( + items + .iter() + .all(|i| matches!(i, L::Problem { fatal: true, .. })) + ); + } + + #[test] + fn multi_block_lines_get_indexed_part_ids() { + let s = r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","stop_reason":"end_turn","content":[{"type":"thinking","thinking":"x"},{"type":"text","text":"a"},{"type":"tool_use","id":"t","name":"Glob","input":{"pattern":"*.rs"}}]}}"# + .to_string() + + "\n"; + let (_d, mut r) = reader_with(&s); + let items = r.poll(); + assert_eq!(items.len(), 3); + let ids: Vec<&str> = items + .iter() + .filter_map(|i| { + if let L::AssistantPart { part, .. } = i { + Some(part.id()) + } else { + None + } + }) + .collect(); + assert_eq!(ids, ["a1:1", "a1:2"]); + assert!(matches!( + items[2], + L::TurnEnded { + outcome: Outcome::Done, + .. + } + )); + } + + #[test] + fn timestamp_parsing() { + assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:01.5Z"), Some(1500)); + assert_eq!( + parse_rfc3339_ms("2026-10-15T01:00:00+01:00"), + parse_rfc3339_ms("2026-10-15T00:00:00Z") + ); + assert_eq!(parse_rfc3339_ms("garbage"), None); + } + + #[test] + fn tool_summaries_are_short_and_safe() { + use serde_json::json; + assert_eq!( + summarize_tool("Bash", &json!({"command":"echo hi\nrm x"})), + "echo hi" + ); + let long = summarize_tool("Bash", &json!({"command":"é".repeat(200)})); + assert_eq!(long.chars().count(), 120); + assert!(long.ends_with('…')); + assert_eq!(summarize_tool("Read", &json!({"file_path":"/a/b"})), "/a/b"); + assert_eq!( + summarize_tool("NotebookEdit", &json!({"notebook_path":"/n"})), + "/n" + ); + assert_eq!(summarize_tool("Grep", &json!({"pattern":"foo"})), "foo"); + assert_eq!( + summarize_tool("WebFetch", &json!({"url":"http://x"})), + "http://x" + ); + assert_eq!(summarize_tool("WebSearch", &json!({"query":"q"})), "q"); + assert_eq!(summarize_tool("Task", &json!({"prompt":"p"})), ""); + assert_eq!(summarize_tool("Bash", &json!({})), ""); + } + + #[test] + fn multibyte_line_split_mid_codepoint() { + let l = user_line("u1", r#""promptId":"p","#, r#""café 😀 ok""#); + let pos = l.find('😀').unwrap() + 2; // inside the emoji + let (a, b) = l.as_bytes().split_at(pos); + let (_d, mut r) = reader_with(""); + std::fs::write(r.path(), a).unwrap(); + assert!(r.poll().is_empty()); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(r.path()) + .unwrap(); + f.write_all(b).unwrap(); + drop(f); + let items = r.poll(); + assert!( + matches!(&items[0], L::UserMessage { text, .. } if text == "café 😀 ok"), + "{items:?}" + ); + } + + #[test] + fn seen_set_is_bounded() { + let mut s = String::new(); + for i in 0..5 { + s += &user_line(&format!("u{i}"), "", r#""x""#); + } + let (_d, mut r) = reader_with(&s); + r.seen_cap = 2; + assert_eq!(r.poll().len(), 5); + assert_eq!(r.seen.len(), 2); + assert!(r.seen.contains("u4") && r.seen.contains("u3")); + r.reread_from_start(); + // evicted ids are accepted again (the set stays bounded) + let again = r.poll(); + assert!(matches!(&again[0], L::UserMessage { id, .. } if id == "u0")); + assert!(r.seen.len() <= 2); + } + + #[test] + fn tool_result_prompt_id_sets_turn_for_following_parts() { + let mut s = user_line( + "t1", + r#""promptId":"pX","#, + r#"[{"type":"tool_result","tool_use_id":"x","content":"ok"}]"#, + ); + s += r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","content":[{"type":"text","text":"hi"}]}}"#; + s += "\n"; + let (_d, mut r) = reader_with(&s); + let items = r.poll(); + assert_eq!(items.len(), 1); + assert!(matches!(&items[0], L::AssistantPart { turn_id, .. } if turn_id == "pX")); + } + + #[test] + fn truncated_file_resets_offset() { + let (_d, mut r) = reader_with(&user_line("u1", "", r#""hello there long""#)); + assert_eq!(r.poll().len(), 1); + std::fs::write(r.path(), user_line("u2", "", r#""x""#)).unwrap(); + let items = r.poll(); + assert_eq!(items.len(), 1); + assert!(matches!(&items[0], L::UserMessage { id, .. } if id == "u2")); + } + + #[test] + fn timestamp_edge_cases() { + assert_eq!( + parse_rfc3339_ms("2024-02-29T00:00:00Z"), + Some(1_709_164_800_000) + ); + assert_eq!( + parse_rfc3339_ms("2000-03-01T00:00:00Z"), + Some(951_868_800_000) + ); + assert_eq!( + parse_rfc3339_ms("2024-01-01T00:00:00-05:00"), + parse_rfc3339_ms("2024-01-01T05:00:00Z") + ); + assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:02Z"), Some(2000)); + assert_eq!(parse_rfc3339_ms("2023-02-29T00:00:00Z"), None); + assert_eq!(parse_rfc3339_ms("2024-+1-01T00:00:00Z"), None); + assert_eq!(parse_rfc3339_ms("2024-01-01T00:00:00+é:00"), None); + assert_eq!(parse_rfc3339_ms("2024-01-01T00:00:00éééé"), None); + } +} diff --git a/src/chat/mod.rs b/src/chat/mod.rs new file mode 100644 index 0000000..5862861 --- /dev/null +++ b/src/chat/mod.rs @@ -0,0 +1,4 @@ +pub mod adapter; +pub mod claude; +pub mod session; +pub mod wire; diff --git a/src/chat/session.rs b/src/chat/session.rs new file mode 100644 index 0000000..0d1772f --- /dev/null +++ b/src/chat/session.rs @@ -0,0 +1,1473 @@ +//! Agent-neutral chat session: status, message history, prompt acceptance and the +//! checks a prompt must pass before it is pasted. It never reads the clock and never +//! touches the PTY; the App feeds it events and the current input count. +//! +//! Event ordering: the App feeds a tick's log items before that tick's hook +//! `TurnEnded`, so the normal case is log-then-hook and "the log's outcome wins" +//! (the log is in hand first). A hook `TurnEnded` that arrives alone is still +//! authoritative: it emits `Complete { Done }` and marks the turn completed; a later +//! log `TurnEnded` for that turn is ignored, so a turn never completes twice. +use crate::chat::adapter::{AgentEvent, LogItem, Outcome, Part}; +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, Instant}; + +const MAX_HISTORY_MESSAGES: usize = 500; +const MAX_HISTORY_BYTES: usize = 8 * 1024 * 1024; +const MAX_REQUESTS: usize = 1000; +const MAX_MESSAGE_BYTES: usize = 131_072; +const MAX_PROMPT_BYTES: usize = 32_768; +const ACCEPT_TIMEOUT: Duration = Duration::from_secs(10); +const TRUNCATED: &str = "… (truncated)"; +/// Reserved part id; adapters never produce ids with this prefix. +const TRUNCATED_ID: &str = "atem:truncated"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + Ready, + Working, + Waiting, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Status { + pub state: State, + pub since_ms: i64, + pub detail: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + User, + Assistant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + Local, + Remote, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatMessage { + pub message_id: String, + pub role: Role, + pub origin: Option, + pub turn_id: String, + pub request_id: Option, + pub created_at_ms: i64, + pub parts: Vec, +} + +impl ChatMessage { + /// Approximate size: the sum of the part text lengths. + fn bytes(&self) -> usize { + self.parts.iter().map(part_bytes).sum() + } +} + +fn part_bytes(part: &Part) -> usize { + match part { + Part::Text { markdown, .. } => markdown.len(), + Part::Tool { name, summary, .. } => name.len() + summary.len(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Out { + Message(ChatMessage), + Delta { + message_id: String, + part: Part, + }, + Status(Status), + Accepted { + request_id: String, + }, + Complete { + turn_id: String, + request_id: Option, + outcome: Outcome, + }, + Error { + text: String, + request_id: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RequestState { + Pending, + Accepted, + Completed { turn_id: String, outcome: Outcome }, + Failed(String), +} + +#[derive(Debug)] +struct Pending { + request_id: String, + text: String, + pasted_at: Option, + hook_seen: bool, + log_seen: bool, +} + +pub struct ChatSession { + generation: String, + status: Status, + started: bool, + exited: bool, + history: VecDeque, + /// Sum of `ChatMessage::bytes` over `history`. + history_bytes: usize, + requests: HashMap, + request_order: VecDeque, + pending: Option, + /// Last request whose delivery timed out: a later matching transcript entry + /// proves it was delivered, so its turn may still complete with its requestId. + expired: Option<(String, String)>, + input_mark: u64, + current_turn: Option<(String, Option)>, + last_completed: Option, +} + +/// Redact credential values (names are kept). See `memory::secrets::redact_secrets`. +pub fn redact(text: &str) -> String { + crate::memory::secrets::redact_secrets(text) +} + +/// Size and character rules for a prompt; the error text is shown to the user. +pub fn clean_prompt(text: &str) -> Result<&str, &'static str> { + if text.len() > MAX_PROMPT_BYTES { + return Err("prompt too long (max 32768 bytes)"); + } + if text + .chars() + .any(|c| c.is_control() && c != '\n' && c != '\t') + { + return Err("prompt contains control characters"); + } + if text.trim().is_empty() { + return Err("prompt is empty"); + } + // "/" runs a command (no hook fires, so no acceptance); "!" is shell mode, + // which runs the command without a permission prompt. + if matches!(text.trim_start().chars().next(), Some('/' | '!')) { + return Err("commands and shell mode are not available from chat"); + } + Ok(text) +} + +fn same_prompt(a: &str, b: &str) -> bool { + a.trim() == b.trim() +} + +fn truncate_at(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut end = max; + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +fn redact_part(part: Part) -> Part { + match part { + Part::Text { id, markdown } => Part::Text { + id, + markdown: redact(&markdown), + }, + Part::Tool { id, name, summary } => Part::Tool { + id, + name: redact(&name), + summary: redact(&summary), + }, + } +} + +/// A user message: redacted, capped at 131 072 bytes (then a marker part). +fn user_message( + id: String, + turn_id: String, + text: &str, + request_id: Option, + created_at_ms: i64, +) -> ChatMessage { + let mut text = redact(text); + let cut = truncate_at(&text, MAX_MESSAGE_BYTES).len(); + let was_cut = cut < text.len(); + text.truncate(cut); + let mut parts = vec![Part::Text { + id: "text".into(), + markdown: text, + }]; + if was_cut { + parts.push(truncated_marker()); + } + ChatMessage { + message_id: id, + role: Role::User, + origin: Some(if request_id.is_some() { + Origin::Remote + } else { + Origin::Local + }), + turn_id, + request_id, + created_at_ms, + parts, + } +} + +fn truncated_marker() -> Part { + Part::Text { + id: TRUNCATED_ID.into(), + markdown: TRUNCATED.into(), + } +} + +impl ChatSession { + pub fn new(generation: String, now_ms: i64) -> Self { + Self { + generation, + status: Status { + state: State::Unknown, + since_ms: now_ms, + detail: None, + }, + started: false, + exited: false, + history: VecDeque::new(), + history_bytes: 0, + requests: HashMap::new(), + request_order: VecDeque::new(), + pending: None, + expired: None, + input_mark: 0, + current_turn: None, + last_completed: None, + } + } + + pub fn generation(&self) -> &str { + &self.generation + } + + pub fn status(&self) -> &Status { + &self.status + } + + pub fn heartbeat(&self) -> Out { + Out::Status(self.status.clone()) + } + + fn set_status(&mut self, state: State, detail: Option<&str>, now_ms: i64, out: &mut Vec) { + let detail = detail.map(str::to_string); + if self.status.state == state && self.status.detail == detail { + return; + } + self.status = Status { + state, + since_ms: now_ms, + detail, + }; + out.push(Out::Status(self.status.clone())); + } + + fn record(&mut self, request_id: &str, state: RequestState) { + if self + .requests + .insert(request_id.to_string(), state) + .is_none() + { + self.request_order.push_back(request_id.to_string()); + while self.request_order.len() > MAX_REQUESTS { + if let Some(old) = self.request_order.pop_front() { + self.requests.remove(&old); + } + } + } + } + + fn live(&self) -> bool { + self.started && !self.exited + } + + pub fn on_event(&mut self, event: AgentEvent, now_ms: i64, input_count: u64) -> Vec { + let mut out = Vec::new(); + if self.exited { + return out; + } + match event { + AgentEvent::SessionStarted { + source, same_log, .. + } => { + // Compaction or a resume of the session being read (or another + // start on that log mid-turn): the same conversation goes on, so + // the turn, the pending prompt and the input mark stay. Startup, + // /clear or another log start afresh. + let in_turn = matches!(self.status.state, State::Working | State::Waiting) + || self.pending.is_some(); + let continues = match source.as_str() { + "startup" | "clear" => false, + "compact" | "resume" => true, + _ => in_turn, + }; + if self.started && same_log && continues { + return out; + } + self.started = true; + self.input_mark = input_count; + self.set_status(State::Ready, None, now_ms, &mut out); + } + AgentEvent::PromptSubmitted { text } => { + if let Some(p) = &mut self.pending + && same_prompt(&p.text, &text) + { + p.hook_seen = true; + } + if self.live() { + self.set_status(State::Working, None, now_ms, &mut out); + } + self.check_accepted(&mut out); + } + AgentEvent::ToolStarted => { + if self.live() { + self.set_status(State::Working, None, now_ms, &mut out); + } + } + // A finished tool ends a permission wait (the user approved); otherwise + // hooks may arrive out of order and it says nothing about the turn. + AgentEvent::ToolFinished => { + if self.live() && self.status.state == State::Waiting { + self.set_status(State::Working, None, now_ms, &mut out); + } + } + AgentEvent::WaitingForPermission { .. } => { + if self.live() { + self.set_status( + State::Waiting, + Some("approve in the terminal"), + now_ms, + &mut out, + ); + } + } + AgentEvent::TurnEnded if !self.started => {} + AgentEvent::TurnEnded => { + self.expired = None; + let turn = self.current_turn.as_ref().map(|(t, _)| t.clone()); + if let Some(turn) = turn + && self.last_completed.as_deref() != Some(turn.as_str()) + { + self.complete(turn, Outcome::Done, &mut out); + } + self.input_mark = input_count; + self.set_status(State::Ready, None, now_ms, &mut out); + } + } + out + } + + fn check_accepted(&mut self, out: &mut Vec) { + let ready = self + .pending + .as_ref() + .is_some_and(|p| p.hook_seen && p.log_seen); + if ready { + let p = self.pending.take().unwrap(); + self.record(&p.request_id, RequestState::Accepted); + out.push(Out::Accepted { + request_id: p.request_id, + }); + } + } + + fn complete(&mut self, turn_id: String, outcome: Outcome, out: &mut Vec) { + self.last_completed = Some(turn_id.clone()); + let request_id = match &self.current_turn { + Some((t, r)) if *t == turn_id => r.clone(), + _ => None, + }; + if let Some(r) = &request_id { + self.record( + r, + RequestState::Completed { + turn_id: turn_id.clone(), + outcome, + }, + ); + if self.pending.as_ref().is_some_and(|p| p.request_id == *r) { + self.pending = None; + } + } + out.push(Out::Complete { + turn_id, + request_id, + outcome, + }); + } + + pub fn on_log(&mut self, item: LogItem, now_ms: i64, input_count: u64) -> Vec { + let mut out = Vec::new(); + if self.exited { + return out; + } + match item { + LogItem::UserMessage { + id, + turn_id, + text, + created_at_ms, + } => { + let mut request_id = None; + if let Some(p) = &mut self.pending + && !p.log_seen + && same_prompt(&p.text, &text) + { + p.log_seen = true; + request_id = Some(p.request_id.clone()); + } + // The timed-out link may only match the NEXT user entry. + let expired = self.expired.take(); + if request_id.is_none() + && let Some((rid, t)) = expired + && same_prompt(&t, &text) + { + request_id = Some(rid); + } + let message = user_message( + id, + turn_id.clone(), + &text, + request_id.clone(), + created_at_ms, + ); + self.current_turn = Some((turn_id, request_id)); + self.push_message(message.clone()); + out.push(Out::Message(message)); + self.check_accepted(&mut out); + } + LogItem::AssistantPart { + message_id, + part, + turn_id, + created_at_ms, + } => { + let part = redact_part(part); + self.assistant_part(message_id, part, turn_id, created_at_ms, &mut out); + } + LogItem::TurnEnded { .. } if !self.started => {} + LogItem::TurnEnded { turn_id, outcome } => { + self.expired = None; + if self.last_completed.as_deref() != Some(turn_id.as_str()) { + self.complete(turn_id, outcome, &mut out); + } + self.input_mark = input_count; + self.set_status(State::Ready, None, now_ms, &mut out); + } + LogItem::Backfill(item) => self.on_backfill(*item), + // The App handles problems (withdrawing chat when fatal). + LogItem::Problem { .. } => {} + } + out + } + + fn push_message(&mut self, message: ChatMessage) { + self.history_bytes += message.bytes(); + self.history.push_back(message); + self.trim_history(); + } + + /// Evict the oldest messages beyond 500 messages or 8 MiB (the newest stays). + fn trim_history(&mut self) { + while self.history.len() > 1 + && (self.history.len() > MAX_HISTORY_MESSAGES || self.history_bytes > MAX_HISTORY_BYTES) + { + if let Some(old) = self.history.pop_front() { + self.history_bytes -= old.bytes(); + } + } + } + + fn assistant_part( + &mut self, + message_id: String, + mut part: Part, + turn_id: String, + created_at_ms: i64, + out: &mut Vec, + ) { + let existing = self + .history + .iter() + .rposition(|m| m.role == Role::Assistant && m.message_id == message_id); + let size = existing.map_or(0, |i| self.history[i].bytes()); + if let Some(i) = existing { + let m = &self.history[i]; + if m.parts + .iter() + .any(|p| p.id() == part.id() || p.id() == TRUNCATED_ID) + { + return; // repeat, or the message already stopped growing + } + } + // Cap the message: keep what fits, then one final marker part. + let mut marker = false; + if size + part_bytes(&part) > MAX_MESSAGE_BYTES { + marker = true; + let room = MAX_MESSAGE_BYTES - size; + match &mut part { + Part::Text { markdown, .. } => { + let cut = truncate_at(markdown, room).len(); + markdown.truncate(cut); + } + Part::Tool { .. } => {} + } + } + let mut new_parts = Vec::new(); + let keep = !(marker && matches!(part, Part::Tool { .. })); + if keep { + new_parts.push(part); + } + if marker { + new_parts.push(truncated_marker()); + } + match existing { + Some(i) => { + for p in new_parts { + self.history_bytes += part_bytes(&p); + self.history[i].parts.push(p.clone()); + out.push(Out::Delta { + message_id: message_id.clone(), + part: p, + }); + } + self.trim_history(); + } + None => { + let request_id = match &self.current_turn { + Some((t, r)) if *t == turn_id => r.clone(), + _ => None, + }; + let message = ChatMessage { + message_id, + role: Role::Assistant, + origin: None, + turn_id, + request_id, + created_at_ms, + parts: new_parts, + }; + self.push_message(message.clone()); + out.push(Out::Message(message)); + } + } + } + + /// Validate and, if allowed, return the text to paste (the caller pastes it with + /// `send_paste`). `Err` carries the `Out`s to send to the asking conversation; a + /// repeated request that is still in flight has nothing new to report (empty vec). + pub fn submit( + &mut self, + request_id: &str, + generation: &str, + text: &str, + input_count: u64, + remote_controlled: bool, + ) -> Result> { + let err = |text: &str| { + Err(vec![Out::Error { + text: text.to_string(), + request_id: Some(request_id.to_string()), + }]) + }; + if self.exited { + return err("the agent exited; start it again"); + } + if generation != self.generation { + return err("the agent was restarted; reopen the chat"); + } + if let Some(state) = self.requests.get(request_id) { + return Err(match state { + RequestState::Pending => Vec::new(), + RequestState::Accepted => vec![Out::Accepted { + request_id: request_id.to_string(), + }], + RequestState::Completed { turn_id, outcome } => vec![ + Out::Accepted { + request_id: request_id.to_string(), + }, + Out::Complete { + turn_id: turn_id.clone(), + request_id: Some(request_id.to_string()), + outcome: *outcome, + }, + ], + RequestState::Failed(text) => vec![Out::Error { + text: text.clone(), + request_id: Some(request_id.to_string()), + }], + }); + } + let text = match clean_prompt(text) { + Ok(t) => t, + Err(e) => return err(e), + }; + if self.status.state == State::Unknown { + return err("chat is not ready yet"); + } + if self.status.state != State::Ready || self.pending.is_some() { + return err("the agent is busy; wait for the current turn to finish"); + } + if input_count != self.input_mark { + return err("the terminal has unsent input; finish or clear it there"); + } + if remote_controlled { + return err("a terminal viewer has control; release it first"); + } + self.expired = None; + self.pending = Some(Pending { + request_id: request_id.to_string(), + text: text.to_string(), + pasted_at: None, + hook_seen: false, + log_seen: false, + }); + self.record(request_id, RequestState::Pending); + Ok(text.to_string()) + } + + /// Call right after a successful paste so the 10 s acceptance clock starts then. + pub fn mark_pasted(&mut self, request_id: &str, now: Instant) { + if let Some(p) = &mut self.pending + && p.request_id == request_id + { + p.pasted_at = Some(now); + } + } + + /// The paste itself failed (queue full): report it, never resend. + pub fn paste_failed(&mut self, request_id: &str, reason: &str) -> Vec { + if self + .pending + .as_ref() + .is_some_and(|p| p.request_id == request_id) + { + self.pending = None; + } + self.record(request_id, RequestState::Failed(reason.to_string())); + vec![Out::Error { + text: reason.to_string(), + request_id: Some(request_id.to_string()), + }] + } + + pub fn tick(&mut self, now: Instant) -> Vec { + // A paste whose clock was not started (no `mark_pasted`) starts at the first tick. + if let Some(p) = &mut self.pending { + p.pasted_at.get_or_insert(now); + } + let expired = self.pending.as_ref().is_some_and(|p| { + p.pasted_at + .is_some_and(|t| now.saturating_duration_since(t) >= ACCEPT_TIMEOUT) + }); + if !expired { + return Vec::new(); + } + let p = self.pending.take().unwrap(); + self.expired = Some((p.request_id.clone(), p.text.clone())); + let text = "delivery unknown; check the terminal"; + self.record(&p.request_id, RequestState::Failed(text.into())); + vec![Out::Error { + text: text.into(), + request_id: Some(p.request_id), + }] + } + + /// Whole messages, oldest first: the newest ones that fit `max_messages` and + /// `max_bytes` (sum of part text lengths), strictly older than `before` when given + /// (an unknown `before` gives nothing). The newest message is always included even + /// if it alone exceeds `max_bytes` (messages are capped at 131 072 bytes). + pub fn history( + &self, + before: Option<&str>, + max_messages: usize, + max_bytes: usize, + ) -> Vec { + let end = match before { + None => self.history.len(), + Some(id) => match self.history.iter().position(|m| m.message_id == id) { + Some(i) => i, + None => return Vec::new(), + }, + }; + let mut picked = Vec::new(); + let mut bytes = 0usize; + for m in self.history.iter().take(end).rev() { + if picked.len() >= max_messages { + break; + } + let size = m.bytes(); + if !picked.is_empty() && bytes + size > max_bytes { + break; + } + bytes += size; + picked.push(m.clone()); + } + picked.reverse(); + picked + } + + /// Content that was already in a log the adapter switched to (a resume of + /// another session): it goes into history only. Nothing is sent, and status, + /// the current turn, pending prompts and request records are left alone. The + /// App closes open conversations afterwards so clients reload the history. + pub fn on_backfill(&mut self, item: LogItem) { + if self.exited { + return; + } + let mut ignored = Vec::new(); + match item { + LogItem::UserMessage { + id, + turn_id, + text, + created_at_ms, + } => { + // Resuming a session read earlier must not repeat its prompts. + if self.history.iter().any(|m| m.message_id == id) { + return; + } + let message = user_message(id, turn_id, &text, None, created_at_ms); + self.push_message(message); + } + LogItem::AssistantPart { + message_id, + part, + turn_id, + created_at_ms, + } => { + let part = redact_part(part); + // Old turns carry no request id: pass a turn that is never current. + let current = self.current_turn.take(); + self.assistant_part(message_id, part, turn_id, created_at_ms, &mut ignored); + self.current_turn = current; + } + LogItem::Backfill(item) => self.on_backfill(*item), + LogItem::TurnEnded { .. } | LogItem::Problem { .. } => {} + } + } + + pub fn on_process_exit(&mut self, now_ms: i64) -> Vec { + let mut out = Vec::new(); + if self.exited { + return out; + } + self.exited = true; + self.set_status(State::Unknown, None, now_ms, &mut out); + let request_id = self.pending.take().map(|p| p.request_id); + if let Some(r) = &request_id { + self.record(r, RequestState::Failed("the agent exited".into())); + } + out.push(Out::Error { + text: "the agent exited".into(), + request_id, + }); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn started() -> AgentEvent { + AgentEvent::SessionStarted { + session_id: "s".into(), + log_path: PathBuf::from("/x"), + source: "startup".into(), + same_log: false, + } + } + fn ready() -> ChatSession { + let mut s = ChatSession::new("g1".into(), 0); + s.on_event(started(), 1, 0); + s + } + fn user(id: &str, turn: &str, text: &str) -> LogItem { + LogItem::UserMessage { + id: id.into(), + turn_id: turn.into(), + text: text.into(), + created_at_ms: 5, + } + } + fn text_part(id: &str, t: &str) -> Part { + Part::Text { + id: id.into(), + markdown: t.into(), + } + } + fn asst(mid: &str, part: Part, turn: &str) -> LogItem { + LogItem::AssistantPart { + message_id: mid.into(), + part, + turn_id: turn.into(), + created_at_ms: 6, + } + } + fn end(turn: &str, outcome: Outcome) -> LogItem { + LogItem::TurnEnded { + turn_id: turn.into(), + outcome, + } + } + fn completes(outs: &[Out]) -> Vec<&Out> { + outs.iter() + .filter(|o| matches!(o, Out::Complete { .. })) + .collect() + } + fn err_text(r: Result>) -> String { + match r.unwrap_err().remove(0) { + Out::Error { text, .. } => text, + o => panic!("{o:?}"), + } + } + + #[test] + fn status_table() { + let mut s = ChatSession::new("g".into(), 0); + assert_eq!(s.status().state, State::Unknown); + // events before a session started do not change status + assert!(s.on_event(AgentEvent::ToolStarted, 1, 0).is_empty()); + // a turn end before the session started neither sets Ready nor the input mark + assert!(s.on_event(AgentEvent::TurnEnded, 1, 9).is_empty()); + assert!(s.on_log(end("t", Outcome::Done), 1, 9).is_empty()); + assert_eq!(s.status().state, State::Unknown); + assert!( + s.on_event(AgentEvent::PromptSubmitted { text: "x".into() }, 1, 0) + .is_empty() + ); + let o = s.on_event(started(), 10, 0); + assert_eq!( + o, + vec![Out::Status(Status { + state: State::Ready, + since_ms: 10, + detail: None + })] + ); + // repeated Ready is no change + assert!(s.on_event(AgentEvent::TurnEnded, 11, 0).is_empty()); + assert_eq!(s.status().since_ms, 10); + s.on_event(AgentEvent::PromptSubmitted { text: "x".into() }, 20, 0); + assert_eq!( + (s.status().state, s.status().since_ms), + (State::Working, 20) + ); + assert!(s.on_event(AgentEvent::ToolStarted, 21, 0).is_empty()); + // ToolFinished while Working changes nothing + assert!(s.on_event(AgentEvent::ToolFinished, 22, 0).is_empty()); + let o = s.on_event( + AgentEvent::WaitingForPermission { + detail: "raw secret text".into(), + }, + 30, + 0, + ); + assert_eq!(s.status().state, State::Waiting); + assert_eq!( + s.status().detail.as_deref(), + Some("approve in the terminal") + ); + assert_eq!(o.len(), 1); + // approval given: the tool finished, the agent works again + s.on_event(AgentEvent::ToolFinished, 31, 0); + assert_eq!( + (s.status().state, s.status().since_ms), + (State::Working, 31) + ); + assert_eq!(s.status().detail, None); + s.on_event(AgentEvent::TurnEnded, 40, 0); + assert_eq!((s.status().state, s.status().since_ms), (State::Ready, 40)); + assert_eq!(s.status().detail, None); + assert_eq!(s.heartbeat(), Out::Status(s.status().clone())); + } + + #[test] + fn turn_end_from_hook_and_log_completes_once() { + // log-then-hook (the App's order) + let mut s = ready(); + s.on_log(user("u1", "t1", "hi"), 2, 0); + s.on_event(AgentEvent::ToolStarted, 3, 0); + let o = s.on_log(end("t1", Outcome::Done), 4, 0); + assert_eq!(completes(&o).len(), 1); + assert!(completes(&s.on_event(AgentEvent::TurnEnded, 4, 0)).is_empty()); + // hook-first + s.on_log(user("u2", "t2", "again"), 5, 0); + let o = s.on_event(AgentEvent::TurnEnded, 6, 0); + assert_eq!( + completes(&o), + vec![&Out::Complete { + turn_id: "t2".into(), + request_id: None, + outcome: Outcome::Done + }] + ); + assert!(completes(&s.on_log(end("t2", Outcome::Interrupted), 6, 0)).is_empty()); + } + + #[test] + fn problem_items_produce_no_output() { + let mut s = ready(); + let p = LogItem::Problem { + detail: "bad".into(), + fatal: false, + }; + assert!(s.on_log(p, 2, 0).is_empty()); + } + + #[test] + fn late_turn_end_completes_a_request_whose_delivery_was_unknown() { + let mut s = ready(); + submit_ok(&mut s, "r1", "go"); + let t = Instant::now(); + s.tick(t); + s.tick(t + Duration::from_secs(11)); + // the transcript proves delivery after all + s.on_log(user("u1", "t1", "go"), 20, 0); + let o = s.on_log(end("t1", Outcome::Done), 21, 0); + assert!(matches!( + completes(&o)[0], + Out::Complete { request_id: Some(r), .. } if r == "r1" + )); + let again = s.submit("r1", "g1", "go", 0, false).unwrap_err(); + assert!(matches!(again[1], Out::Complete { .. })); + } + + #[test] + fn timed_out_request_only_matches_the_next_user_entry() { + let mut s = ready(); + submit_ok(&mut s, "r1", "continue"); + let t = Instant::now(); + s.tick(t); + s.tick(t + Duration::from_secs(11)); + s.on_log(user("u1", "t1", "something else"), 20, 0); + s.on_log(end("t1", Outcome::Done), 21, 0); + s.on_log(user("u2", "t2", "continue"), 22, 0); + let m = s.history(None, 1, usize::MAX).remove(0); + assert_eq!(m.origin, Some(Origin::Local)); + assert_eq!(m.request_id, None); + } + + #[test] + fn oversized_user_message_gets_a_separate_truncated_part() { + let mut s = ready(); + s.on_log(user("u1", "t1", &"a".repeat(140_000)), 2, 0); + let m = &s.history(None, 1, usize::MAX)[0]; + assert_eq!(m.parts.len(), 2); + assert_eq!(m.parts[1].id(), TRUNCATED_ID); + assert!( + matches!(&m.parts[0], Part::Text { markdown, .. } if markdown.len() == MAX_MESSAGE_BYTES) + ); + } + + #[test] + fn turn_end_without_open_turn_emits_no_complete() { + let mut s = ready(); + assert!(completes(&s.on_event(AgentEvent::TurnEnded, 2, 0)).is_empty()); + } + + #[test] + fn interrupt_outcome_wins() { + let mut s = ready(); + s.on_log(user("u1", "t1", "hi"), 2, 0); + let o = s.on_log(end("t1", Outcome::Interrupted), 3, 0); + assert!(matches!( + completes(&o)[0], + Out::Complete { + outcome: Outcome::Interrupted, + .. + } + )); + assert!(completes(&s.on_event(AgentEvent::TurnEnded, 3, 0)).is_empty()); + } + + fn submit_ok(s: &mut ChatSession, rid: &str, text: &str) { + assert_eq!(s.submit(rid, "g1", text, 0, false).unwrap(), text); + } + + #[test] + fn remote_prompt_is_accepted_only_after_hook_and_log() { + for hook_first in [true, false] { + let mut s = ready(); + submit_ok(&mut s, "r1", "do it\n"); + let hook = AgentEvent::PromptSubmitted { + text: "do it".into(), // trailing whitespace is trimmed on both sides + }; + let log = user("u1", "t1", "do it"); + let (a, b) = if hook_first { + let a = s.on_event(hook, 2, 0); + let b = s.on_log(log, 2, 0); + (a, b) + } else { + let a = s.on_log(log, 2, 0); + let b = s.on_event(hook, 2, 0); + (a, b) + }; + let acc = Out::Accepted { + request_id: "r1".into(), + }; + assert!(!a.contains(&acc), "accepted too early"); + assert!(b.contains(&acc)); + let m = s.history(None, 10, 1000); + assert_eq!(m[0].origin, Some(Origin::Remote)); + assert_eq!(m[0].request_id.as_deref(), Some("r1")); + } + } + + #[test] + fn local_prompt_is_origin_local_without_request_id() { + let mut s = ready(); + let o = s.on_log(user("u1", "t1", "typed"), 2, 0); + match &o[0] { + Out::Message(m) => { + assert_eq!(m.role, Role::User); + assert_eq!(m.origin, Some(Origin::Local)); + assert_eq!(m.request_id, None); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn assistant_parts_become_message_then_deltas_and_repeats_are_ignored() { + let mut s = ready(); + let o = s.on_log(asst("m1", text_part("p1", "a"), "t1"), 2, 0); + assert!( + matches!(&o[0], Out::Message(m) if m.role == Role::Assistant && m.parts.len() == 1) + ); + let tool = Part::Tool { + id: "p2".into(), + name: "Bash".into(), + summary: "ls".into(), + }; + let o = s.on_log(asst("m1", tool.clone(), "t1"), 3, 0); + assert_eq!( + o, + vec![Out::Delta { + message_id: "m1".into(), + part: tool + }] + ); + assert!( + s.on_log(asst("m1", text_part("p1", "again"), "t1"), 4, 0) + .is_empty() + ); + assert_eq!(s.history(None, 10, 1000)[0].parts.len(), 2); + } + + #[test] + fn assistant_reply_to_remote_prompt_carries_request_id() { + let mut s = ready(); + submit_ok(&mut s, "r1", "go"); + s.on_log(user("u1", "t1", "go"), 2, 0); + let o = s.on_log(asst("m1", text_part("p", "ok"), "t1"), 3, 0); + assert!(matches!(&o[0], Out::Message(m) if m.request_id.as_deref() == Some("r1"))); + s.on_event(AgentEvent::PromptSubmitted { text: "go".into() }, 3, 0); + let o = s.on_log(end("t1", Outcome::Done), 4, 0); + assert!(matches!( + completes(&o)[0], + Out::Complete { request_id: Some(r), .. } if r == "r1" + )); + // after completion a duplicate returns Accepted + Complete + let again = s.submit("r1", "g1", "go", 0, false).unwrap_err(); + assert!(matches!(again[0], Out::Accepted { .. })); + assert!(matches!(again[1], Out::Complete { .. })); + } + + #[test] + fn submit_checks_in_order_with_exact_errors() { + let mut s = ChatSession::new("g1".into(), 0); + assert_eq!( + err_text(s.submit("r", "other", "hi", 0, false)), + "the agent was restarted; reopen the chat" + ); + let long = "a".repeat(32_769); + assert_eq!( + err_text(s.submit("r", "g1", &long, 0, false)), + "prompt too long (max 32768 bytes)" + ); + assert_eq!( + err_text(s.submit("r", "g1", "a\x1b[201~b", 0, false)), + "prompt contains control characters" + ); + assert_eq!( + err_text(s.submit("r", "g1", "hi", 0, false)), + "chat is not ready yet" + ); + s.on_event(started(), 1, 0); + s.on_event(AgentEvent::ToolStarted, 2, 0); + assert_eq!( + err_text(s.submit("r", "g1", "hi", 0, false)), + "the agent is busy; wait for the current turn to finish" + ); + s.on_event(AgentEvent::TurnEnded, 3, 0); + assert_eq!( + err_text(s.submit("r", "g1", "hi", 2, false)), + "the terminal has unsent input; finish or clear it there" + ); + assert_eq!( + err_text(s.submit("r", "g1", "hi", 0, true)), + "a terminal viewer has control; release it first" + ); + // errors do not consume the request id + assert!(s.submit("r", "g1", "hi", 0, false).is_ok()); + // a second prompt while one waits for acceptance is busy + assert_eq!( + err_text(s.submit("r2", "g1", "hi2", 0, false)), + "the agent is busy; wait for the current turn to finish" + ); + } + + #[test] + fn duplicate_request_returns_recorded_state_and_never_pastes() { + let mut s = ready(); + submit_ok(&mut s, "r1", "go"); + assert!(s.submit("r1", "g1", "go", 0, false).unwrap_err().is_empty()); + s.on_event(AgentEvent::PromptSubmitted { text: "go".into() }, 2, 0); + s.on_log(user("u1", "t1", "go"), 2, 0); + assert_eq!( + s.submit("r1", "g1", "go", 0, false).unwrap_err(), + vec![Out::Accepted { + request_id: "r1".into() + }] + ); + // a failed one answers with the earlier error + let mut s = ready(); + submit_ok(&mut s, "r2", "go"); + let t = Instant::now(); + s.tick(t); + s.tick(t + Duration::from_secs(11)); + assert_eq!( + err_text(s.submit("r2", "g1", "go", 0, false)), + "delivery unknown; check the terminal" + ); + } + + #[test] + fn typing_after_turn_end_blocks_submit_and_next_turn_end_clears_it() { + let mut s = ready(); + s.on_event(AgentEvent::PromptSubmitted { text: "x".into() }, 2, 0); + s.on_event(AgentEvent::TurnEnded, 3, 5); + assert!(s.submit("r1", "g1", "hi", 5, false).is_ok()); + let mut s = ready(); + s.on_event(AgentEvent::TurnEnded, 3, 5); + assert!(s.submit("r1", "g1", "hi", 6, false).is_err()); + s.on_event( + AgentEvent::PromptSubmitted { + text: "typed".into(), + }, + 4, + 6, + ); + s.on_event(AgentEvent::TurnEnded, 5, 6); + assert!(s.submit("r2", "g1", "hi", 6, false).is_ok()); + } + + #[test] + fn delivery_unknown_after_10s_and_no_resend() { + let mut s = ready(); + submit_ok(&mut s, "r1", "go"); + let t = Instant::now(); + assert!(s.tick(t).is_empty()); + assert!(s.tick(t + Duration::from_secs(9)).is_empty()); + let o = s.tick(t + Duration::from_secs(10)); + assert_eq!( + o, + vec![Out::Error { + text: "delivery unknown; check the terminal".into(), + request_id: Some("r1".into()) + }] + ); + assert!(s.tick(t + Duration::from_secs(30)).is_empty()); + // the chat can be used again, the request cannot + assert!(s.submit("r1", "g1", "go", 0, false).is_err()); + assert!(s.submit("r3", "g1", "go", 0, false).is_ok()); + } + + #[test] + fn paste_failed_reports_once_and_records() { + let mut s = ready(); + submit_ok(&mut s, "r1", "go"); + let o = s.paste_failed( + "r1", + "Terminal input queue full or closed; input was not queued", + ); + assert_eq!(o.len(), 1); + assert!(s.submit("r2", "g1", "go", 0, false).is_ok()); + } + + #[test] + fn history_respects_counts_bytes_and_before() { + let mut s = ready(); + for i in 0..5 { + s.on_log(user(&format!("u{i}"), &format!("t{i}"), "0123456789"), 2, 0); + } + let ids = |v: Vec| v.into_iter().map(|m| m.message_id).collect::>(); + assert_eq!(ids(s.history(None, 2, 1000)), ["u3", "u4"]); + assert_eq!(ids(s.history(None, 10, 25)), ["u3", "u4"]); + assert_eq!(ids(s.history(Some("u3"), 10, 1000)), ["u0", "u1", "u2"]); + assert_eq!(ids(s.history(Some("u3"), 1, 1000)), ["u2"]); + assert!(s.history(Some("nope"), 10, 1000).is_empty()); + assert!(s.history(Some("u0"), 10, 1000).is_empty()); + // the newest message is returned even if it alone exceeds max_bytes + assert_eq!(ids(s.history(None, 10, 3)), ["u4"]); + } + + #[test] + fn history_is_bounded_to_500_messages() { + let mut s = ready(); + for i in 0..520 { + s.on_log(user(&format!("u{i}"), "t", "x"), 2, 0); + } + let all = s.history(None, 10_000, usize::MAX); + assert_eq!(all.len(), 500); + assert_eq!(all[0].message_id, "u20"); + } + + #[test] + fn oversized_message_stops_growing_with_a_truncated_part() { + let mut s = ready(); + s.on_log(asst("m", text_part("p1", &"a".repeat(131_000)), "t"), 2, 0); + let o = s.on_log(asst("m", text_part("p2", &"b".repeat(2000)), "t"), 3, 0); + assert_eq!(o.len(), 2, "kept part + marker"); + assert!( + s.on_log(asst("m", text_part("p3", "more"), "t"), 4, 0) + .is_empty() + ); + let m = &s.history(None, 1, usize::MAX)[0]; + assert!(m.bytes() <= MAX_MESSAGE_BYTES + TRUNCATED.len()); + assert!( + matches!(m.parts.last().unwrap(), Part::Text { markdown, .. } if markdown == TRUNCATED) + ); + } + + #[test] + fn redaction_masks_secret_values_in_text_and_tool_summaries() { + let key = "sk-proj-abcdef1234567890ABCDEF"; + let mut s = ready(); + s.on_log(user("u1", "t1", &format!("my key is {key}")), 2, 0); + s.on_log( + asst("m1", text_part("p1", &format!("saw {key}")), "t1"), + 3, + 0, + ); + s.on_log( + asst( + "m1", + Part::Tool { + id: "p2".into(), + name: "Bash".into(), + summary: format!("curl -H 'Authorization: {key}'"), + }, + "t1", + ), + 4, + 0, + ); + let dump = format!("{:?}", s.history(None, 10, usize::MAX)); + assert!(!dump.contains("abcdef1234567890"), "{dump}"); + assert!(dump.contains("[redacted]")); + assert_eq!(redact("vault get dialf/openai"), "vault get dialf/openai"); + } + + fn restart(source: &str, same_log: bool) -> AgentEvent { + AgentEvent::SessionStarted { + session_id: "s".into(), + log_path: PathBuf::from("/x"), + source: source.into(), + same_log, + } + } + + #[test] + fn compact_or_resume_on_the_same_log_keeps_the_turn() { + for source in ["compact", "resume"] { + let mut s = ready(); + submit_ok(&mut s, "r1", "go"); + s.on_event(AgentEvent::PromptSubmitted { text: "go".into() }, 2, 0); + let since = s.status().since_ms; + // typing during the turn moved the input count; compaction must not + // hide it, nor end the pending prompt or the turn + assert!( + s.on_event(restart(source, true), 3, 7).is_empty(), + "{source}" + ); + assert_eq!(s.status().state, State::Working); + assert_eq!(s.status().since_ms, since); + let o = s.on_log(user("u1", "t1", "go"), 4, 7); + assert!(o.contains(&Out::Accepted { + request_id: "r1".into() + })); + let o = s.on_log(end("t1", Outcome::Done), 5, 7); + assert!(matches!( + completes(&o)[0], + Out::Complete { request_id: Some(r), .. } if r == "r1" + )); + } + // any SessionStarted on the same log while a turn is in progress keeps it + let mut s = ready(); + s.on_event(AgentEvent::PromptSubmitted { text: "x".into() }, 2, 0); + assert!(s.on_event(restart("", true), 3, 4).is_empty()); + assert_eq!(s.status().state, State::Working); + // compact while idle keeps the input mark too + let mut s = ready(); + s.on_event(restart("compact", true), 3, 4); + assert_eq!( + err_text(s.submit("r", "g1", "hi", 4, false)), + "the terminal has unsent input; finish or clear it there" + ); + } + + #[test] + fn startup_clear_or_a_new_log_resets_to_ready() { + for (source, same_log) in [("startup", true), ("clear", true), ("resume", false)] { + let mut s = ready(); + s.on_event(AgentEvent::PromptSubmitted { text: "x".into() }, 2, 0); + let o = s.on_event(restart(source, same_log), 3, 4); + assert_eq!( + o, + vec![Out::Status(Status { + state: State::Ready, + since_ms: 3, + detail: None + })], + "{source} {same_log}" + ); + assert!(s.submit("r", "g1", "hi", 4, false).is_ok()); + } + } + + #[test] + fn backfill_lands_in_history_without_outs_or_status() { + let mut s = ready(); + s.on_event(AgentEvent::PromptSubmitted { text: "x".into() }, 2, 0); + // on_backfill returns nothing: no Outs by construction + s.on_backfill(user("u1", "t1", "old prompt")); + s.on_backfill(asst("m1", text_part("p1", "old reply"), "t1")); + s.on_backfill(asst("m1", text_part("p2", "more"), "t1")); + s.on_backfill(end("t1", Outcome::Done)); + s.on_backfill(end("t1", Outcome::Interrupted)); + // wrapped items given to on_log are backfill too + let wrapped = LogItem::Backfill(Box::new(user("u2", "t2", "older"))); + assert!(s.on_log(wrapped, 3, 0).is_empty()); + assert_eq!(s.status().state, State::Working); + let h = s.history(None, 10, usize::MAX); + assert_eq!(h.len(), 3); + assert_eq!(h[0].origin, Some(Origin::Local)); + assert_eq!(h[1].parts.len(), 2); + assert!(h.iter().all(|m| m.request_id.is_none())); + } + + #[test] + fn resuming_a_session_read_before_does_not_repeat_its_prompts() { + let mut s = ready(); + s.on_log(user("u1", "t1", "first"), 2, 0); + s.on_log(asst("m1", text_part("p1", "reply"), "t1"), 3, 0); + // /clear, then /resume of the same session: its file is read again + s.on_backfill(user("u1", "t1", "first")); + s.on_backfill(asst("m1", text_part("p1", "reply"), "t1")); + let h = s.history(None, 10, usize::MAX); + assert_eq!(h.len(), 2); + assert_eq!(h[1].parts.len(), 1); + } + + #[test] + fn commands_and_shell_mode_are_refused() { + let mut s = ready(); + for text in ["/clear", " /model opus", "!rm -rf x", "\n\t!ls"] { + assert_eq!( + err_text(s.submit("r", "g1", text, 0, false)), + "commands and shell mode are not available from chat", + "{text:?}" + ); + } + // a duplicate request id is still answered from the record first + submit_ok(&mut s, "r1", "go"); + assert!( + s.submit("r1", "g1", "/clear", 0, false) + .unwrap_err() + .is_empty() + ); + let mut s = ready(); + submit_ok(&mut s, "r2", "# remember this"); + let mut s = ready(); + submit_ok(&mut s, "r3", "explain a/b and !x"); + } + + #[test] + fn prompt_match_ignores_leading_whitespace_too() { + let mut s = ready(); + submit_ok(&mut s, "r1", " go"); + s.on_event(AgentEvent::PromptSubmitted { text: "go".into() }, 2, 0); + let o = s.on_log(user("u1", "t1", "go "), 2, 0); + assert!(o.contains(&Out::Accepted { + request_id: "r1".into() + })); + } + + #[test] + fn history_is_bounded_to_8_mib() { + let mut s = ready(); + let big = "a".repeat(120_000); + for i in 0..80 { + s.on_log(asst(&format!("m{i}"), text_part("p", &big), "t"), 2, 0); + } + let all = s.history(None, 10_000, usize::MAX); + let total: usize = all.iter().map(ChatMessage::bytes).sum(); + assert!(total <= 8 * 1024 * 1024, "{total}"); + assert_eq!(all.len(), 8 * 1024 * 1024 / 120_000); + assert_eq!(all.last().unwrap().message_id, "m79"); + // growing a message by deltas counts too + let mut s = ready(); + for i in 0..69 { + s.on_log(asst(&format!("m{i}"), text_part("p", &big), "t"), 2, 0); + } + s.on_log(asst("m68", text_part("q", "b"), "t"), 2, 0); + let all = s.history(None, 10_000, usize::MAX); + let total: usize = all.iter().map(ChatMessage::bytes).sum(); + assert!(total <= 8 * 1024 * 1024, "{total}"); + assert_eq!(all.last().unwrap().parts.len(), 2); + } + + #[test] + fn process_exit_goes_unknown_and_refuses_submits() { + let mut s = ready(); + submit_ok(&mut s, "r1", "go"); + let o = s.on_process_exit(50); + assert!(o.contains(&Out::Status(Status { + state: State::Unknown, + since_ms: 50, + detail: None + }))); + assert!(o.contains(&Out::Error { + text: "the agent exited".into(), + request_id: Some("r1".into()) + })); + assert_eq!( + err_text(s.submit("r9", "g1", "go", 0, false)), + "the agent exited; start it again" + ); + assert_eq!( + err_text(s.submit("r9", "wrong", "go", 0, false)), + "the agent exited; start it again" + ); + assert!(s.on_event(started(), 60, 0).is_empty()); + assert_eq!(s.status().state, State::Unknown); + } +} diff --git a/src/chat/wire.rs b/src/chat/wire.rs new file mode 100644 index 0000000..016cc2e --- /dev/null +++ b/src/chat/wire.rs @@ -0,0 +1,623 @@ +//! agentChat v1: JSON shapes exchanged with Astation (see designs/agent-chat-host.md). + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::adapter::{Outcome, Part}; +use super::session::{ChatMessage, Origin, Out, Role, State, Status}; + +const MAX_ID: usize = 128; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ChatEnvelope { + pub v: u32, + pub op: String, + pub conversation_id: String, + pub agent_id: String, + #[serde(flatten)] + pub body: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Inbound { + Open { + before: Option, + }, + Submit { + generation: String, + request_id: String, + text: String, + }, + Close, +} + +fn valid_id(s: &str) -> bool { + (1..=MAX_ID).contains(&s.len()) +} + +fn str_field(env: &ChatEnvelope, name: &str) -> Result { + match env.body.get(name) { + Some(Value::String(s)) => Ok(s.clone()), + _ => Err(format!("missing field {name}")), + } +} + +pub fn parse_inbound(env: &ChatEnvelope) -> Result { + if env.v != 1 { + return Err("unsupported version".into()); + } + if !matches!(env.op.as_str(), "open" | "submit" | "close") { + return Err("unsupported op".into()); + } + if !valid_id(&env.conversation_id) { + return Err("invalid conversationId".into()); + } + if !valid_id(&env.agent_id) { + return Err("invalid agentId".into()); + } + match env.op.as_str() { + "open" => { + let before = match env.body.get("before") { + None | Some(Value::Null) => None, + Some(Value::String(s)) if s.len() <= MAX_ID => Some(s.clone()), + Some(_) => return Err("invalid before".into()), + }; + Ok(Inbound::Open { before }) + } + "submit" => { + let generation = str_field(env, "generation")?; + let request_id = str_field(env, "requestId")?; + let text = str_field(env, "text")?; + if !valid_id(&request_id) { + return Err("invalid requestId".into()); + } + Ok(Inbound::Submit { + generation, + request_id, + text, + }) + } + _ => Ok(Inbound::Close), + } +} + +// ── Bodies ──────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Head<'a> { + generation: &'a str, + sequence: u64, +} + +#[derive(Serialize)] +struct StatusBody<'a> { + state: &'static str, + since: i64, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option<&'a str>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct OpenedBody<'a> { + generation: &'a str, + sequence: u64, + status: StatusBody<'a>, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum PartBody<'a> { + Text { + id: &'a str, + markdown: &'a str, + }, + Tool { + id: &'a str, + name: &'a str, + summary: &'a str, + }, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MessageBody<'a> { + message_id: &'a str, + role: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + origin: Option<&'static str>, + turn_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option<&'a str>, + created_at: i64, + parts: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + history: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DeltaBody<'a> { + message_id: &'a str, + part: PartBody<'a>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AcceptedBody<'a> { + request_id: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CompleteBody<'a> { + turn_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option<&'a str>, + outcome: &'static str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorBody<'a> { + text: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option<&'a str>, +} + +fn state_name(s: State) -> &'static str { + match s { + State::Ready => "ready", + State::Working => "working", + State::Waiting => "waiting", + State::Unknown => "unknown", + } +} + +fn status_body(s: &Status) -> StatusBody<'_> { + StatusBody { + state: state_name(s.state), + since: s.since_ms, + detail: s.detail.as_deref(), + } +} + +fn part_body(p: &Part) -> PartBody<'_> { + match p { + Part::Text { id, markdown } => PartBody::Text { id, markdown }, + Part::Tool { id, name, summary } => PartBody::Tool { id, name, summary }, + } +} + +fn message_body(m: &ChatMessage, history: bool) -> MessageBody<'_> { + MessageBody { + message_id: &m.message_id, + role: match m.role { + Role::User => "user", + Role::Assistant => "assistant", + }, + origin: m.origin.map(|o| match o { + Origin::Local => "local", + Origin::Remote => "remote", + }), + turn_id: &m.turn_id, + request_id: m.request_id.as_deref(), + created_at: m.created_at_ms, + parts: m.parts.iter().map(part_body).collect(), + history: history.then_some(true), + } +} + +fn envelope( + conv: &str, + agent: &str, + op: &str, + head: Option>, + body: impl Serialize, +) -> ChatEnvelope { + let mut map = match serde_json::to_value(body) { + Ok(Value::Object(m)) => m, + _ => Map::new(), + }; + if let Some(Ok(Value::Object(hm))) = head.map(serde_json::to_value) { + map.extend(hm); + } + ChatEnvelope { + v: 1, + op: op.into(), + conversation_id: conv.into(), + agent_id: agent.into(), + body: map, + } +} + +pub fn opened(conv: &str, agent: &str, generation: &str, status: &Status) -> ChatEnvelope { + envelope( + conv, + agent, + "opened", + None, + OpenedBody { + generation, + sequence: 0, + status: status_body(status), + }, + ) +} + +pub fn event(conv: &str, agent: &str, generation: &str, sequence: u64, out: &Out) -> ChatEnvelope { + let head = Head { + generation, + sequence, + }; + match out { + Out::Message(m) => envelope(conv, agent, "message", Some(head), message_body(m, false)), + Out::Delta { message_id, part } => envelope( + conv, + agent, + "delta", + Some(head), + DeltaBody { + message_id, + part: part_body(part), + }, + ), + Out::Status(s) => envelope(conv, agent, "status", Some(head), status_body(s)), + Out::Accepted { request_id } => envelope( + conv, + agent, + "accepted", + Some(head), + AcceptedBody { request_id }, + ), + Out::Complete { + turn_id, + request_id, + outcome, + } => envelope( + conv, + agent, + "complete", + Some(head), + CompleteBody { + turn_id, + request_id: request_id.as_deref(), + outcome: match outcome { + Outcome::Done => "done", + Outcome::Interrupted => "interrupted", + }, + }, + ), + Out::Error { text, request_id } => envelope( + conv, + agent, + "error", + Some(head), + ErrorBody { + text, + request_id: request_id.as_deref(), + }, + ), + } +} + +pub fn history_message( + conv: &str, + agent: &str, + generation: &str, + sequence: u64, + m: &ChatMessage, +) -> ChatEnvelope { + envelope( + conv, + agent, + "message", + Some(Head { + generation, + sequence, + }), + message_body(m, true), + ) +} + +/// For errors raised before a session exists: no generation or sequence. +pub fn error(conv: &str, agent: &str, text: &str, request_id: Option<&str>) -> ChatEnvelope { + envelope(conv, agent, "error", None, ErrorBody { text, request_id }) +} + +/// Atem ends a conversation: after an `error` saying why, or without one when a +/// ninth conversation replaces the oldest. +pub fn close(conv: &str, agent: &str) -> ChatEnvelope { + envelope(conv, agent, "close", None, Map::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn env(op: &str, body: Value) -> ChatEnvelope { + let mut v = json!({"v":1,"op":op,"conversationId":"c1","agentId":"claude"}); + for (k, x) in body.as_object().unwrap() { + v[k] = x.clone(); + } + serde_json::from_value(v).unwrap() + } + + fn msg() -> ChatMessage { + ChatMessage { + message_id: "m1".into(), + role: Role::User, + origin: Some(Origin::Remote), + turn_id: "t1".into(), + request_id: Some("r1".into()), + created_at_ms: 1700000000000, + parts: vec![ + Part::Text { + id: "p1".into(), + markdown: "hi".into(), + }, + Part::Tool { + id: "p2".into(), + name: "Bash".into(), + summary: "ls".into(), + }, + ], + } + } + + fn body(e: &ChatEnvelope) -> Value { + serde_json::to_value(e).unwrap() + } + + #[test] + fn parse_each_op() { + assert_eq!( + parse_inbound(&env("open", json!({}))).unwrap(), + Inbound::Open { before: None } + ); + assert_eq!( + parse_inbound(&env("open", json!({"before":"m9"}))).unwrap(), + Inbound::Open { + before: Some("m9".into()) + } + ); + assert_eq!( + parse_inbound(&env( + "submit", + json!({"generation":"g","requestId":"r","text":"t"}) + )) + .unwrap(), + Inbound::Submit { + generation: "g".into(), + request_id: "r".into(), + text: "t".into() + } + ); + assert_eq!( + parse_inbound(&env("close", json!({"extra":1}))).unwrap(), + Inbound::Close + ); + } + + #[test] + fn parse_rejects() { + let mut e = env("open", json!({})); + e.v = 2; + assert_eq!(parse_inbound(&e).unwrap_err(), "unsupported version"); + assert_eq!( + parse_inbound(&env("nope", json!({}))).unwrap_err(), + "unsupported op" + ); + assert_eq!( + parse_inbound(&env("accepted", json!({}))).unwrap_err(), + "unsupported op" + ); + assert_eq!( + parse_inbound(&env("submit", json!({"requestId":"r","text":"t"}))).unwrap_err(), + "missing field generation" + ); + assert_eq!( + parse_inbound(&env("submit", json!({"generation":"g","text":"t"}))).unwrap_err(), + "missing field requestId" + ); + assert_eq!( + parse_inbound(&env( + "submit", + json!({"generation":"g","requestId":"r","text":5}) + )) + .unwrap_err(), + "missing field text" + ); + let long = "x".repeat(129); + assert_eq!( + parse_inbound(&env( + "submit", + json!({"generation":"g","requestId":long,"text":"t"}) + )) + .unwrap_err(), + "invalid requestId" + ); + let mut e = env("open", json!({})); + e.conversation_id = long.clone(); + assert_eq!(parse_inbound(&e).unwrap_err(), "invalid conversationId"); + let mut e = env("open", json!({})); + e.agent_id = String::new(); + assert_eq!(parse_inbound(&e).unwrap_err(), "invalid agentId"); + assert_eq!( + parse_inbound(&env("open", json!({"before":long}))).unwrap_err(), + "invalid before" + ); + // text size is not checked here + let big = "y".repeat(100_000); + assert!( + parse_inbound(&env( + "submit", + json!({"generation":"g","requestId":"r","text":big}) + )) + .is_ok() + ); + } + + #[test] + fn opened_shape() { + let st = Status { + state: State::Working, + since_ms: 5, + detail: None, + }; + assert_eq!( + body(&opened("c1", "claude", "g", &st)), + json!({"v":1,"op":"opened","conversationId":"c1","agentId":"claude","generation":"g","sequence":0, + "status":{"state":"working","since":5}}) + ); + let st = Status { + state: State::Waiting, + since_ms: 6, + detail: Some("Bash".into()), + }; + assert_eq!( + body(&opened("c1", "claude", "g", &st))["status"], + json!({"state":"waiting","since":6,"detail":"Bash"}) + ); + } + + #[test] + fn event_shapes() { + let b = |o: &Out| body(&event("c1", "claude", "g", 3, o)); + let head = |op: &str| json!({"v":1,"op":op,"conversationId":"c1","agentId":"claude","generation":"g","sequence":3}); + let merged = |op: &str, extra: Value| { + let mut h = head(op); + for (k, v) in extra.as_object().unwrap() { + h[k] = v.clone(); + } + h + }; + assert_eq!( + b(&Out::Message(msg())), + merged( + "message", + json!({"messageId":"m1","role":"user","origin":"remote","turnId":"t1","requestId":"r1", + "createdAt":1700000000000u64, + "parts":[{"id":"p1","kind":"text","markdown":"hi"},{"id":"p2","kind":"tool","name":"Bash","summary":"ls"}]}) + ) + ); + let mut a = msg(); + a.role = Role::Assistant; + a.origin = None; + a.request_id = None; + let v = b(&Out::Message(a)); + assert_eq!(v["role"], "assistant"); + assert!(v.get("origin").is_none() && v.get("requestId").is_none()); + assert_eq!( + b(&Out::Delta { + message_id: "m1".into(), + part: Part::Text { + id: "p3".into(), + markdown: "x".into() + } + }), + merged( + "delta", + json!({"messageId":"m1","part":{"id":"p3","kind":"text","markdown":"x"}}) + ) + ); + assert_eq!( + b(&Out::Status(Status { + state: State::Unknown, + since_ms: 9, + detail: None + })), + merged("status", json!({"state":"unknown","since":9})) + ); + assert_eq!( + b(&Out::Status(Status { + state: State::Ready, + since_ms: 9, + detail: Some("d".into()) + })), + merged("status", json!({"state":"ready","since":9,"detail":"d"})) + ); + assert_eq!( + b(&Out::Accepted { + request_id: "r1".into() + }), + merged("accepted", json!({"requestId":"r1"})) + ); + assert_eq!( + b(&Out::Complete { + turn_id: "t1".into(), + request_id: None, + outcome: Outcome::Interrupted + }), + merged("complete", json!({"turnId":"t1","outcome":"interrupted"})) + ); + assert_eq!( + b(&Out::Complete { + turn_id: "t1".into(), + request_id: Some("r1".into()), + outcome: Outcome::Done + }), + merged( + "complete", + json!({"turnId":"t1","requestId":"r1","outcome":"done"}) + ) + ); + assert_eq!( + b(&Out::Error { + text: "bad".into(), + request_id: Some("r1".into()) + }), + merged("error", json!({"text":"bad","requestId":"r1"})) + ); + assert_eq!( + b(&Out::Error { + text: "bad".into(), + request_id: None + }), + merged("error", json!({"text":"bad"})) + ); + } + + #[test] + fn history_and_pre_session_error() { + let v = body(&history_message("c1", "claude", "g", 4, &msg())); + assert_eq!(v["op"], "message"); + assert_eq!(v["history"], true); + assert_eq!(v["sequence"], 4); + assert_eq!(v["messageId"], "m1"); + assert_eq!( + body(&error("c1", "claude", "no chat", Some("r1"))), + json!({"v":1,"op":"error","conversationId":"c1","agentId":"claude","text":"no chat","requestId":"r1"}) + ); + assert_eq!( + body(&error("c1", "claude", "no chat", None)), + json!({"v":1,"op":"error","conversationId":"c1","agentId":"claude","text":"no chat"}) + ); + } + + #[test] + fn envelope_round_trip() { + let e = event( + "c1", + "claude", + "g", + 1, + &Out::Accepted { + request_id: "r".into(), + }, + ); + let back: ChatEnvelope = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap(); + assert_eq!(back, e); + } + + #[test] + fn close_carries_only_the_ids() { + assert_eq!( + serde_json::to_value(close("c1", "claude")).unwrap(), + json!({"v":1,"op":"close","conversationId":"c1","agentId":"claude"}) + ); + } +} diff --git a/src/claude_client.rs b/src/claude_client.rs index 17c64dd..4ed77a0 100644 --- a/src/claude_client.rs +++ b/src/claude_client.rs @@ -27,8 +27,27 @@ impl ClaudeClient { pty_session::spawn(&self.binary, &self.extra_args) } - pub async fn start_session_sized(&self, rows: u16, cols: u16) -> Result { - pty_session::spawn_sized(&self.binary, &self.extra_args, rows, cols) + /// The arguments every spawn gets (env defaults, then launch arguments). + pub fn launch_args(&self) -> &[String] { + &self.extra_args + } + + /// Spawn with `first` placed before the stored arguments, for this spawn only + /// (so they stay ahead of a user's `--`). + pub async fn start_session_sized( + &self, + rows: u16, + cols: u16, + first: &[String], + ) -> Result { + let args = [first, &self.extra_args[..]].concat(); + pty_session::spawn_sized(&self.binary, &args, rows, cols) + } + + #[cfg(test)] + pub fn set_command_for_test(&mut self, binary: String, args: Vec) { + self.binary = binary; + self.extra_args = args; } } diff --git a/src/codex_client.rs b/src/codex_client.rs index d145503..e28f035 100644 --- a/src/codex_client.rs +++ b/src/codex_client.rs @@ -30,6 +30,12 @@ impl CodexClient { pub async fn start_session_sized(&self, rows: u16, cols: u16) -> Result { pty_session::spawn_sized(&self.binary, &self.extra_args, rows, cols) } + + #[cfg(test)] + pub fn set_command_for_test(&mut self, binary: String, args: Vec) { + self.binary = binary; + self.extra_args = args; + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 85519f0..b5ed409 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ mod tui; mod vault_client; mod memory; mod pair_verify; +mod chat; mod websocket_client; // Agent hub — PTY agents launched by `atem agent launch` mod agent_client; diff --git a/src/memory/secrets.rs b/src/memory/secrets.rs index 1260321..ac7df4c 100644 --- a/src/memory/secrets.rs +++ b/src/memory/secrets.rs @@ -143,6 +143,61 @@ pub fn find_secrets(text: &str) -> Vec { out } +/// Replaces every value `find_secrets` would flag with "[redacted]". A line with a +/// private key header becomes "[redacted]" entirely. Line breaks are kept. +pub fn redact_secrets(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for line in text.split_inclusive('\n') { + let body = line.strip_suffix('\n').unwrap_or(line); + let nl = &line[body.len()..]; + if body.contains("-----BEGIN") && body.contains("PRIVATE KEY") { + out.push_str("[redacted]"); + out.push_str(nl); + continue; + } + let mut secrets: Vec<&str> = url_passwords(body); + secrets.extend(slack_webhook_secret(body)); + for tok in body.split(|c: char| !is_token_char(c)) { + if tok.is_empty() { + continue; + } + if classify(tok).is_some() { + secrets.push(tok); + } + if is_path_like(tok) { + for seg in tok.split(['/', '@']).filter(|s| !s.is_empty()) { + if classify(seg).is_some() { + secrets.push(seg); + } + } + } + } + // Replace only the detected byte ranges (every slice points into `body`). + let mut spans: Vec<(usize, usize)> = secrets + .iter() + .map(|s| { + let start = s.as_ptr() as usize - body.as_ptr() as usize; + (start, start + s.len()) + }) + .collect(); + spans.sort(); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (start, end) in spans { + match merged.last_mut() { + Some(last) if start <= last.1 => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + let mut redacted = body.to_string(); + for (start, end) in merged.into_iter().rev() { + redacted.replace_range(start..end, "[redacted]"); + } + out.push_str(&redacted); + out.push_str(nl); + } + out +} + /// Like `find_secrets`, but for file bytes. Non-UTF-8 content can't be /// checked, so it is reported as a finding (fail-closed). pub fn check_bytes(bytes: &[u8]) -> Vec { @@ -294,4 +349,30 @@ mod tests { assert!(p.iter().any(|l| l.starts_with("creds.txt:1") && !l.contains("IOSFODNN7"))); assert!(p.iter().any(|l| l.starts_with("img.png:0") && l.contains("unreadable (binary)"))); } + + #[test] + fn redact_secrets_replaces_values_and_keeps_the_rest() { + assert_eq!(redact_secrets("key is sk-proj-abcdef1234567890ABCDEF ok"), "key is [redacted] ok"); + assert_eq!(redact_secrets("a AKIAIOSFODNN7EXAMPLE\nb xoxb-1234567890-abcdefghij"), "a [redacted]\nb [redacted]"); + assert_eq!(redact_secrets("https://alice:hunter2pass@example.com/x"), "https://alice:[redacted]@example.com/x"); + assert_eq!(redact_secrets("run `atem vault get dialf/openai`"), "run `atem vault get dialf/openai`"); + let url = format!("https://hooks.slack.com/services/{}/{}/{}", "T00000000", "B00000000", "X".repeat(24)); + assert!(!redact_secrets(&url).contains("XXXX")); + let seg = redact_secrets("https://example.com/cb/Q4mTLy5h9qtD46vrdMgotPH9WrZxDsLxThPD9vtlf+o"); + assert!(!seg.contains("Q4mTLy5h9"), "{seg}"); + } + + #[test] + fn redact_secrets_replaces_only_the_detected_position() { + assert_eq!(redact_secrets("https://u:p@h.example/p"), "https://u:[redacted]@h.example/p"); + assert_eq!( + redact_secrets("pw hunter2pass at https://alice:hunter2pass@example.com/x"), + "pw hunter2pass at https://alice:[redacted]@example.com/x" + ); + } + + #[test] + fn redact_secrets_blanks_a_private_key_header_line() { + assert_eq!(redact_secrets("x\n-----BEGIN RSA PRIVATE KEY-----\nMIIE"), "x\n[redacted]\nMIIE"); + } } diff --git a/src/pty_session.rs b/src/pty_session.rs index 3c3635f..4ee90b6 100644 --- a/src/pty_session.rs +++ b/src/pty_session.rs @@ -2,6 +2,7 @@ use anyhow::{Result, anyhow, bail}; use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; use std::io::{Read, Write}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; @@ -55,6 +56,8 @@ fn write_request( pub struct PtyInput { tx: mpsc::Sender, authority: Arc>, + /// Successful send()/send_remote() calls, shared by clones. + inputs: Arc, } impl PtyInput { @@ -75,7 +78,43 @@ impl PtyInput { if authority.remote { bail!("Remote control is active; use Atem's Take Control command"); } - self.enqueue(data.into_bytes(), Some(authority.epoch), None) + self.enqueue(data.into_bytes(), Some(authority.epoch), None)?; + self.inputs.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + /// Count of send()/send_remote() calls (terminal input), not respond() or send_paste(). + pub fn input_count(&self) -> u64 { + self.inputs.load(Ordering::SeqCst) + } + + /// Bracketed paste plus Enter, queued as consecutive chunks of at most MAX_INPUT bytes + /// while holding the authority lock, so send()/send_remote() cannot interleave. + /// Refused while remote-controlled. Does not count as terminal input. On a full + /// queue the error is returned and nothing is resent. + pub fn send_paste(&self, text: &str) -> Result<()> { + let authority = self.authority.lock().unwrap(); + if authority.remote { + bail!("Remote control is active; use Atem's Take Control command"); + } + let mut bytes = Vec::with_capacity(text.len() + 13); + bytes.extend_from_slice(b"\x1b[200~"); + bytes.extend_from_slice(text.as_bytes()); + bytes.extend_from_slice(b"\x1b[201~\r"); + let chunks: Vec<&[u8]> = bytes.chunks(MAX_INPUT).collect(); + // Reserve every slot first: all chunks are queued or none is. + let permits = self + .tx + .try_reserve_many(chunks.len()) + .map_err(|_| anyhow!("Terminal input queue full or closed; input was not queued"))?; + for (permit, chunk) in permits.zip(chunks) { + permit.send(WriteRequest { + data: chunk.to_vec(), + epoch: Some(authority.epoch), + sequence: None, + }); + } + Ok(()) } pub fn send_remote(&self, data: Vec, epoch: u64, sequence: u64) -> Result<()> { @@ -83,7 +122,9 @@ impl PtyInput { if !authority.remote || authority.epoch != epoch { bail!("Control lease expired"); } - self.enqueue(data, Some(epoch), Some(sequence)) + self.enqueue(data, Some(epoch), Some(sequence))?; + self.inputs.fetch_add(1, Ordering::SeqCst); + Ok(()) } pub fn respond(&self, data: Vec) -> Result<()> { @@ -110,6 +151,7 @@ impl PtyInput { Self { tx, authority: Arc::default(), + inputs: Arc::default(), }, TestInputReader(requests), ) @@ -273,7 +315,11 @@ pub fn spawn_sized(binary: &str, args: &[String], rows: u16, cols: u16) -> Resul let _ = output.blocking_send(PtyEvent::Exit(status)); }); Ok(PtySession { - sender: PtyInput { tx, authority }, + sender: PtyInput { + tx, + authority, + inputs: Arc::default(), + }, receiver, resize_handle: handle, }) @@ -283,6 +329,74 @@ pub fn spawn_sized(binary: &str, args: &[String], rows: u16, cols: u16) -> Resul mod tests { use super::*; + #[test] + fn send_paste_chunk_boundaries_at_max_input() { + let (input, mut q) = PtyInput::test_channel(); + input.send_paste(&"a".repeat(MAX_INPUT - 13)).unwrap(); + assert!(q.try_recv().is_ok()); + assert!(q.try_recv().is_err()); + input.send_paste(&"a".repeat(MAX_INPUT - 12)).unwrap(); + assert!(q.try_recv().is_ok()); + assert!(q.try_recv().is_ok()); + assert!(q.try_recv().is_err()); + } + + #[test] + fn send_paste_is_all_or_nothing() { + let (input, mut q) = PtyInput::test_channel(); + for _ in 0..63 { + input.send("x".into()).unwrap(); + } + // one free slot, three chunks needed + assert!(input.send_paste(&"a".repeat(32 * 1024)).is_err()); + let mut n = 0; + while q.try_recv().is_ok() { + n += 1; + } + assert_eq!(n, 63, "no paste chunk may be queued"); + } + + #[test] + fn input_count_counts_send_and_send_remote_not_respond_or_paste() { + let (input, _q) = PtyInput::test_channel(); + assert_eq!(input.input_count(), 0); + input.send("a".into()).unwrap(); + assert_eq!(input.input_count(), 1); + input.respond(b"y".to_vec()).unwrap(); + input.send_paste("hello").unwrap(); + assert_eq!(input.input_count(), 1); + let epoch = input.set_remote(true); + assert!(input.send("b".into()).is_err()); + input.send_remote(b"c".to_vec(), epoch, 1).unwrap(); + assert_eq!(input.clone().input_count(), 2); + } + + #[test] + fn send_paste_wraps_in_bracketed_paste() { + let (input, mut q) = PtyInput::test_channel(); + input.send_paste("hi there").unwrap(); + assert_eq!(q.try_recv().unwrap(), "\x1b[200~hi there\x1b[201~\r"); + assert!(q.try_recv().is_err()); + input.set_remote(true); + assert!(input.send_paste("x").is_err()); + } + + #[test] + fn send_paste_chunks_large_text_in_order() { + let (input, mut q) = PtyInput::test_channel(); + let text = "a".repeat(32 * 1024); + input.send_paste(&text).unwrap(); + let mut all = String::new(); + let mut n = 0; + while let Ok(c) = q.try_recv() { + assert!(c.len() <= MAX_INPUT); + all.push_str(&c); + n += 1; + } + assert_eq!(n, 3); + assert_eq!(all, format!("\x1b[200~{text}\x1b[201~\r")); + } + #[test] fn queued_input_is_checked_again_at_writer_after_handoff() { let (input, mut queued) = PtyInput::test_channel(); diff --git a/src/tui/draw.rs b/src/tui/draw.rs index 696f450..190f3f7 100644 --- a/src/tui/draw.rs +++ b/src/tui/draw.rs @@ -508,6 +508,7 @@ pub(crate) fn draw_agent_panel(frame: &mut Frame, area: ratatui::layout::Rect, a AgentStatus::Thinking => "\u{1f7e1}", // yellow circle AgentStatus::WaitingForInput => "\u{1f7e2}", // green circle AgentStatus::Disconnected => "\u{1f534}", // red circle + AgentStatus::Unknown => "\u{2754}", // question mark: activity unknown }; let active_marker = if is_active { " \u{2605}" } else { "" }; // ★ diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d6f8dc7..b473f99 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -222,6 +222,7 @@ async fn run_tui_loop( app.process_astation_messages().await; app.process_codex_output(); app.process_claude_output(); + app.poll_chat().await; // Drain bytes from the old geometry before notifying the CLI of a resize. sync_terminal_sizes(app, terminal.size()?); app.pump_terminals(); diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 9f8e88e..d68160e 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -73,6 +73,9 @@ pub enum AstationMessage { #[serde(rename = "terminal")] Terminal(crate::terminal_share::Envelope), + #[serde(rename = "agentChat")] + AgentChat(crate::chat::wire::ChatEnvelope), + #[serde(rename = "projectListRequest")] ProjectListRequest, @@ -2894,6 +2897,7 @@ mod tests { session_ids: vec![], acp_url: None, pty_pid: Some(4242), + capabilities: vec![], } } @@ -3061,6 +3065,22 @@ mod tests { assert!(json.contains(r#""save_credentials":true"#)); } + #[test] + fn agent_chat_round_trips_through_astation_message() { + let json = r#"{"type":"agentChat","data":{"v":1,"op":"open","conversationId":"c1","agentId":"claude","before":"m9"}}"#; + let msg: AstationMessage = serde_json::from_str(json).unwrap(); + match &msg { + AstationMessage::AgentChat(env) => { + assert_eq!(env.op, "open"); + assert_eq!(env.conversation_id, "c1"); + assert_eq!(env.body["before"], "m9"); + } + other => panic!("unexpected {other:?}"), + } + let back: serde_json::Value = serde_json::to_value(&msg).unwrap(); + assert_eq!(back, serde_json::from_str::(json).unwrap()); + } + #[test] fn credential_sync_deserialize_from_json() { let json = r#"{"type":"credentialSync","data":{ diff --git a/tests/agent_launch_binary.rs b/tests/agent_launch_binary.rs index 94a31d5..df943ee 100644 --- a/tests/agent_launch_binary.rs +++ b/tests/agent_launch_binary.rs @@ -20,6 +20,19 @@ impl Drop for Atem { fn launch_and_capture(agent: &str, defaults: &str, arguments: &[&str]) -> Vec { let dir = tempfile::tempdir().unwrap(); + launch_in(&dir, agent, defaults, arguments, &[], |_| {}) +} + +/// Launch Atem with the fixture agent, call `running` with the agent's +/// arguments while both still run, then quit Atem and return them. +fn launch_in( + dir: &tempfile::TempDir, + agent: &str, + defaults: &str, + arguments: &[&str], + env: &[(&str, &std::path::Path)], + running: impl FnOnce(&[String]), +) -> Vec { let fixture = dir.path().join("agent fixture"); let argv = dir.path().join("argv"); std::fs::write( @@ -48,6 +61,9 @@ fn launch_and_capture(agent: &str, defaults: &str, arguments: &[&str]) -> Vec Vec = bytes .strip_suffix(&[0]) .expect("Missing final argument delimiter") .split(|byte| *byte == 0) .map(|value| String::from_utf8(value.to_vec()).unwrap()) .collect(); + running(&values); writer.write_all(b"\x1dhq").unwrap(); // Atem prefix, hide, then quit. writer.flush().unwrap(); @@ -145,7 +162,58 @@ fn claude_launch_forwards_options_preserving_values_and_env_defaults() { arguments.insert(0, "--"); } let actual = launch_and_capture(agent, "--verbose", &arguments); - assert_eq!(actual, [vec!["--verbose".to_string()], expected].concat()); + // Chat hooks come first, ahead of the user's `--`. + assert_eq!(actual[0], "--settings"); + assert_eq!(actual[2], "--session-id"); + assert_eq!( + actual[4..], + [vec!["--verbose".to_string()], expected].concat() + ); } } } + +#[test] +fn claude_launch_adds_chat_hooks_and_removes_their_directory_on_quit() { + let dir = tempfile::tempdir().unwrap(); + let runtime = dir.path().join("runtime"); + std::fs::create_dir(&runtime).unwrap(); + let mut chat_dir = None; + let args = launch_in( + &dir, + "claude", + "", + &["--model", "haiku"], + &[("XDG_RUNTIME_DIR", runtime.as_path())], + |args| { + assert_eq!(args[0], "--settings"); + let settings = std::path::PathBuf::from(&args[1]); + assert!(settings.is_file(), "settings.json exists while Claude runs"); + let parent = settings.parent().unwrap(); + assert_eq!(parent.parent().unwrap(), runtime.join("atem")); + assert!( + parent + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("chat-") + ); + assert_eq!(settings.file_name().unwrap(), "settings.json"); + chat_dir = Some(parent.to_path_buf()); + }, + ); + assert_eq!(args[2], "--session-id"); + assert!(uuid_like(&args[3]), "{}", args[3]); + assert_eq!(args[4..], ["--model", "haiku"]); + let chat_dir = chat_dir.unwrap(); + assert!( + !chat_dir.exists(), + "the chat directory is removed when Atem quits" + ); +} + +fn uuid_like(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + parts.iter().map(|p| p.len()).collect::>() == [8, 4, 4, 4, 12] + && s.chars().all(|c| c == '-' || c.is_ascii_hexdigit()) +} diff --git a/tests/fixtures/chat/claude/README.md b/tests/fixtures/chat/claude/README.md new file mode 100644 index 0000000..7370dfc --- /dev/null +++ b/tests/fixtures/chat/claude/README.md @@ -0,0 +1,126 @@ +# Claude Code hook and transcript fixtures + +Recorded by driving an interactive `claude --model haiku --settings +--session-id ` in tmux, with the six hooks from `designs/agent-chat-host.md` +each writing its stdin to `events/ev.XXXXXXXX.json` (mktemp then rename). +Installed version was 2.1.299 at launch; Claude auto-updated and the transcript +entries say `"version":"2.1.300"` (hook behaviour identical). + +Anonymised: home -> `/home/user`, cwd -> `/home/user/proj`, chat dir -> +`/run/user/1000/atem/chat-xxxx`, thinking `signature` -> `"sig"`. All ids and +timestamps are real. Large bookkeeping bodies are elided: `api-request-*` lines +keep only their scalar fields plus `"elided":true`; `attachment` lines keep +`attachment.type` plus `"elided":true` (except `plan_mode_exit`); +`serverClassifierContext` is removed. Real files hold much larger versions of +these (up to 100 KB per line), so the reader must not assume small lines. + +## Files + +- `events/NN-*.json`: all 14 event files in arrival order (mtime). Run: prompt 1 + (auto permission mode, Bash `echo hi`, no approval needed) -> prompt 2 (default + mode, Bash `touch probe.txt`, approved in the TUI) -> prompt 3 (essay, Esc + after ~3 s) -> `/clear` -> prompt 4 (`say hi`) -> `/exit`. +- `session-basic.jsonl`: transcript `c142b440-2f9a-4f2e-bf30-d99335ab2e7e` (= the + `--session-id`), prompts 1-3 including the interrupt. +- `session-after-clear.jsonl`: transcript `0e5a8679-f18d-4d65-ba20-58c30c004ad4` + created by `/clear` (new session id; `SessionStart` `source:"clear"`). + +## Hook events (stdin JSON; all arrive as `.json` files) + +Common to all: `session_id`, `transcript_path` (absolute, +`~/.claude/projects//.jsonl`), `cwd`, +`hook_event_name`. + +| Event | Extra fields observed | +|---|---| +| `SessionStart` | `source` (`startup`, `clear`; resume/compact not exercised), `model` (present on startup, ABSENT on clear) | +| `UserPromptSubmit` | `prompt` (the typed text), `prompt_id` (equals `promptId` of the transcript user entry), `permission_mode` (`auto`, `default`) | +| `PreToolUse` | `prompt_id`, `permission_mode`, `effort:{level}`, `tool_name`, `tool_input` (Bash: `command`, `description`), `tool_use_id` (equals the transcript `tool_use.id`) | +| `PostToolUse` | as PreToolUse plus `tool_response` (Bash: `stdout`, `stderr`, `interrupted`, `isImage`, `noOutputExpected`) and `duration_ms` | +| `Notification` | `prompt_id`, `message` ("Claude needs your permission"), `notification_type` ("permission_prompt"). No `permission_mode`. No idle notification appeared in this run (short run, no 60 s idle). | +| `Stop` | `prompt_id`, `permission_mode`, `effort`, `stop_hook_active`, `last_assistant_message`, `background_tasks`, `session_crons` | + +Findings relevant to the design: + +- `SessionStart` is written at startup with no prompt typed (first run it + appeared only once the workspace-trust dialog was answered; Atem's 15 s wait + must allow for a first-time trust prompt in a new directory). It carries + `session_id` equal to `--session-id` and a `transcript_path` ending in + `.jsonl`. The transcript file does not exist until the first + entries are written (first lines are bookkeeping). +- `--settings` hooks fire interactively, receive the JSON on stdin and merge + with the user's own hooks (the `stop_hook_summary` entry shows `hookCount: 2`: + a user hook plus ours). +- `Notification` with `notification_type:"permission_prompt"` fired about 6 s + after the `PreToolUse` of the same tool (permission asked), before + `PostToolUse`. In `auto` permission mode no `Notification` fired. +- **No `Stop` event after Esc interrupt** (event 11, the essay prompt, is + followed directly by the `/clear` `SessionStart`). The interrupt is only + visible in the transcript. The design's table already covers this. +- `/clear` fires `SessionStart source:"clear"` with a new `session_id` and + `transcript_path`; no `UserPromptSubmit` for `/clear`. Nothing fires on `/exit`. +- `Stop.last_assistant_message` carries the final text (could be used as a + cross-check, not required). + +## Transcript entry shapes (one JSON object per line) + +Common on conversation entries: `parentUuid`, `isSidechain`, `uuid`, `timestamp`, +`sessionId`, `cwd`, `version`, `gitBranch`, `userType`, `entrypoint`, optional `slug`. + +- User prompt (`session-basic.jsonl` line 8): `type:"user"`, `promptId`, + `message:{role:"user",content:""}` (content is a STRING for typed + prompts), `origin:{kind:"human"}`, `promptSource:"typed"`, `turnOrigin:"human"`, + `turnPosition:{promptIndex,turnIndex}`, `permissionMode`. +- Meta entry (`session-after-clear.jsonl` line 5): `type:"user"`, `isMeta:true`, + content string `...`; skip. +- Slash command (`session-after-clear.jsonl` lines 5-7): user entry with content + string `/clear\n clear\n ` + (no `promptId`, not meta), followed by `type:"system"`, `subtype:"local_command"`, + `commandRun:{command,args}`. The command user entry must not become a chat message. +- Assistant blocks: `type:"assistant"`, `message:{id:"msg_...",role,model,content:[ONE block],stop_reason,usage...}`, + `apiBlockIndex`, `requestRef`. One content block per line; lines of one reply + share `message.id`, each with its own `uuid`. Block kinds: + - `text`: `{type:"text",text}` + - `tool_use`: `{type:"tool_use",id,name,input:{...},caller:{type:"direct"}}` + - `thinking`: `{type:"thinking",thinking:"" ,signature}` (thinking text was + empty in this haiku run; `thinkingDurationMs`, `thinkingDisplay` on the entry) +- `stop_reason`: `end_turn` on the final text line, `tool_use` on a tool_use + line (observed on the line itself), and **`null` on blocks written while + streaming** (the aborted partial and its thinking block). Observed replies had + a single content block, so whether earlier lines of a multi-block reply + carry `null` vs the final reason was not observed beyond the aborted case. +- Tool result: `type:"user"`, `message.content:[{tool_use_id,type:"tool_result",content:"",is_error}]`, + `toolUseResult:{stdout,stderr,interrupted,isImage,noOutputExpected}`, + `sourceToolAssistantUUID`, `promptId`, `permissionDecision` (auto mode). +- Interrupt (`session-basic.jsonl`, last conversation lines): the partial + assistant text line has `stop_reason:null` and `isAbortedMidStream:true` + (thinking line before it, same `message.id`); then `type:"user"`, + `message.content:[{type:"text",text:"[Request interrupted by user]"}]` (an + ARRAY here), `interruptedMessageId` = that `message.id`, `promptId` = the + interrupted prompt, no `isMeta`. No `Stop` hook. +- `system` / `stop_hook_summary` (after each turn end): `hookCount`, + `hookInfos:[{command,durationMs}]`, `hookErrors`, `preventedContinuation`, + `stopReason`, `hasOutput`, `level`, `toolUseID`; then `system`/`turn_duration` + (`durationMs`, `messageCount`). `system` entries have no `message`. +- Bookkeeping types to skip: `last-prompt`, `mode`, `permission-mode`, + `atis-latch`, `ai-title`, `file-history-snapshot`, `cost-state`, + `attachment` (hook_success, hook_additional_context, environment, model, + agent_listing_delta, skill_listing, total_tokens_reminder, instructions, + session_context, date, remote_session_change, prompt_snapshot, + plan_mode_exit), `api-request-shape`, `api-request-blob`, `api-request`. + Many have no `uuid`. + +## Not recorded + +A permission prompt answered with deny/Esc (`[Request interrupted by user for +tool use]`, a user entry without `interruptedMessageId`) could not be recorded +on 2026-10-15: the Claude CLI on the recording machine was not logged in +("Not logged in · Please run /login"). The reader's tests use hand-written +lines for it (`transcript.rs`, `interrupt_marker_without_message_id_ends_the_turn`). + +## Deviations from the spec + +None that break it. Notes: first-run trust dialog can delay `SessionStart`; +interrupt marker content is an array of one text block; `isAbortedMidStream` +exists as an extra interrupt signal; `Stop` is not sent on interrupt; the +auto-updater can change the Claude version while Atem is running. diff --git a/tests/fixtures/chat/claude/events/01-session-start.json b/tests/fixtures/chat/claude/events/01-session-start.json new file mode 100644 index 0000000..c856aab --- /dev/null +++ b/tests/fixtures/chat/claude/events/01-session-start.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","hook_event_name":"SessionStart","source":"startup","model":"claude-haiku-5-5"} diff --git a/tests/fixtures/chat/claude/events/02-user-prompt-submit.json b/tests/fixtures/chat/claude/events/02-user-prompt-submit.json new file mode 100644 index 0000000..1af57db --- /dev/null +++ b/tests/fixtures/chat/claude/events/02-user-prompt-submit.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"fa1cc4a0-50ce-4785-aab5-0355d2a806db","permission_mode":"auto","hook_event_name":"UserPromptSubmit","prompt":"run `echo hi` with Bash then say done"} diff --git a/tests/fixtures/chat/claude/events/03-pre-tool-use.json b/tests/fixtures/chat/claude/events/03-pre-tool-use.json new file mode 100644 index 0000000..a8dae3a --- /dev/null +++ b/tests/fixtures/chat/claude/events/03-pre-tool-use.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"fa1cc4a0-50ce-4785-aab5-0355d2a806db","permission_mode":"auto","effort":{"level":"medium"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi","description":"Print hi via echo"},"tool_use_id":"toolu_0182RfBiPJ6oPYsaMQ1YETQt"} diff --git a/tests/fixtures/chat/claude/events/04-post-tool-use.json b/tests/fixtures/chat/claude/events/04-post-tool-use.json new file mode 100644 index 0000000..f8528d8 --- /dev/null +++ b/tests/fixtures/chat/claude/events/04-post-tool-use.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"fa1cc4a0-50ce-4785-aab5-0355d2a806db","permission_mode":"auto","effort":{"level":"medium"},"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"echo hi","description":"Print hi via echo"},"tool_response":{"stdout":"hi","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"tool_use_id":"toolu_0182RfBiPJ6oPYsaMQ1YETQt","duration_ms":328} diff --git a/tests/fixtures/chat/claude/events/05-stop.json b/tests/fixtures/chat/claude/events/05-stop.json new file mode 100644 index 0000000..3b3ddd7 --- /dev/null +++ b/tests/fixtures/chat/claude/events/05-stop.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"fa1cc4a0-50ce-4785-aab5-0355d2a806db","permission_mode":"auto","effort":{"level":"medium"},"hook_event_name":"Stop","stop_hook_active":false,"last_assistant_message":"Done.","background_tasks":[],"session_crons":[]} diff --git a/tests/fixtures/chat/claude/events/06-user-prompt-submit.json b/tests/fixtures/chat/claude/events/06-user-prompt-submit.json new file mode 100644 index 0000000..94e975f --- /dev/null +++ b/tests/fixtures/chat/claude/events/06-user-prompt-submit.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"7484dc79-63b0-44e0-9b03-04aebb3f806f","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"use Bash to run `touch probe.txt` then say ok"} diff --git a/tests/fixtures/chat/claude/events/07-pre-tool-use.json b/tests/fixtures/chat/claude/events/07-pre-tool-use.json new file mode 100644 index 0000000..38cebc7 --- /dev/null +++ b/tests/fixtures/chat/claude/events/07-pre-tool-use.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"7484dc79-63b0-44e0-9b03-04aebb3f806f","permission_mode":"default","effort":{"level":"medium"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"touch probe.txt","description":"Create empty probe.txt file in current directory"},"tool_use_id":"toolu_01LxRP8yPQAS8Nt1FPBf8d3C"} diff --git a/tests/fixtures/chat/claude/events/08-notification.json b/tests/fixtures/chat/claude/events/08-notification.json new file mode 100644 index 0000000..84e4bfa --- /dev/null +++ b/tests/fixtures/chat/claude/events/08-notification.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"7484dc79-63b0-44e0-9b03-04aebb3f806f","hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"} diff --git a/tests/fixtures/chat/claude/events/09-post-tool-use.json b/tests/fixtures/chat/claude/events/09-post-tool-use.json new file mode 100644 index 0000000..8ae9e0c --- /dev/null +++ b/tests/fixtures/chat/claude/events/09-post-tool-use.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"7484dc79-63b0-44e0-9b03-04aebb3f806f","permission_mode":"default","effort":{"level":"medium"},"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"touch probe.txt","description":"Create empty probe.txt file in current directory"},"tool_response":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":true},"tool_use_id":"toolu_01LxRP8yPQAS8Nt1FPBf8d3C","duration_ms":41} diff --git a/tests/fixtures/chat/claude/events/10-stop.json b/tests/fixtures/chat/claude/events/10-stop.json new file mode 100644 index 0000000..02acccf --- /dev/null +++ b/tests/fixtures/chat/claude/events/10-stop.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"7484dc79-63b0-44e0-9b03-04aebb3f806f","permission_mode":"default","effort":{"level":"medium"},"hook_event_name":"Stop","stop_hook_active":false,"last_assistant_message":"ok","background_tasks":[],"session_crons":[]} diff --git a/tests/fixtures/chat/claude/events/11-user-prompt-submit.json b/tests/fixtures/chat/claude/events/11-user-prompt-submit.json new file mode 100644 index 0000000..9b50a5c --- /dev/null +++ b/tests/fixtures/chat/claude/events/11-user-prompt-submit.json @@ -0,0 +1 @@ +{"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","transcript_path":"/home/user/.claude/projects/-home-user-proj/c142b440-2f9a-4f2e-bf30-d99335ab2e7e.jsonl","cwd":"/home/user/proj","prompt_id":"64707c1e-bb7f-4bb8-9065-1514059dcb0d","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"write a 600-word essay about rivers"} diff --git a/tests/fixtures/chat/claude/events/12-session-start.json b/tests/fixtures/chat/claude/events/12-session-start.json new file mode 100644 index 0000000..50b4803 --- /dev/null +++ b/tests/fixtures/chat/claude/events/12-session-start.json @@ -0,0 +1 @@ +{"session_id":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","transcript_path":"/home/user/.claude/projects/-home-user-proj/0e5a8679-f18d-4d65-ba20-58c30c004ad4.jsonl","cwd":"/home/user/proj","hook_event_name":"SessionStart","source":"clear"} diff --git a/tests/fixtures/chat/claude/events/13-user-prompt-submit.json b/tests/fixtures/chat/claude/events/13-user-prompt-submit.json new file mode 100644 index 0000000..bc639ff --- /dev/null +++ b/tests/fixtures/chat/claude/events/13-user-prompt-submit.json @@ -0,0 +1 @@ +{"session_id":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","transcript_path":"/home/user/.claude/projects/-home-user-proj/0e5a8679-f18d-4d65-ba20-58c30c004ad4.jsonl","cwd":"/home/user/proj","prompt_id":"2697f85f-4099-4b72-bffc-bca834e5ca97","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"say hi"} diff --git a/tests/fixtures/chat/claude/events/14-stop.json b/tests/fixtures/chat/claude/events/14-stop.json new file mode 100644 index 0000000..3d51b27 --- /dev/null +++ b/tests/fixtures/chat/claude/events/14-stop.json @@ -0,0 +1 @@ +{"session_id":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","transcript_path":"/home/user/.claude/projects/-home-user-proj/0e5a8679-f18d-4d65-ba20-58c30c004ad4.jsonl","cwd":"/home/user/proj","prompt_id":"2697f85f-4099-4b72-bffc-bca834e5ca97","permission_mode":"default","effort":{"level":"medium"},"hook_event_name":"Stop","stop_hook_active":false,"last_assistant_message":"Hi! What can I help you with today? I can work on code in `/home/user/proj` or anything else you have in mind.","background_tasks":[],"session_crons":[]} diff --git a/tests/fixtures/chat/claude/session-after-clear.jsonl b/tests/fixtures/chat/claude/session-after-clear.jsonl new file mode 100644 index 0000000..317a500 --- /dev/null +++ b/tests/fixtures/chat/claude/session-after-clear.jsonl @@ -0,0 +1,36 @@ +{"type":"mode","mode":"normal","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4"} +{"type":"file-history-snapshot","messageId":"ccf18009-1b22-47df-a6d5-050446ebf4f7","snapshot":{"messageId":"ccf18009-1b22-47df-a6d5-050446ebf4f7","trackedFileBackups":{},"timestamp":"2026-10-15T03:28:05.584Z"},"isSnapshotUpdate":false} +{"parentUuid":null,"isSidechain":false,"attachment":{"type":"hook_success","elided":true},"type":"attachment","uuid":"20161d5e-8ebe-4966-be4d-0a0ca58fc68b","timestamp":"2026-10-15T03:28:05.620Z","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"20161d5e-8ebe-4966-be4d-0a0ca58fc68b","isSidechain":false,"attachment":{"type":"hook_additional_context","elided":true},"type":"attachment","uuid":"5fc164d6-503b-4f2c-afd0-68d928cd65be","timestamp":"2026-10-15T03:28:05.620Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"5fc164d6-503b-4f2c-afd0-68d928cd65be","isSidechain":false,"type":"user","message":{"role":"user","content":"The command below was run directly in Claude Code, not sent to you as a request, and its output goes straight to the user. It's recorded here as context for later messages."},"isMeta":true,"uuid":"a3482c8d-71c4-4df8-9a4c-7ec05ceff7fc","timestamp":"2026-10-15T03:28:05.584Z","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"a3482c8d-71c4-4df8-9a4c-7ec05ceff7fc","isSidechain":false,"type":"user","message":{"role":"user","content":"/clear\n clear\n "},"uuid":"ccf18009-1b22-47df-a6d5-050446ebf4f7","timestamp":"2026-10-15T03:28:05.576Z","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"ccf18009-1b22-47df-a6d5-050446ebf4f7","isSidechain":false,"type":"system","subtype":"local_command","content":"","level":"info","timestamp":"2026-10-15T03:28:05.581Z","uuid":"e5329629-a5d8-42ea-b41c-19bc63066253","isMeta":false,"commandRun":{"command":"clear","args":""},"userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"type":"file-history-snapshot","messageId":"a1439293-076c-47f4-964e-805e571f6140","snapshot":{"messageId":"a1439293-076c-47f4-964e-805e571f6140","trackedFileBackups":{},"timestamp":"2026-10-15T03:28:09.591Z"},"isSnapshotUpdate":false} +{"parentUuid":"e5329629-a5d8-42ea-b41c-19bc63066253","isSidechain":false,"promptId":"2697f85f-4099-4b72-bffc-bca834e5ca97","type":"user","message":{"role":"user","content":"say hi"},"uuid":"a1439293-076c-47f4-964e-805e571f6140","timestamp":"2026-10-15T03:28:09.582Z","permissionMode":"default","origin":{"kind":"human"},"promptSource":"typed","turnOrigin":"human","turnPosition":{"promptIndex":1,"turnIndex":1},"userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"a1439293-076c-47f4-964e-805e571f6140","isSidechain":false,"attachment":{"type":"environment","elided":true},"type":"attachment","uuid":"105e67aa-e181-45c4-87c3-b556fa1ecab7","timestamp":"2026-10-15T03:28:09.582Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"105e67aa-e181-45c4-87c3-b556fa1ecab7","isSidechain":false,"attachment":{"type":"model","elided":true},"type":"attachment","uuid":"d76a6ea4-2be6-4c91-a87a-cf9d7c6ea10c","timestamp":"2026-10-15T03:28:09.582Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"d76a6ea4-2be6-4c91-a87a-cf9d7c6ea10c","isSidechain":false,"attachment":{"type":"agent_listing_delta","elided":true},"type":"attachment","uuid":"d9cbc453-a46a-4abe-a6d2-80da48da4bd2","timestamp":"2026-10-15T03:28:09.582Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"d9cbc453-a46a-4abe-a6d2-80da48da4bd2","isSidechain":false,"attachment":{"type":"skill_listing","elided":true},"type":"attachment","uuid":"cfd47034-a34c-4021-a2da-885ec417911f","timestamp":"2026-10-15T03:28:09.582Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"cfd47034-a34c-4021-a2da-885ec417911f","isSidechain":false,"attachment":{"type":"total_tokens_reminder","elided":true},"type":"attachment","uuid":"e1d46112-da68-401e-b7c6-9a5c1ee330b1","timestamp":"2026-10-15T03:28:09.582Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"type":"atis-latch","atis":"","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4"} +{"parentUuid":"e1d46112-da68-401e-b7c6-9a5c1ee330b1","isSidechain":false,"attachment":{"type":"instructions","elided":true},"type":"attachment","uuid":"afe2c396-5a1b-4b4d-b3c9-b3a85dd3711d","timestamp":"2026-10-15T03:28:09.613Z","renderedRole":"user","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"afe2c396-5a1b-4b4d-b3c9-b3a85dd3711d","isSidechain":false,"attachment":{"type":"session_context","elided":true},"type":"attachment","uuid":"449dfa83-250a-4168-ba3d-80116f307395","timestamp":"2026-10-15T03:28:09.613Z","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"449dfa83-250a-4168-ba3d-80116f307395","isSidechain":false,"attachment":{"type":"date","elided":true},"type":"attachment","uuid":"21a8db76-6b6b-47e9-add1-afb9ed02d668","timestamp":"2026-10-15T03:28:09.613Z","renderedRole":"system","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"21a8db76-6b6b-47e9-add1-afb9ed02d668","isSidechain":false,"attachment":{"type":"remote_session_change","elided":true},"type":"attachment","uuid":"5bd0e88c-8a72-499a-b74e-e000656d704e","timestamp":"2026-10-15T03:28:09.615Z","renderedRole":"user","renderedBesideToolResult":true,"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"5bd0e88c-8a72-499a-b74e-e000656d704e","isSidechain":false,"attachment":{"type":"prompt_snapshot","elided":true},"type":"attachment","uuid":"a43d09f7-b138-4407-bcdb-e2af4a7a1681","timestamp":"2026-10-15T03:28:09.616Z","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"type":"api-request-shape","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","shapeHash":"161bc4e9c93610cddfb69aa1f76999cf686778e963062d1bc857f703f6fd0fd7","timestamp":"2026-10-15T03:28:09.628Z","version":"2.1.300","elided":true} +{"type":"api-request-blob","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","hash":"0b55f8c3e88b377a6613782144687ced34c4d03e5c360fb2cbb4b473cc6fc8be","elided":true} +{"type":"api-request-blob","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","hash":"4fedc99b133e0c8ce9d776a25ef2656b0652e60b423a05c1ca48ca2b2a794918","elided":true} +{"type":"api-request","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","id":"eb886d42-9972-4b3d-9618-98aa2e89db1d","timestamp":"2026-10-15T03:28:09.628Z","version":"2.1.300","querySource":"repl_main_thread","shapeHash":"161bc4e9c93610cddfb69aa1f76999cf686778e963062d1bc857f703f6fd0fd7","keep":0,"requestDigest":"7dbe4cb96116d70a219c6c602eee0ff3e28583651b3d7fb18e9043c5038181d2","elided":true} +{"type":"last-prompt","lastPrompt":"say hi","leafUuid":"a43d09f7-b138-4407-bcdb-e2af4a7a1681","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4"} +{"type":"mode","mode":"normal","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4"} +{"type":"atis-latch","atis":"","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4"} +{"parentUuid":"a43d09f7-b138-4407-bcdb-e2af4a7a1681","isSidechain":false,"message":{"model":"claude-haiku-5-5","id":"msg_011Cg3K5dZFRgVQvy6iJr8Sx","type":"message","role":"assistant","content":[{"type":"text","text":"Hi! What can I help you with today? I can work on code in `/home/user/proj` or anything else you have in mind."}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":8409,"cache_read_input_tokens":25174,"output_tokens":53,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":8409},"inference_geo":"global","iterations":[],"speed":"standard","fallback_credit":null},"diagnostics":null,"context_management":null},"apiBlockIndex":0,"requestRef":"eb886d42-9972-4b3d-9618-98aa2e89db1d","requestedModel":"claude-haiku-5-5","type":"assistant","uuid":"05a08ebc-c96a-4934-88e4-a7a420bf77d9","timestamp":"2026-10-15T03:28:13.093Z","effort":"medium","perTurnEffort":"medium","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"05a08ebc-c96a-4934-88e4-a7a420bf77d9","isSidechain":false,"attachment":{"type":"prompt_snapshot","elided":true},"type":"attachment","uuid":"306ca4ce-a848-4db3-bf28-c9812096a41d","timestamp":"2026-10-15T03:28:13.107Z","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"306ca4ce-a848-4db3-bf28-c9812096a41d","isSidechain":false,"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"printf '\\a' 2>/dev/null >/dev/tty || true","durationMs":8},{"command":"{ f=$(mktemp \"/run/user/1000/atem/chat-xxxx/chat/events/ev.XXXXXXXX\") && cat > \"$f\" && mv \"$f\" \"$f.json\"; } 2>/dev/null; exit 0","durationMs":8}],"hookErrors":[],"hookAdditionalContext":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-10-15T03:28:13.118Z","uuid":"d57177b0-a4c8-4320-936f-730c5474e49a","toolUseID":"c97d0d0f-c71e-446b-8d26-d0efd5f9b1da","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"d57177b0-a4c8-4320-936f-730c5474e49a","isSidechain":false,"type":"system","subtype":"turn_duration","durationMs":3562,"messageCount":19,"timestamp":"2026-10-15T03:28:13.119Z","uuid":"49d1884e-9e7f-4be8-b8ff-1621f13a7a3a","isMeta":false,"userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","version":"2.1.300","gitBranch":"HEAD"} +{"type":"last-prompt","lastPrompt":"say hi","leafUuid":"49d1884e-9e7f-4be8-b8ff-1621f13a7a3a","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4"} +{"type":"mode","mode":"normal","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4"} +{"type":"atis-latch","atis":"","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4"} +{"type":"file-history-snapshot","messageId":"4e76045d-1341-4b76-b122-2bc3cdc20216","snapshot":{"messageId":"4e76045d-1341-4b76-b122-2bc3cdc20216","trackedFileBackups":{},"timestamp":"2026-10-15T03:28:21.375Z"},"isSnapshotUpdate":false} +{"type":"cost-state","sessionId":"0e5a8679-f18d-4d65-ba20-58c30c004ad4","totalCostUSD":0.001329565,"totalAPIDuration":3484,"totalAPIDurationWithoutRetries":3480,"totalToolDuration":0,"totalLinesAdded":0,"totalLinesRemoved":0,"totalDuration":15797,"startTime":1792034885580,"modelUsage":{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":53,"thinkingTokens":0,"cacheReadInputTokens":25174,"cacheCreationInputTokens":8409,"webSearchRequests":0,"costUSD":0.001329565}},"hasUnknownModelCost":false} diff --git a/tests/fixtures/chat/claude/session-basic.jsonl b/tests/fixtures/chat/claude/session-basic.jsonl new file mode 100644 index 0000000..cb7bb8a --- /dev/null +++ b/tests/fixtures/chat/claude/session-basic.jsonl @@ -0,0 +1,79 @@ +{"type":"last-prompt","leafUuid":"927cf6d1-b441-47fe-afaf-9ba8870e6689","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"mode","mode":"normal","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"permission-mode","permissionMode":"auto","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"atis-latch","atis":"","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"parentUuid":null,"isSidechain":false,"attachment":{"type":"hook_success","elided":true},"type":"attachment","uuid":"34e95862-0f67-40ec-8d9b-acc14131f90c","timestamp":"2026-10-15T03:26:45.992Z","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"34e95862-0f67-40ec-8d9b-acc14131f90c","isSidechain":false,"attachment":{"type":"hook_additional_context","elided":true},"type":"attachment","uuid":"927cf6d1-b441-47fe-afaf-9ba8870e6689","timestamp":"2026-10-15T03:26:45.993Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"type":"file-history-snapshot","messageId":"cadfe8a7-c84d-4ada-8197-af26681b24ee","snapshot":{"messageId":"cadfe8a7-c84d-4ada-8197-af26681b24ee","trackedFileBackups":{},"timestamp":"2026-10-15T03:26:53.824Z"},"isSnapshotUpdate":false} +{"parentUuid":"927cf6d1-b441-47fe-afaf-9ba8870e6689","isSidechain":false,"promptId":"fa1cc4a0-50ce-4785-aab5-0355d2a806db","type":"user","message":{"role":"user","content":"run `echo hi` with Bash then say done"},"uuid":"cadfe8a7-c84d-4ada-8197-af26681b24ee","timestamp":"2026-10-15T03:26:53.813Z","permissionMode":"auto","origin":{"kind":"human"},"promptSource":"typed","turnOrigin":"human","turnPosition":{"promptIndex":1,"turnIndex":1},"userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"cadfe8a7-c84d-4ada-8197-af26681b24ee","isSidechain":false,"attachment":{"type":"environment","elided":true},"type":"attachment","uuid":"ff000f89-1eb6-4a4e-8b8e-b792027a9c43","timestamp":"2026-10-15T03:26:53.813Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"ff000f89-1eb6-4a4e-8b8e-b792027a9c43","isSidechain":false,"attachment":{"type":"model","elided":true},"type":"attachment","uuid":"cfe1518d-37ee-4d57-8587-59dafd1e7987","timestamp":"2026-10-15T03:26:53.813Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"cfe1518d-37ee-4d57-8587-59dafd1e7987","isSidechain":false,"attachment":{"type":"agent_listing_delta","elided":true},"type":"attachment","uuid":"9853132c-c74b-4c8d-a6e2-623369191510","timestamp":"2026-10-15T03:26:53.813Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"9853132c-c74b-4c8d-a6e2-623369191510","isSidechain":false,"attachment":{"type":"skill_listing","elided":true},"type":"attachment","uuid":"c0c345f6-6f5e-4546-8765-1a0ba4771295","timestamp":"2026-10-15T03:26:53.813Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"c0c345f6-6f5e-4546-8765-1a0ba4771295","isSidechain":false,"attachment":{"type":"total_tokens_reminder","elided":true},"type":"attachment","uuid":"db2d1788-7299-4496-b0a6-14ff0f66f4d1","timestamp":"2026-10-15T03:26:53.813Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"db2d1788-7299-4496-b0a6-14ff0f66f4d1","isSidechain":false,"attachment":{"type":"instructions","elided":true},"type":"attachment","uuid":"b4351ad6-a5b8-481a-a512-bac0be3d60fd","timestamp":"2026-10-15T03:26:53.841Z","renderedRole":"user","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"b4351ad6-a5b8-481a-a512-bac0be3d60fd","isSidechain":false,"attachment":{"type":"session_context","elided":true},"type":"attachment","uuid":"f9fdf491-270e-4255-a4a7-3d7c54c6c856","timestamp":"2026-10-15T03:26:53.841Z","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"f9fdf491-270e-4255-a4a7-3d7c54c6c856","isSidechain":false,"attachment":{"type":"date","elided":true},"type":"attachment","uuid":"4c24c164-1b59-4be5-b10a-c4db6150ec74","timestamp":"2026-10-15T03:26:53.841Z","renderedRole":"system","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"4c24c164-1b59-4be5-b10a-c4db6150ec74","isSidechain":false,"attachment":{"type":"remote_session_change","elided":true},"type":"attachment","uuid":"2f03c25b-0871-4e96-bae4-334b6c6fa046","timestamp":"2026-10-15T03:26:53.843Z","renderedRole":"user","renderedBesideToolResult":true,"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"2f03c25b-0871-4e96-bae4-334b6c6fa046","isSidechain":false,"attachment":{"type":"prompt_snapshot","elided":true},"type":"attachment","uuid":"fde9453b-dba3-450f-88b4-5cac73b13764","timestamp":"2026-10-15T03:26:53.844Z","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"type":"last-prompt","lastPrompt":"run `echo hi` with Bash then say done","leafUuid":"fde9453b-dba3-450f-88b4-5cac73b13764","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"mode","mode":"normal","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"permission-mode","permissionMode":"auto","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"atis-latch","atis":"","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"ai-title","aiTitle":"Bash echo hi test","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"parentUuid":"fde9453b-dba3-450f-88b4-5cac73b13764","isSidechain":false,"message":{"model":"claude-haiku-5-5","id":"msg_011Cg3Jz3bhGr5sGCxGvPUMN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0182RfBiPJ6oPYsaMQ1YETQt","name":"Bash","input":{"command":"echo hi","description":"Print hi via echo"},"caller":{"type":"direct"}}],"container":null,"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":32627,"cache_read_input_tokens":0,"output_tokens":77,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":32627},"inference_geo":"global","iterations":[],"speed":"standard","fallback_credit":null},"diagnostics":null,"context_management":null},"serverClassifierRequest":"e89536c8-1d06-4165-8110-5574fb79746b","apiBlockIndex":0,"requestRef":"c98a6253-9ada-4d6a-b18d-49a35085be78","requestedModel":"claude-haiku-5-5","type":"assistant","uuid":"3f3f835c-bebc-41d8-82c4-486a6f2ce036","timestamp":"2026-10-15T03:26:55.724Z","effort":"medium","perTurnEffort":"medium","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"3f3f835c-bebc-41d8-82c4-486a6f2ce036","isSidechain":false,"promptId":"fa1cc4a0-50ce-4785-aab5-0355d2a806db","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0182RfBiPJ6oPYsaMQ1YETQt","type":"tool_result","content":"hi","is_error":false}]},"uuid":"27928187-5842-4047-8cb9-9be8efbf55f5","timestamp":"2026-10-15T03:26:56.343Z","toolUseResult":{"stdout":"hi","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"sourceToolAssistantUUID":"3f3f835c-bebc-41d8-82c4-486a6f2ce036","permissionDecision":{"decision":"accept","source":"config","reasonType":"classifier"},"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"27928187-5842-4047-8cb9-9be8efbf55f5","isSidechain":false,"attachment":{"type":"prompt_snapshot","elided":true},"type":"attachment","uuid":"5a52eb73-72fb-404d-90b9-8ff2d3b4ba2d","timestamp":"2026-10-15T03:26:56.346Z","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"type":"api-request-shape","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","shapeHash":"8d04d231f7004cd6a6a964aae2883ac63f2ab32cddcf3992531db0c23eada8db","timestamp":"2026-10-15T03:26:56.368Z","version":"2.1.300","elided":true} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"3020ddec87b3d8f97dc454b258b8d6efcce739f0359723815bbce6e41105a3de","elided":true} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"4fedc99b133e0c8ce9d776a25ef2656b0652e60b423a05c1ca48ca2b2a794918","elided":true} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"677db78bedbb1cde7712918775b13e730bc11f4ca65cf8d2446c8f7169bd6759","elided":true} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"893009f66b1ede5a8bc2cb8b53ba69cb66378f1d847a091207cf483765242e5b","elided":true} +{"type":"api-request","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","id":"479f7982-8940-4195-8d63-73fa05b2ee80","timestamp":"2026-10-15T03:26:56.368Z","version":"2.1.300","querySource":"repl_main_thread","shapeHash":"8d04d231f7004cd6a6a964aae2883ac63f2ab32cddcf3992531db0c23eada8db","keep":0,"requestDigest":"dc208aca5798a613b33d1349135b5b761767d3f991fe48d4275ba87e3a1975a1","elided":true} +{"type":"last-prompt","lastPrompt":"run `echo hi` with Bash then say done","leafUuid":"5a52eb73-72fb-404d-90b9-8ff2d3b4ba2d","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"ai-title","aiTitle":"Bash echo hi test","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"mode","mode":"normal","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"permission-mode","permissionMode":"auto","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"atis-latch","atis":"","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"parentUuid":"5a52eb73-72fb-404d-90b9-8ff2d3b4ba2d","isSidechain":false,"attachment":{"type":"total_tokens_reminder","elided":true},"type":"attachment","uuid":"2aea6566-09fd-4603-8742-88f0e45f3e7f","timestamp":"2026-10-15T03:26:56.348Z","renderedRole":"system","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"2aea6566-09fd-4603-8742-88f0e45f3e7f","isSidechain":false,"message":{"model":"claude-haiku-5-5","id":"msg_011Cg3JzE5Dvd53BcRAghhBw","type":"message","role":"assistant","content":[{"type":"text","text":"Done."}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":108,"cache_read_input_tokens":32627,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":108},"inference_geo":"global","iterations":[],"speed":"standard","fallback_credit":null},"diagnostics":null,"context_management":null},"serverClassifierRequest":"184145bd-0572-4552-b669-fcfe13dab237","apiBlockIndex":0,"requestRef":"479f7982-8940-4195-8d63-73fa05b2ee80","requestedModel":"claude-haiku-5-5","type":"assistant","uuid":"163b839b-1c0c-4ce4-9cfe-22ac1dc9b848","timestamp":"2026-10-15T03:26:57.681Z","effort":"medium","perTurnEffort":"medium","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"163b839b-1c0c-4ce4-9cfe-22ac1dc9b848","isSidechain":false,"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"printf '\\a' 2>/dev/null >/dev/tty || true","durationMs":9},{"command":"{ f=$(mktemp \"/run/user/1000/atem/chat-xxxx/chat/events/ev.XXXXXXXX\") && cat > \"$f\" && mv \"$f\" \"$f.json\"; } 2>/dev/null; exit 0","durationMs":10}],"hookErrors":[],"hookAdditionalContext":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-10-15T03:26:57.704Z","uuid":"f8b1bfb6-728b-4bf1-ac14-86aac3541861","toolUseID":"152f1514-0aad-4194-8b0c-1e21bd966daa","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"parentUuid":"f8b1bfb6-728b-4bf1-ac14-86aac3541861","isSidechain":false,"type":"system","subtype":"turn_duration","durationMs":3918,"messageCount":19,"timestamp":"2026-10-15T03:26:57.706Z","uuid":"77ddf81a-85db-4dde-9bfe-a42efd2b5411","isMeta":false,"userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD"} +{"type":"file-history-snapshot","messageId":"10134330-9280-42c6-ada4-5d5e035260a0","snapshot":{"messageId":"10134330-9280-42c6-ada4-5d5e035260a0","trackedFileBackups":{},"timestamp":"2026-10-15T03:27:20.803Z"},"isSnapshotUpdate":false} +{"type":"api-request-shape","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","shapeHash":"161bc4e9c93610cddfb69aa1f76999cf686778e963062d1bc857f703f6fd0fd7","timestamp":"2026-10-15T03:27:20.815Z","version":"2.1.300","elided":true} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"b7c0b4bfbfb24516f31b773a6118d4f468a347c774b0b9c590315a43857255b2","elided":true} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"0ba7e04624ab8bc6392c397a9ef698478ce1120fd9c80c68c948f69b09fb8471","elided":true} +{"type":"api-request","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","id":"f04bb6f1-62a0-43dd-903d-08998eaaafff","timestamp":"2026-10-15T03:27:20.815Z","version":"2.1.300","querySource":"repl_main_thread","shapeHash":"161bc4e9c93610cddfb69aa1f76999cf686778e963062d1bc857f703f6fd0fd7","base":"479f7982-8940-4195-8d63-73fa05b2ee80","keep":5,"requestDigest":"9f3bf6e9903b23eed88d2b80d54d7e6059629bb4470e15889c04d3cad1db0583","elided":true} +{"parentUuid":"77ddf81a-85db-4dde-9bfe-a42efd2b5411","isSidechain":false,"promptId":"7484dc79-63b0-44e0-9b03-04aebb3f806f","type":"user","message":{"role":"user","content":"use Bash to run `touch probe.txt` then say ok"},"uuid":"10134330-9280-42c6-ada4-5d5e035260a0","timestamp":"2026-10-15T03:27:20.794Z","permissionMode":"default","origin":{"kind":"human"},"promptSource":"typed","turnOrigin":"human","turnPosition":{"promptIndex":2,"turnIndex":2},"userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"10134330-9280-42c6-ada4-5d5e035260a0","isSidechain":false,"attachment":{"type":"plan_mode_exit","planFilePath":"/home/user/.claude/plans/validated-discovering-key.md","planExists":false},"type":"attachment","uuid":"5fc16103-b503-43cf-a589-a8c57262a0a4","timestamp":"2026-10-15T03:27:20.794Z","rendered":[{"content":"\n## Exited Plan Mode\n\nYou have exited plan mode. You can now make edits, run tools, and take actions.\n"}],"renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"5fc16103-b503-43cf-a589-a8c57262a0a4","isSidechain":false,"attachment":{"type":"total_tokens_reminder","elided":true},"type":"attachment","uuid":"e82dd0e1-89fb-4600-a959-f3d5c67bc6ec","timestamp":"2026-10-15T03:27:20.794Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"type":"last-prompt","lastPrompt":"use Bash to run `touch probe.txt` then say ok","leafUuid":"e82dd0e1-89fb-4600-a959-f3d5c67bc6ec","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"ai-title","aiTitle":"Bash echo hi test","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"mode","mode":"normal","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"permission-mode","permissionMode":"default","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"atis-latch","atis":"","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"parentUuid":"e82dd0e1-89fb-4600-a959-f3d5c67bc6ec","isSidechain":false,"message":{"model":"claude-haiku-5-5","id":"msg_011Cg3K22ajHCUsrPU5h1ngD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LxRP8yPQAS8Nt1FPBf8d3C","name":"Bash","input":{"command":"touch probe.txt","description":"Create empty probe.txt file in current directory"},"caller":{"type":"direct"}}],"container":null,"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":33667,"cache_read_input_tokens":0,"output_tokens":87,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":33667},"inference_geo":"global","iterations":[],"speed":"standard","fallback_credit":null},"diagnostics":null,"context_management":null},"apiBlockIndex":0,"requestRef":"f04bb6f1-62a0-43dd-903d-08998eaaafff","requestedModel":"claude-haiku-5-5","type":"assistant","uuid":"5f870a8e-4687-43e8-b26f-678e373adf59","timestamp":"2026-10-15T03:27:23.653Z","effort":"medium","perTurnEffort":"medium","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"5f870a8e-4687-43e8-b26f-678e373adf59","isSidechain":false,"promptId":"7484dc79-63b0-44e0-9b03-04aebb3f806f","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LxRP8yPQAS8Nt1FPBf8d3C","type":"tool_result","content":"(Bash completed with no output)","is_error":false}]},"uuid":"ab829cb4-a806-46bb-aa43-44be6517ad3e","timestamp":"2026-10-15T03:27:42.840Z","toolUseResult":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":true},"sourceToolAssistantUUID":"5f870a8e-4687-43e8-b26f-678e373adf59","permissionDecision":{"decision":"accept","source":"user_temporary"},"session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"ab829cb4-a806-46bb-aa43-44be6517ad3e","isSidechain":false,"attachment":{"type":"prompt_snapshot","elided":true},"type":"attachment","uuid":"eed9a284-505a-4810-ab33-3726169997f7","timestamp":"2026-10-15T03:27:42.842Z","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"9eb0ebd6896494fcbc9d00efa41c7ea4c877547cdcb53d2a3f7746e25c7b7d4b","elided":true} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"7e2f1658d000d9c3e6dd9498adce7d9c156832f4f1b7db852948e8f624b8172c","elided":true} +{"type":"api-request","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","id":"487f7a0f-758b-4558-b776-97832aa79c2b","timestamp":"2026-10-15T03:27:42.855Z","version":"2.1.300","querySource":"repl_main_thread","shapeHash":"161bc4e9c93610cddfb69aa1f76999cf686778e963062d1bc857f703f6fd0fd7","base":"f04bb6f1-62a0-43dd-903d-08998eaaafff","keep":8,"requestDigest":"410b6fed7cbac0713d2fb2dcc7677413258869296c6dddb51b4141c873ac5ad3","elided":true} +{"type":"last-prompt","lastPrompt":"use Bash to run `touch probe.txt` then say ok","leafUuid":"eed9a284-505a-4810-ab33-3726169997f7","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"ai-title","aiTitle":"Bash echo hi test","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"mode","mode":"normal","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"permission-mode","permissionMode":"default","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"type":"atis-latch","atis":"","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e"} +{"parentUuid":"eed9a284-505a-4810-ab33-3726169997f7","isSidechain":false,"attachment":{"type":"total_tokens_reminder","elided":true},"type":"attachment","uuid":"d685be70-9da4-4236-9aee-b3c8d95bf3ef","timestamp":"2026-10-15T03:27:42.843Z","renderedRole":"system","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"d685be70-9da4-4236-9aee-b3c8d95bf3ef","isSidechain":false,"message":{"model":"claude-haiku-5-5","id":"msg_011Cg3K3eqq6H13BYhPzxsBR","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":33667,"output_tokens":4,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":126},"inference_geo":"global","iterations":[],"speed":"standard","fallback_credit":null},"diagnostics":null,"context_management":null},"apiBlockIndex":0,"requestRef":"487f7a0f-758b-4558-b776-97832aa79c2b","requestedModel":"claude-haiku-5-5","type":"assistant","uuid":"182464c2-98dd-4174-96ed-d69f0338e1d1","timestamp":"2026-10-15T03:27:44.175Z","effort":"medium","perTurnEffort":"medium","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"182464c2-98dd-4174-96ed-d69f0338e1d1","isSidechain":false,"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"printf '\\a' 2>/dev/null >/dev/tty || true","durationMs":7},{"command":"{ f=$(mktemp \"/run/user/1000/atem/chat-xxxx/chat/events/ev.XXXXXXXX\") && cat > \"$f\" && mv \"$f\" \"$f.json\"; } 2>/dev/null; exit 0","durationMs":8}],"hookErrors":[],"hookAdditionalContext":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-10-15T03:27:44.188Z","uuid":"e9247eda-67a2-43a4-95f4-8b849d6dd84f","toolUseID":"00a2426f-0dec-4ad2-8138-1d41b1866c7f","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"e9247eda-67a2-43a4-95f4-8b849d6dd84f","isSidechain":false,"type":"system","subtype":"turn_duration","durationMs":4301,"messageCount":29,"timestamp":"2026-10-15T03:27:44.189Z","uuid":"f3c4993b-80ef-48f2-9e91-e00c47b52202","isMeta":false,"userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"type":"file-history-snapshot","messageId":"0b7979d8-34e3-488b-8671-5498dd757bd6","snapshot":{"messageId":"0b7979d8-34e3-488b-8671-5498dd757bd6","trackedFileBackups":{},"timestamp":"2026-10-15T03:27:53.751Z"},"isSnapshotUpdate":false} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"a480ec819c94a4775daf7e7a11b8a74939feed5523356fce6d285f8d242dc9f9","elided":true} +{"type":"api-request-blob","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","hash":"4b8bd9022e1f1d78333fcd370681ee79d1655b97a52c14e0e30360c64fe2dca4","elided":true} +{"type":"api-request","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","id":"3fdd4d1a-2166-4eae-9769-3aaeee239f66","timestamp":"2026-10-15T03:27:53.761Z","version":"2.1.300","querySource":"repl_main_thread","shapeHash":"161bc4e9c93610cddfb69aa1f76999cf686778e963062d1bc857f703f6fd0fd7","base":"487f7a0f-758b-4558-b776-97832aa79c2b","keep":11,"requestDigest":"5d5456f7248f4db182e84069c5fffdf3c7d7cbd6187f0afb0e6dbb1ccd294cf8","elided":true} +{"parentUuid":"f3c4993b-80ef-48f2-9e91-e00c47b52202","isSidechain":false,"promptId":"64707c1e-bb7f-4bb8-9065-1514059dcb0d","type":"user","message":{"role":"user","content":"write a 600-word essay about rivers"},"uuid":"0b7979d8-34e3-488b-8671-5498dd757bd6","timestamp":"2026-10-15T03:27:53.745Z","permissionMode":"default","origin":{"kind":"human"},"promptSource":"typed","turnOrigin":"human","turnPosition":{"promptIndex":3,"turnIndex":3},"userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"0b7979d8-34e3-488b-8671-5498dd757bd6","isSidechain":false,"attachment":{"type":"total_tokens_reminder","elided":true},"type":"attachment","uuid":"55019d49-4bc1-49e4-af8d-db7c633c2c70","timestamp":"2026-10-15T03:27:53.745Z","renderedRole":"system","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"55019d49-4bc1-49e4-af8d-db7c633c2c70","isSidechain":false,"message":{"model":"claude-haiku-5-5","id":"msg_011Cg3K4TQ2bfNspZtvpMrjP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":43,"cache_read_input_tokens":33793,"cache_creation":{"ephemeral_5m_input_tokens":43,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"thinkingDurationMs":23,"thinkingDisplay":"updates","apiBlockIndex":0,"requestRef":"3fdd4d1a-2166-4eae-9769-3aaeee239f66","requestedModel":"claude-haiku-5-5","type":"assistant","uuid":"466c1950-9954-42e1-953e-880c48fc4527","timestamp":"2026-10-15T03:27:55.971Z","effort":"medium","perTurnEffort":"medium","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"466c1950-9954-42e1-953e-880c48fc4527","isSidechain":false,"message":{"model":"claude-haiku-5-5","id":"msg_011Cg3K4TQ2bfNspZtvpMrjP","type":"message","role":"assistant","content":[{"type":"text","text":"**Rivers: The Patient Architects of the Land**\n\nRivers are among the oldest storytellers on Earth. Long before people wrote histories, rivers were carving canyons, depositing silt, and shaping the places where civilizations would later rise. They do their work slowly, often over millennia, and their results are visible everywhere: in the wide valleys of the Nile, the gorges of the Colorado, the braided channels of the Brah"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":43,"cache_read_input_tokens":33793,"cache_creation":{"ephemeral_5m_input_tokens":43,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"apiBlockIndex":1,"requestRef":"3fdd4d1a-2166-4eae-9769-3aaeee239f66","requestedModel":"claude-haiku-5-5","type":"assistant","uuid":"807361cb-ca6d-4d3b-8642-1eb047c65ed7","timestamp":"2026-10-15T03:27:56.782Z","isAbortedMidStream":true,"effort":"medium","perTurnEffort":"medium","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"parentUuid":"807361cb-ca6d-4d3b-8642-1eb047c65ed7","isSidechain":false,"promptId":"64707c1e-bb7f-4bb8-9065-1514059dcb0d","type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user]"}]},"uuid":"1c8fc328-276a-4799-be7d-4470bb058106","timestamp":"2026-10-15T03:27:56.783Z","interruptedMessageId":"msg_011Cg3K4TQ2bfNspZtvpMrjP","session_id":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","userType":"external","entrypoint":"cli","cwd":"/home/user/proj","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","version":"2.1.300","gitBranch":"HEAD","slug":"validated-discovering-key"} +{"type":"cost-state","sessionId":"c142b440-2f9a-4f2e-bf30-d99335ab2e7e","totalCostUSD":0.009192140000000001,"totalAPIDuration":8575,"totalAPIDurationWithoutRetries":8506,"totalToolDuration":369,"totalLinesAdded":0,"totalLinesRemoved":0,"totalDuration":97722,"startTime":1792034787858,"modelUsage":{"claude-haiku-5-5":{"inputTokens":1182,"outputTokens":190,"thinkingTokens":0,"cacheReadInputTokens":66294,"cacheCreationInputTokens":66528,"webSearchRequests":0,"costUSD":0.009192140000000001}},"hasUnknownModelCost":false} diff --git a/tests/fixtures/chat/fake-claude.sh b/tests/fixtures/chat/fake-claude.sh new file mode 100755 index 0000000..7cebf27 --- /dev/null +++ b/tests/fixtures/chat/fake-claude.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Stand-in for the interactive Claude CLI, used by Atem's chat tests. +# Runs the hook command Atem put in --settings for each lifecycle event and +# writes a transcript the way Claude Code 2.1.x does (see claude/README.md). +# Each bracketed-paste line read from the terminal is one prompt; "exit" quits. +# It prints nothing, so Atem saves no session log for it. +settings= +session= +while [ $# -gt 0 ]; do + case "$1" in + --settings) settings=$2; shift ;; + --session-id) session=$2; shift ;; + esac + shift +done +[ -n "$settings" ] || exec cat >/dev/null +[ -n "$session" ] || session=fake-session +hook=$(python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1]))["hooks"]["Stop"][0]["hooks"][0]["command"])' "$settings") +chat_dir=$(dirname "$settings") +transcript="$(dirname "$chat_dir")/transcripts/$session.jsonl" +mkdir -p "$(dirname "$transcript")" +: >> "$transcript" + +fire() { printf '%s' "$1" | sh -c "$hook"; } +log() { printf '%s\n' "$1" >> "$transcript"; } +json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } + +# Cooked mode: the paste's final \r arrives as a newline. +stty -echo icrnl 2>/dev/null || true +fire "{\"hook_event_name\":\"SessionStart\",\"session_id\":\"$session\",\"transcript_path\":\"$transcript\",\"source\":\"startup\"}" + +esc=$(printf '\033') +n=0 +while IFS= read -r line; do + case "$line" in + *"$esc[200~"*) + n=$((n + 1)) + text=${line#*"$esc[200~"} + text=${text%"$esc[201~"*} + t=$(json_escape "$text") + ts="2026-10-15T00:00:0$((n % 10)).000Z" + fire "{\"hook_event_name\":\"UserPromptSubmit\",\"session_id\":\"$session\",\"prompt\":\"$t\",\"prompt_id\":\"p-$n\"}" + log "{\"type\":\"user\",\"uuid\":\"u-$n\",\"promptId\":\"p-$n\",\"timestamp\":\"$ts\",\"message\":{\"role\":\"user\",\"content\":\"$t\"}}" + fire "{\"hook_event_name\":\"PreToolUse\",\"session_id\":\"$session\",\"tool_name\":\"Bash\"}" + log "{\"type\":\"assistant\",\"uuid\":\"a-$n-1\",\"promptId\":\"p-$n\",\"timestamp\":\"$ts\",\"message\":{\"id\":\"msg-$n\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tool-$n\",\"name\":\"Bash\",\"input\":{\"command\":\"echo hi\"}}],\"stop_reason\":\"tool_use\"}}" + fire "{\"hook_event_name\":\"PostToolUse\",\"session_id\":\"$session\",\"tool_name\":\"Bash\"}" + log "{\"type\":\"user\",\"uuid\":\"r-$n\",\"promptId\":\"p-$n\",\"timestamp\":\"$ts\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"tool-$n\",\"content\":\"hi\"}]}}" + log "{\"type\":\"assistant\",\"uuid\":\"a-$n-2\",\"promptId\":\"p-$n\",\"timestamp\":\"$ts\",\"message\":{\"id\":\"msg-$n\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"reply $n\"}],\"stop_reason\":\"end_turn\"}}" + fire "{\"hook_event_name\":\"Stop\",\"session_id\":\"$session\"}" + ;; + exit) exit 0 ;; + esac +done