diff --git a/.claude/settings.json b/.claude/settings.json index 35d06c1d..80bf99d8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -73,6 +73,19 @@ "Read(**/*.p12)" ] }, + "sandbox": { + "network": { + "allowLocalBinding": true, + "allowMachLookup": ["com.apple.SystemConfiguration.configd"] + }, + "filesystem": { + "allowWrite": [ + "~/.cache/uv", + "~/.local/share/uv", + "~/.local/state/uv" + ] + } + }, "hooks": { "SessionStart": [ { diff --git a/.importlinter b/.importlinter index 5fe0d3d5..6153a684 100644 --- a/.importlinter +++ b/.importlinter @@ -13,7 +13,7 @@ type = layers ; assembles the command layer — main, command_registry, help_panels, options — ; stays at the package root, above `commands`, and is intentionally unlisted ; (it legitimately imports the command modules to discover/register them). -; Feature slices (agent, tts, streaming, code_agent, code_gen, init, auth, onboard) are +; Feature slices (agent, tts, streaming, agent_cascade, code_gen, init, auth, onboard) are ; likewise unlisted vertical slices governed by contract 2. layers = commands @@ -34,7 +34,6 @@ source_modules = aai_cli.agent aai_cli.agent_cascade aai_cli.auth - aai_cli.code_agent aai_cli.code_gen aai_cli.init aai_cli.onboard diff --git a/README.md b/README.md index 674a9818..a5fd6133 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,6 @@ That's it. Run `assembly onboard` for a guided tour, or see [Installation](#-ins | `assembly live` | Talk live to a tool-using voice agent, wired client-side from Streaming STT + a deepagents brain on the LLM Gateway + streaming TTS — it can web-search, fetch URLs, and read the docs mid-conversation, like the `agent-cascade` starter (sandbox-only) | | `assembly speak` | Synthesize text to speech over the streaming-TTS WebSocket (sandbox-only) | | `assembly llm` | Prompt the LLM Gateway over a transcript, files, stdin, or a live stream | -| `assembly code` | Terminal coding agent (deepagents SDK) backed only by the LLM Gateway — reads/writes/edits files, runs shell, searches the docs MCP, and can invoke the `assembly` CLI itself; mutating actions ask for approval. Defaults to voice in a terminal (speak your request, replies read back via streaming TTS in the sandbox); pass `--no-voice` for the keyboard TUI | | `assembly clip` | Cut audio/video with ffmpeg by diarized speaker, text match, LLM pick, or time range (`--video` keeps the picture for URL sources) — clip boundaries snap into nearby silence | | `assembly dub` | Re-voice an audio/video file or URL in another language: transcription, LLM translation, per-speaker TTS, ffmpeg track-swap (sandbox-only) | | `assembly caption` | Burn always-visible captions into a video: transcribe (or reuse a transcript), fetch SRT, ffmpeg burns it in — audio untouched | diff --git a/REFERENCE.md b/REFERENCE.md index 9288fbb9..75f8164c 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -159,3 +159,18 @@ Each server is launched independently and best-effort: one that won't start (a missing `npx`/`uvx`, an offline host) drops only its own tools, so a single broken tool never sinks the session. MCP tools are a live-run feature and are not reflected in `--show-code` output. + +`--files` lets the agent read, write, and run code in the directory you launch +it from (off by default). Reads run immediately; a write, edit, or command run pauses +the turn for confirmation in the voice TUI — press `y`/`n` (`a` approves the rest of the +session) or just say it ("approve" / "run it" / "go ahead"; anything unclear is treated as +a no). Destructive commands (e.g. `rm -rf`, `sudo`) ignore the spoken answer and require a +keypress. Commands run OS-sandboxed in that directory — confined to it, with no network +access — on macOS (`sandbox-exec`) and Linux (`bwrap`); on any other platform, or if the +sandbox tool is missing, running code is refused rather than run unconfined. Access is +rooted at the launch directory — the agent can't escape it. It can also delegate a +focused subtask to a helper (a sandboxed general-purpose subagent), whose own writes and +runs need the same confirmation. The agent also keeps a per-project memory file +(`./.deepagents/AGENTS.md`) so it resumes knowing what it was working on. A non-interactive +run (a file/URL source, `--json`, `-o text`, or a non-TTY) has no way to confirm a write or +run, so those are declined there while reads still work. diff --git a/aai_cli/AGENTS.md b/aai_cli/AGENTS.md index 6cc9f17f..5a0e3d8d 100644 --- a/aai_cli/AGENTS.md +++ b/aai_cli/AGENTS.md @@ -44,7 +44,7 @@ contract: `help_panels`, `options`. They assemble/define the command layer (and `command_registry` imports the command modules to discover them), so they live *above* `commands` and stay at the root. -- **Feature slices** — `agent/`, `tts/`, `streaming/`, `code_agent/`, `code_gen/`, +- **Feature slices** — `agent/`, `tts/`, `streaming/`, `code_gen/`, `init/`, `auth/`, `onboard/`. These are cohesive vertical slices that internally mix protocol + rendering, so they aren't a single horizontal layer; contract 2 forbids them from importing `commands`. @@ -151,9 +151,8 @@ heavily-reworked commands with long bodies; small commands keep the inline - **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback. - **`core/sync_stt.py`** + **`core/signals.py`** + `commands/dictate/` — `assembly dictate`: headless dictation over the **Sync STT API** (`Environment.sync_base`, one POST `/transcribe` per utterance with the required `X-AAI-Model: u3-sync-pro` header; 80 ms–120 s of PCM/WAV). It needs no terminal: recording starts immediately and `dictate_exec._record` polls `signals.stop_on_terminate` between ~100 ms mic chunks for a SIGTERM, which finishes the utterance (clean exit 0) — so a hotkey tool like Hammerspoon can launch it as a background task and `kill -TERM`/`task:terminate()` to transcribe. SIGINT (Ctrl-C) still cancels (exit 130). Both boundaries (the stop latch, mic, HTTP) are injectable, so the suite never needs a real signal or microphone (`tests/test_dictate_exec.py` scripts the SIGTERM latch). Contrast `signals.terminate_as_interrupt` (used by `stream`/`agent`/`speak`), which routes SIGTERM into the *cancel* path instead. - **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). -- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, per-sentence TTS, barge-in, history window — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`); under `-v` (`debuglog.active()`) `brain._run_graph` *streams* that graph instead of `invoke`-ing it and logs each tool call/result/interim line as it lands (reusing `code_agent.events.message_events`), so a spoken turn that stalls mid-tool is debuggable — plain `invoke` runs the whole loop internally and `-v` would otherwise show only the httpx lines. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It shares the `assembly code` TUI's chrome (`code_agent.banner` wordmark, `code_agent.messages` widgets, `code_agent.tui_status.voicebar_markup`/`VOICE_FRAMES`); the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. +- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in, history window — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`) streamed token-by-token via `brain.build_streamer` (`graph.stream(stream_mode="messages")`): the engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. Under `-v` (`debuglog.active()`) `brain._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (off by default) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`; `brain._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`, and binds one gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool; spec in `agent_cascade/subagents.py`, omitting `model`/`tools` so it inherits both) for delegating a focused subtask. The subagent's own `interrupt_on` mirrors `_WRITE_TOOLS`, and a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated. - **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless. -- **`code_agent/`** + `commands/code/` — `assembly code`: a terminal coding agent (a bespoke port of langchain-ai/deepagents' `code` agent) that talks **only** to the LLM Gateway. `model.py` pins the model to `ChatOpenAI` against `llm_gateway_base`; `agent.py` builds the deepagents graph over a cwd-scoped `LocalShellBackend` (filesystem + shell tools), plus extra tools: the custom `assembly` CLI tool (`cli_tool.py`, runs `python -m aai_cli` with the key via child env, never argv), a URL `fetch_url` tool (`fetch_tool.py`), Firecrawl web search when `FIRECRAWL_API_KEY` is set (`firecrawl_search.py`, shared with the live voice agent), an `ask_user` tool routed through an `AskBridge` to the front-end (`ask_tool.py`), and best-effort docs MCP tools (`docs_mcp.py`). Middleware adds installed skills (`skills.py`) and long-term memory (`memory.py`), each over its own dedicated backend. Sessions persist via a SQLite checkpointer (`store.py`) keyed by `--session`, so conversations resume. Approval gates the mutating tools (write/edit/execute/`assembly`/`fetch_url`); the general-purpose `task` subagent comes from deepagents by default. `session.py` drives the graph turn-by-turn (interrupt/resume = human approval), emitting framework-agnostic `events.py` to either the Textual TUI (`tui.py`, modeled on deepagents-code: transcript + input + approval/ask modals + clipboard copy) or the Rich fallback (`render.py`). The whole orchestration is tested by driving the **real** graph with a fake `BaseChatModel` (`tests/test_code_agent.py`), so no network/TTY is needed. **Voice is the default front-end in an interactive TTY** (`voice.py` + `_exec._run_voice`): `VoiceSession.listen` captures one spoken turn over Streaming STT (gating the mic shut the instant a turn finalizes) and `VoiceSession.speak` reads each assistant reply back over streaming TTS. It runs the **Rich REPL** loop (not the keyboard TUI) with a voice `read_line` + a reply-speaking sink. Readback needs streaming TTS, so it's **sandbox-only** (`tts.session.is_available`); in production the mic input still works and replies stay on screen. A mic-less box degrades to typed input on the first `AUDIO_ERROR_TYPES` `CLIError`; `--no-voice` selects the TUI, and a non-TTY (pipe/CI) the headless loop. Both legs (STT/TTS) are injected like the cascade's, so `tests/test_code_voice.py` drives it with fakes — no mic/speaker/socket. - **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`). - **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps. - **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`. diff --git a/aai_cli/agent/audio.py b/aai_cli/agent/audio.py index 1a6580c2..6fb2e2a4 100644 --- a/aai_cli/agent/audio.py +++ b/aai_cli/agent/audio.py @@ -102,6 +102,11 @@ def __init__( # access goes through `_lock`. `_out_state` (the target->device ratecv state) # is touched ONLY by feed(), never the callback, so it needs no lock. self._in: queue.Queue[bytes | None] = queue.Queue() + # The mic gate: set = listening (real audio), clear = muted (silence to STT). Flipped + # from the UI thread (start/stop listening), read on the capture thread, so it's an + # Event rather than a bare bool. Starts open — a session listens as soon as it connects. + self._listening = threading.Event() + self._listening.set() # How long capture_frames() waits for a chunk before checking whether the # device stream silently died (e.g. unplugged); injectable for fast tests. self._poll_timeout = poll_timeout @@ -179,12 +184,40 @@ def capture_frames(self) -> Iterator[bytes]: continue if chunk is None: return + if not self._listening.is_set(): + # Muted: feed silence of the same length so the recognizer keeps receiving + # audio (the socket stays alive) but hears nothing, instead of stalling the + # stream. Resampling zeros still yields zeros, so gate before the resample. + chunk = bytes(len(chunk)) if self._device_rate != self._target: chunk, state = resample_pcm16( chunk, state, src_rate=self._device_rate, dst_rate=self._target ) yield chunk + def set_listening(self, *, on: bool) -> None: + """Open or mute the mic in place, without tearing down the stream. + + Muting keeps the full-duplex stream and the live STT/TTS session alive — captured + frames are zeroed to silence (see :meth:`capture_frames`) — so toggling back on + resumes listening instantly, with no socket reconnect. + """ + if on: + self._listening.set() + else: + self._listening.clear() + + def toggle_listening(self) -> bool: + """Flip the mic between listening and muted; return the resulting listening state.""" + on = not self._listening.is_set() + self.set_listening(on=on) + return on + + @property + def listening(self) -> bool: + """Whether the mic is feeding real audio to STT (vs muted silence).""" + return self._listening.is_set() + def close(self) -> None: self._in.put(None) # end capture_frames() if self._stream is not None: diff --git a/aai_cli/agent_cascade/_io.py b/aai_cli/agent_cascade/_io.py new file mode 100644 index 00000000..89a2a2e6 --- /dev/null +++ b/aai_cli/agent_cascade/_io.py @@ -0,0 +1,125 @@ +"""The live cascade's I/O boundary: the render/playback protocols and injected legs. + +Split out of ``engine.py`` to keep that module within the file-length gate. ``Renderer`` and +``Player`` are the surfaces the engine drives (a TUI/line renderer and a speaker); +``CascadeDeps`` bundles the three network legs plus the thread spawner so the orchestration is +unit-tested against fakes. ``engine`` re-exports all three, so importers keep using +``engine.Renderer`` / ``engine.CascadeDeps`` unchanged. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +from aai_cli.agent_cascade import brain +from aai_cli.agent_cascade._runtime import Worker as _Worker +from aai_cli.agent_cascade._runtime import spawn_thread as _spawn_thread +from aai_cli.agent_cascade.config import CascadeConfig +from aai_cli.core import client +from aai_cli.tts import session as tts_session +from aai_cli.tts.session import SpeakConfig + +if TYPE_CHECKING: + from assemblyai.streaming.v3 import StreamingParameters + +# Streaming TTS synthesizes at 24 kHz, the rate the live player is opened at. +TTS_SAMPLE_RATE = 24000 + + +class Renderer(Protocol): + """The conversation-rendering surface the cascade drives (AgentRenderer satisfies it).""" + + def connected(self) -> None: + """Announce the session is live and listening.""" + + def user_partial(self, text: str) -> None: + """Show an interim user transcript.""" + + def user_final(self, text: str) -> None: + """Show a finalized user transcript.""" + + def tool_call(self, label: str) -> None: + """Show that the agent is using a tool (e.g. "Searching the web") while it thinks.""" + + def reply_started(self) -> None: + """Mark the start of an agent reply.""" + + def agent_transcript(self, text: str, *, interrupted: bool) -> None: + """Show a line of the agent's reply.""" + + def reply_done(self, *, interrupted: bool) -> None: + """Mark the end of an agent reply.""" + + +class Player(Protocol): + """The speaker the cascade enqueues TTS audio into (DuplexAudio/NullPlayer satisfy it).""" + + def start(self) -> None: + """Open the output stream.""" + + def enqueue(self, pcm: bytes) -> None: + """Queue PCM audio for playback.""" + + def flush(self) -> None: + """Drop any queued-but-unplayed audio (used on barge-in).""" + + def pending(self) -> int: + """How many unplayed samples are still queued (>0 while audio is audibly playing).""" + ... + + def close(self) -> None: + """Close the output stream.""" + + +@dataclass +class CascadeDeps: + """The cascade's three network legs plus its thread spawner, all injectable. + + ``CascadeDeps.real`` wires the live STT/LLM/TTS clients; tests pass fakes with + the same shapes (and a synchronous ``spawn``) to drive the orchestration. + """ + + run_stt: Callable[[Callable[[object], None]], None] + # stream_reply(messages) -> iterable of SpeechDelta/ToolNotice events (plus ApprovalPause + # markers under --files write gating). The reply is streamed token-by-token so the engine + # can speak each clause as it lands; a ToolNotice surfaces the "Searching the web…" + # affordance (brain.build_streamer). + stream_reply: Callable[ + ..., Iterable[brain.SpeechDelta | brain.ToolNotice | brain.ApprovalPause] + ] + # synthesize(text, sink): streaming TTS — sink is called with each PCM frame as it + # arrives so playback starts on the first frame instead of after the whole clause. + synthesize: Callable[[str, Callable[[bytes], None]], None] + spawn: Callable[[Callable[[], None]], _Worker] = _spawn_thread + + @classmethod + def real( + cls, + api_key: str, + config: CascadeConfig, + *, + audio: Iterable[bytes], + stt_params: StreamingParameters, + approver: brain.Approver | None = None, + ) -> CascadeDeps: + def run_stt(on_turn: Callable[[object], None]) -> None: + client.stream_audio(api_key, audio, params=stt_params, on_turn=on_turn) + + # The LLM leg is a deepagents graph (web search / MCP tools), streamed token-by-token + # so a spoken turn can transparently use tools and start speaking sooner. ``approver`` + # gates --files writes (None on the non-files path, where the graph never pauses). + stream_reply = brain.build_streamer(api_key, config, approver=approver) + + def synthesize(text: str, sink: Callable[[bytes], None]) -> None: + spec = SpeakConfig( + text=text, + voice=config.voice, + language=config.language, + sample_rate=TTS_SAMPLE_RATE, + extra=config.tts_extra, + ) + tts_session.synthesize(api_key, spec, on_audio=lambda chunk, _rate: sink(chunk)) + + return cls(run_stt=run_stt, stream_reply=stream_reply, synthesize=synthesize) diff --git a/aai_cli/agent_cascade/_runtime.py b/aai_cli/agent_cascade/_runtime.py new file mode 100644 index 00000000..2947d0c5 --- /dev/null +++ b/aai_cli/agent_cascade/_runtime.py @@ -0,0 +1,122 @@ +"""Low-level reply-runtime primitives for the cascade engine. + +The cascade streams each LLM reply on a throwaway producer thread that feeds a +queue the consumer drains under a wall-clock deadline (see ``engine.py``). This +module holds the pieces that machinery is built from — the queue sentinels, the +timeout error, the worker protocol, and the ``concurrent.futures`` executor +detach that keeps an abandoned graph leg from wedging interpreter exit — kept +separate from the orchestration in :class:`~aai_cli.agent_cascade.engine.CascadeSession` +so each file stays focused. + +The module name is underscore-prefixed (package-private); ``engine`` imports +these names and aliases them back to its own ``_``-prefixed internals. +""" + +from __future__ import annotations + +import concurrent.futures.thread as cf_thread +import contextlib +import threading +from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +from aai_cli.agent_cascade import brain +from aai_cli.core.errors import CLIError + +if TYPE_CHECKING: + from openai.types.chat import ChatCompletionMessageParam + +# Wall-clock backstop for one reply turn. The reply is streamed on a throwaway producer +# thread feeding a queue; a stalled gateway can block inside a token read the worker can't +# observe, so the consumer's queue.get is bounded by a monotonic deadline. After this long +# we stop waiting and surface a timeout so the session stays usable. Generous on purpose. +REPLY_TIMEOUT_SECONDS = 60.0 # pragma: no mutate + + +@dataclass(frozen=True) +class Done: + """Producer sentinel: the reply stream finished normally.""" + + +@dataclass(frozen=True) +class Failure: + """Producer sentinel: the reply leg raised a (clean) CLIError.""" + + error: CLIError + + +@dataclass(frozen=True) +class Timeout: + """Consumer sentinel: the wall-clock deadline elapsed before the next event arrived.""" + + +# What the producer thread puts on the consumer's queue: a speech/tool event from the +# streaming leg, an approval-pause marker (--files write gating), or a terminal sentinel. +type ReplyEvent = brain.SpeechDelta | brain.ToolNotice | brain.ApprovalPause | Done | Failure + + +def timeout_error() -> CLIError: + """The backstop error raised when a reply overruns the wall-clock deadline.""" + return CLIError( + f"the agent took longer than {REPLY_TIMEOUT_SECONDS:.0f}s to respond and was cut off", + error_type="agent_timeout", + ) + + +class Worker(Protocol): + """The slice of a thread the session drives: started already, queryable, joinable.""" + + @abstractmethod + def is_alive(self) -> bool: + """Whether the reply worker is still running.""" + + def join(self) -> None: + """Block until the reply worker finishes.""" + + +def new_history() -> list[ChatCompletionMessageParam]: + """Typed empty-history factory (ChatCompletionMessageParam is import-time-only).""" + return [] + + +def executor_threads() -> set[threading.Thread]: + """A snapshot of every live ThreadPoolExecutor worker concurrent.futures tracks for its + interpreter-exit join. Empty if a future Python drops the internal registry.""" + return set(getattr(cf_thread, "_threads_queues", ())) + + +def detach_executor_threads_since(before: set[threading.Thread]) -> None: + """Drop executor workers spawned since ``before`` from concurrent.futures' exit-join list, + so an abandoned (timed-out) graph leg can't wedge process exit. + + ``complete_reply`` runs the deepagents graph, which drives each node through a langchain + ``ThreadPoolExecutor``. Abandoning a timed-out call leaves that executor's worker blocked on + the network leg, and concurrent.futures registers an interpreter-exit hook (``_python_exit``) + that joins *every* executor worker unconditionally — even daemons — by putting a shutdown + sentinel on its queue and waiting. A worker mid-call never reads that sentinel, so the join + (and the whole process exit) hangs until the user Ctrl-Cs — the threading-shutdown traceback + this prevents. The worker was created on our own daemon thread so it inherits ``daemon=True``; + once it's off this registry neither ``_python_exit`` nor ``threading._shutdown`` waits on it, + and the orphaned network call dies with the process as a daemon should. Best-effort: a future + Python that renames the internals simply skips the detach (regressing to the old hang, not + crashing). The diff is scoped to threads that appeared during the call, so a co-running + executor elsewhere keeps its normal exit-time join. + """ + registry = getattr(cf_thread, "_threads_queues", None) + if registry is None: + return + # Mutate under the same lock concurrent.futures holds for the registry, so a concurrent + # submit (or _python_exit itself) never sees a torn dict. + with getattr(cf_thread, "_global_shutdown_lock", contextlib.nullcontext()): + for thread in executor_threads() - before: + registry.pop(thread, None) + + +def spawn_thread(target: Callable[[], None]) -> Worker: + """Start ``target`` on a daemon thread so a reply is generated without blocking + the STT reader (which must stay free to detect a barge-in).""" + thread = threading.Thread(target=target, daemon=True) # pragma: no mutate + thread.start() + return thread diff --git a/aai_cli/code_agent/banner.py b/aai_cli/agent_cascade/banner.py similarity index 100% rename from aai_cli/code_agent/banner.py rename to aai_cli/agent_cascade/banner.py diff --git a/aai_cli/agent_cascade/brain.py b/aai_cli/agent_cascade/brain.py index 95bfbacb..ead1d7fe 100644 --- a/aai_cli/agent_cascade/brain.py +++ b/aai_cli/agent_cascade/brain.py @@ -5,33 +5,51 @@ mid-conversation, mimicking a live multimodal assistant (the "talk to Gemini Live" experience). The toolset is deliberately minimal: a low-latency spoken turn does best with one obvious tool rather than a menu it has to choose among. The graph is built once per session -(:func:`build_graph`) and invoked statelessly per turn with the running history the -cascade already keeps (:func:`build_completer`); tools are read-only and auto-approved, +(:func:`build_graph`) and driven turn-by-turn with the running history the +cascade already keeps (:func:`build_streamer`); tools are read-only and auto-approved, because a spoken turn can't pause for a keyboard confirmation, and the system prompt keeps every reply short and speakable. -The graph is the only network seam: :func:`build_completer` accepts an injected graph, -so the per-turn orchestration is unit-tested against a fake with no sockets — the same -seam the rest of the cascade uses for its STT/LLM/TTS legs. +The graph is the only network seam: :func:`build_streamer` accepts an injected graph, +so the per-turn streaming reply leg is unit-tested against a fake with no sockets — the +same seam the rest of the cascade uses for its STT/LLM/TTS legs. """ from __future__ import annotations +import itertools import logging -from collections.abc import Callable, Mapping, Sequence -from typing import TYPE_CHECKING +from collections.abc import Callable, Iterator, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, runtime_checkable +from aai_cli.agent_cascade import datetime_tool, weather_tool, webpage_tool from aai_cli.agent_cascade.config import CascadeConfig -from aai_cli.code_agent.agent import CompiledAgent -from aai_cli.code_agent.firecrawl_search import WEB_SEARCH_TOOL_NAME -from aai_cli.code_agent.summarize import describe_args +from aai_cli.agent_cascade.firecrawl_search import WEB_SEARCH_TOOL_NAME +from aai_cli.agent_cascade.prompt import build_system_prompt from aai_cli.core import debuglog from aai_cli.core.errors import CLIError if TYPE_CHECKING: + from langchain.agents.middleware import AgentMiddleware from langchain_core.tools import BaseTool from openai.types.chat import ChatCompletionMessageParam + +class CompiledAgent(Protocol): + """The slice of the compiled langgraph graph the live reply leg drives. + + A structural type so we needn't name langgraph's deeply-generic + ``CompiledStateGraph`` (and don't drag its type params through our code). + """ + + def invoke( + self, input: object, config: Mapping[str, object] | None = None + ) -> dict[str, object]: + """Run one step of the graph, returning the updated state (incl. messages).""" + + # Verbose (`-v`) flow logging for the agent's tool loop. `invoke` runs the whole loop # internally, so without this `-v` only shows the httpx request lines and never which # tools the agent reached for or what they returned — exactly what you need to see when @@ -45,7 +63,21 @@ # Human, speakable labels for the tool affordance the live UI shows while a tool runs (so a # spoken turn that pauses to use a tool says *why* it's working, not just spin silently). -_TOOL_LABELS = {WEB_SEARCH_TOOL_NAME: "Searching the web"} +_TOOL_LABELS = { + WEB_SEARCH_TOOL_NAME: "Searching the web", + weather_tool.WEATHER_TOOL_NAME: "Checking the weather", + webpage_tool.READ_URL_TOOL_NAME: "Reading the page", + datetime_tool.DATETIME_TOOL_NAME: "Checking the time", + # The --files filesystem tools (deepagents' built-in names). + "read_file": "Reading a file", + "write_file": "Writing a file", + "edit_file": "Editing a file", + "execute": "Running code", + "task": "Working on a subtask", + "ls": "Listing files", + "glob": "Finding files", + "grep": "Searching files", +} def _tool_label(name: str) -> str: @@ -53,116 +85,168 @@ def _tool_label(name: str) -> str: return _TOOL_LABELS.get(name, f"Using {name}") -def _tool_affordance(name: str, args: Mapping[str, object]) -> str: - """The live UI's tool-affordance string: the label plus its identifying arg. +# Spoken filler the agent says aloud when it pauses for a tool, so a hands-free turn fills the +# silent tool round-trip with *why* it paused instead of dead air (the audible counterpart to the +# visual `_TOOL_LABELS` affordance). Each tool gets a few short, speakable variants the engine +# rotates across turns; unknown/MCP tools fall back to `_GENERIC_FILLERS`. Spoken-style only — no +# markdown, no trailing detail — since they're synthesized straight to TTS ahead of the answer. +_GENERIC_FILLERS: tuple[str, ...] = ("One sec.", "Let me check.") + +_TOOL_FILLERS: dict[str, tuple[str, ...]] = { + WEB_SEARCH_TOOL_NAME: ( + "Let me look that up.", + "Searching now.", + "One moment, checking the web.", + ), + weather_tool.WEATHER_TOOL_NAME: ("Let me check the weather.", "Checking the forecast now."), + webpage_tool.READ_URL_TOOL_NAME: ("Let me pull up that page.", "Reading it now."), + datetime_tool.DATETIME_TOOL_NAME: ("Let me check the time.", "One moment."), +} + + +def _tool_fillers(name: str) -> tuple[str, ...]: + """The spoken filler variants for a tool call, falling back to the generic tuple. - Joins the friendly present-tense label (``Searching the web`` / ``Using read_file``) with - the one identifying argument :func:`describe_args` picks out (a query, path, or URL), so a - paused turn reads as ``Searching the web · ai house Seattle`` rather than a bare verb. Falls - back to the bare label when the call carries no arguments. + Mirrors :func:`_tool_label`: a known tool gets its own phrases, an unknown/MCP tool the + generic fallback. The tuple (not a single pre-chosen phrase) rides on :class:`ToolNotice` + so the engine owns rotation state and two notices for the same tool don't repeat. """ - label = _tool_label(name) - detail = describe_args(args) - return f"{label} · {detail}" if detail else label - - -# Closes every guidance variant: the reply is spoken, so it must stay short and plain. -_SPOKEN_TAIL = ( - "Your reply is read aloud, so keep it short and spoken — no markdown, lists, code, or raw URLs." -) - -# When the session has *no* tools wired (e.g. no web search and the docs host is -# unreachable), the model must answer from its own knowledge — and crucially must not -# promise an action it can't take. Without this, telling it "you can search the web" while -# no search tool is bound makes it narrate "I'll search for that…" and then stop, so the -# answer never comes (the tool it announced was never actually available to call). -_NO_TOOLS_GUIDANCE = ( - "You have no external tools available, so answer from your own knowledge. Never say " - "you will search the web, look something up, or fetch a page — you can't do any of " - "that, so don't promise it; if a question needs information you don't have, say so " - f"briefly instead. {_SPOKEN_TAIL}" -) - - -def _join_clause(parts: list[str]) -> str: - """Join capability phrases into a readable clause: ``a``, ``a and b``, ``a, b, and c``.""" - *initial, last = parts - if not initial: - return last - # Oxford comma only once there are three-or-more items (two or more lead the last). - joiner = ", and " if initial[1:] else " and " - return f"{', '.join(initial)}{joiner}{last}" - - -def _tool_capabilities(tools: Sequence[BaseTool]) -> list[str]: - """The spoken-capability phrase backed by a present built-in tool. - - The live agent's only built-in tool is Firecrawl web search, bound just when a - ``FIRECRAWL_API_KEY`` is set — so the prompt advertises web search only when the agent - can really do it. Advertising a tool it doesn't have made it announce an action ("I'll - search…") it then couldn't take, leaving the turn with no answer. + return _TOOL_FILLERS.get(name, _GENERIC_FILLERS) + + +@dataclass(frozen=True) +class SpeechDelta: + """A top-level assistant-text token delta to be spoken (one piece of the reply).""" + + text: str + + +@dataclass(frozen=True) +class ToolNotice: + """A speakable affordance emitted when the agent starts a tool call mid-turn. + + ``label`` is the visual affordance ("Searching the web"); ``fillers`` are the spoken + variants the engine may say aloud for the *first* tool call of a turn (it owns the + rotation), so a hands-free turn isn't dead air during the tool round-trip. """ - names = {tool.name for tool in tools} - if WEB_SEARCH_TOOL_NAME in names: - return ["search the web for current or unfamiliar facts"] - return [] + + label: str + fillers: tuple[str, ...] -def _extra_capability(extra_tools: Sequence[BaseTool]) -> str | None: - """The spoken-capability phrase for user-configured MCP tools, listing them by name. +@dataclass(frozen=True) +class ApprovalPause: + """Brackets a human write-approval wait (``--files``). - The deepagents graph already shows the model each tool's schema, so this only has to - name the tools so the guidance doesn't claim "no external tools" when MCP tools are - bound — and so the model knows to reach for them. + Emitted ``active=True`` just before the streamer blocks on the user's y/n decision and + ``active=False`` once it's answered, so the engine can suspend its reply-timeout deadline + for exactly the human-think interval (a slow keypress must not cut off the write). """ - names = sorted(tool.name for tool in extra_tools) - if not names: - return None - return f"use your connected tools ({', '.join(names)})" - - -def build_system_prompt( - persona: str, *, tools: Sequence[BaseTool], extra_tools: Sequence[BaseTool] = () -) -> str: - """The live agent's system prompt: the user's persona plus tool guidance. - - The guidance is tailored to the bound tools so the model is only told about - capabilities it actually has — advertising a missing tool (web search without a - ``FIRECRAWL_API_KEY``) made the agent announce an action it then couldn't take, leaving - the turn hanging with no answer. ``tools`` are the built-in legs (web search, URL - fetch, AssemblyAI docs); ``extra_tools`` are user-configured MCP tools, advertised - generically by name. With no tools at all the model answers from its own knowledge. + + active: bool + + +@runtime_checkable +class _GatedGraph(Protocol): + """The graph surface the --files write-approval loop drives beyond ``invoke``. + + ``CompiledAgent`` deliberately declares only ``invoke`` (mirroring the code agent), so the + gated path narrows to this protocol for the ``stream``/``get_state`` it additionally needs. """ - capabilities = _tool_capabilities(tools) - extra = _extra_capability(extra_tools) - if extra is not None: - capabilities.append(extra) - if not capabilities: - return f"{persona}\n\n{_NO_TOOLS_GUIDANCE}" - guidance = ( - f"You can use tools to help answer: {_join_clause(capabilities)}. Reach for a " - "tool when a question needs fresh or external information; answer directly and " - "instantly when you already know. Only offer to do what these tools allow — don't " - f"say you'll search the web or look something up unless it's listed here. {_SPOKEN_TAIL}" - ) - return f"{persona}\n\n{guidance}" + + def stream( + self, graph_input: object, config: Mapping[str, object] | None, *, stream_mode: str + ) -> Iterator[tuple[object, object]]: + """Yield ``(message_chunk, metadata)`` pairs for one streamed segment.""" + + def get_state(self, config: Mapping[str, object] | None) -> object: + """The checkpointed state snapshot (its ``.interrupts`` carry any pending write).""" + + +# Decide whether a gated write may run (front-end supplied). Mirrors the code agent's Approver. +Approver = Callable[[str, dict[str, object]], bool] + +# Message handed back to the model when the user declines a write (matches the code agent's copy). +_DECLINED = "User declined to run this tool." def build_live_tools() -> list[BaseTool]: - """The live agent's single read-only tool: Firecrawl web search (only when keyed). - - Deliberately minimal. A low-latency spoken turn does best with one obvious tool rather - than a large menu it has to choose among — a big toolset made the model narrate "I'll - search…" without ever calling anything, and bloated every request with tool schemas. - Web search is the one capability worth the round-trip; everything else the agent answers - from its own knowledge. The tool is reused (un-approval-gated) from the coding agent and - is present only when ``FIRECRAWL_API_KEY`` is set, so an unkeyed session simply runs - tool-free. Extra tools remain strictly opt-in via ``--mcp-config``. + """The live agent's built-in tools: the keyless weather, read-a-URL, and date/time + tools, plus Firecrawl web search when ``FIRECRAWL_API_KEY`` is set. + + Deliberately minimal. A low-latency spoken turn does best with a few obvious tools + rather than a large menu it must choose among. Open-Meteo, the URL reader, and the + system clock need no key, so the weather, read-url, and datetime tools are always + present (every session has real capabilities); web search is reused (un-approval-gated) + from the coding agent and added only when keyed. Extra tools remain strictly opt-in via + ``--mcp-config``. """ - from aai_cli.code_agent.firecrawl_search import build_web_search_tool + from aai_cli.agent_cascade.datetime_tool import build_datetime_tool + from aai_cli.agent_cascade.firecrawl_search import build_web_search_tool + from aai_cli.agent_cascade.weather_tool import build_weather_tool + from aai_cli.agent_cascade.webpage_tool import build_read_url_tool + tools: list[BaseTool] = [build_weather_tool(), build_read_url_tool(), build_datetime_tool()] search = build_web_search_tool() - return [search] if search is not None else [] + if search is not None: + tools.append(search) + return tools + + +# The mutating tools gated behind human approval when --files is on (reads — incl. grep — stay +# ungated). execute joins the gate because the backend is now sandbox-capable: it runs real +# commands in cwd, OS-confined, but every run is still approved. +_WRITE_TOOLS = ("write_file", "edit_file", "execute") + + +def _build_fs_backend() -> object: + """A sandbox-capable deepagents backend rooted at the launch directory. + + ``virtual_mode=True`` maps the model's ``/``-rooted paths under cwd and blocks traversal + escapes (same containment as before for file ops). Being a ``SandboxBackendProtocol`` backend + is what makes deepagents bind a *functional* ``execute`` — and :class:`SandboxedShellBackend` + runs it OS-sandboxed in cwd (no network, no escape) rather than on the host shell.""" + from aai_cli.agent_cascade.sandbox import SandboxedShellBackend + + return SandboxedShellBackend(root_dir=str(Path.cwd()), virtual_mode=True) + + +def _graph_kwargs( + config: CascadeConfig, *, backend_factory: Callable[[], object] = _build_fs_backend +) -> dict[str, object]: + """Extra ``create_deep_agent`` kwargs that turn on real-cwd files + write-gating. + + Empty when ``--files`` is off, so the graph is built exactly as before. When on: a real-cwd + backend, ``interrupt_on`` pausing only the mutating tools for human approval, and an + in-memory checkpointer (interrupt/resume needs one). ``backend_factory`` is the test seam. + """ + if not config.files: + return {} + from langgraph.checkpoint.memory import InMemorySaver + + from aai_cli.agent_cascade.subagents import general_purpose_subagent + + return { + "backend": backend_factory(), + "interrupt_on": dict.fromkeys(_WRITE_TOOLS, True), + "checkpointer": InMemorySaver(), + "memory": ["./.deepagents/AGENTS.md"], + "subagents": [general_purpose_subagent(dict.fromkeys(_WRITE_TOOLS, True))], + } + + +def _build_middleware(config: CascadeConfig) -> list[AgentMiddleware]: + """The live brain's extra agent middleware: a per-turn tool-call budget. + + ``ToolCallLimitMiddleware(run_limit=…, exit_behavior="continue")`` caps tool calls *per + spoken turn* and, once the budget is hit, blocks further tool calls so the model is forced to + answer with what it has gathered — a graceful stop rather than looping until langgraph's + recursion backstop raises. deepagents inserts this into its own middleware stack (additive, + so the core file/subagent/summarization middleware is untouched). + """ + from langchain.agents.middleware import ToolCallLimitMiddleware + + return [ToolCallLimitMiddleware(run_limit=config.tool_call_limit, exit_behavior="continue")] def build_graph( @@ -185,7 +269,7 @@ def build_graph( from deepagents import create_deep_agent from aai_cli.agent_cascade.mcp_tools import load_mcp_tools - from aai_cli.code_agent.model import build_model + from aai_cli.agent_cascade.model import build_model model = build_model( api_key, model=config.model, max_tokens=config.max_tokens, extra=config.llm_extra @@ -195,116 +279,198 @@ def build_graph( return create_deep_agent( model=model, tools=builtin + extra, - system_prompt=build_system_prompt(config.system_prompt, tools=builtin, extra_tools=extra), + system_prompt=build_system_prompt( + config.system_prompt, tools=builtin, extra_tools=extra, files=config.files + ), + middleware=_build_middleware(config), + **_graph_kwargs(config), ) -def build_completer( - api_key: str, config: CascadeConfig, *, graph: CompiledAgent | None = None -) -> Callable[..., str]: - """A ``complete_reply`` for the cascade engine backed by the deepagents graph. - - The cascade prepends its own ``system`` message to the history each turn; the graph - already owns the system prompt, so we drop it before invoking. The graph runs the full - tool loop and we return its final spoken text. ``on_tool`` (when given) is called with a - short label as each tool call lands, so the front-end can show a "Searching the web…" - affordance instead of sitting silent while the agent works; the loop is also streamed — - rather than ``invoke``-d — whenever a sink is wired or under ``-v`` (see :func:`_run_graph`). - ``graph`` is injected in tests so the per-turn wiring runs against a fake with no network. +def build_streamer( + api_key: str, + config: CascadeConfig, + *, + graph: CompiledAgent | None = None, + approver: Approver | None = None, +) -> Callable[..., Iterator[SpeechDelta | ToolNotice | ApprovalPause]]: + """A streaming reply leg for the cascade engine, backed by the deepagents graph. + + The cascade prepends its own ``system`` message each turn; the graph owns the system + prompt, so it is dropped before streaming. The graph is driven with + ``stream_mode="messages"`` and each top-level assistant token delta is yielded as a + :class:`SpeechDelta`, each started tool call as a :class:`ToolNotice` (the live UI's + affordance). Under ``-v`` the flow is logged. ``graph`` is injected in tests so the + per-turn wiring runs against a fake with no network. + + With ``--files`` on (``config.files``) the graph gates ``write_file``/``edit_file``: a + pending write pauses the stream, ``approver`` decides, and the turn resumes (see + :func:`_stream_gated`). Each turn uses a fresh ``thread_id`` so the checkpointer never + accumulates the cascade's full-history-per-turn input across turns. """ resolved = build_graph(api_key, config) if graph is None else graph + turn_ids = itertools.count() - def complete_reply( + def stream_reply( messages: list[ChatCompletionMessageParam], - on_tool: Callable[[str], None] | None = None, - ) -> str: + ) -> Iterator[SpeechDelta | ToolNotice | ApprovalPause]: conversation = [message for message in messages if message.get("role") != "system"] - return _reply_text(_run_graph(resolved, conversation, on_tool)) + run_config = ( + {"configurable": {"thread_id": f"live-{next(turn_ids)}"}} if config.files else None + ) + return _stream_graph( + resolved, conversation, approver=approver, config=run_config, gated=config.files + ) - return complete_reply + return stream_reply -def _run_graph( +def _stream_graph( graph: CompiledAgent, conversation: list[ChatCompletionMessageParam], - on_tool: Callable[[str], None] | None = None, -) -> dict[str, object]: - """Run one turn through the graph, returning its end state. - - Normally a single ``invoke`` (the whole tool loop runs internally). When a tool sink is - wired (the live UI's affordance) or under verbose mode, and the graph can stream, drive - it as incremental state snapshots instead so :func:`_log_flow` surfaces each tool call as - it happens. The test fakes only implement ``invoke``, so they (and the plain path with no - sink) take the invoke branch. + *, + approver: Approver | None = None, + config: dict[str, object] | None = None, + gated: bool = False, +) -> Iterator[SpeechDelta | ToolNotice | ApprovalPause]: + """Stream one turn through the graph token-by-token, yielding speech/tool events. + + Wraps any graph failure as a CLIError (a clean ``CLIError`` passes through) so the + cascade surfaces it instead of the reply worker dying silently. Under ``-v`` the + accumulated assistant text, each tool call, and each tool result are logged to + ``_FLOW_LOG``. When ``gated`` (``--files``), writes pause for ``approver`` (see + :func:`_stream_gated`); otherwise it is a single uninterrupted stream pass. """ + verbose = debuglog.active() + pending: list[str] = [] # assistant deltas accumulated for one verbose "llm:" line + + def flush_log() -> None: + if verbose and pending: + _FLOW_LOG.info("llm: %s", "".join(pending)) + pending.clear() + + if not hasattr(graph, "stream"): + raise CLIError( + "the agent couldn't complete the turn: the agent graph cannot stream", + error_type="agent_brain_error", + ) try: - return _drive_graph(graph, {"messages": conversation}, on_tool) + # The gated path needs stream + get_state (the graph is built with a checkpointer, so it + # always satisfies _GatedGraph); the isinstance both narrows for mypy and falls back to a + # plain stream for the impossible non-gated-graph case. + if gated and isinstance(graph, _GatedGraph): + yield from _stream_gated( + graph, + conversation, + approver, + config, + verbose=verbose, + pending=pending, + flush_log=flush_log, + ) + else: + for chunk, _m in graph.stream( + {"messages": conversation}, config, stream_mode="messages" + ): + yield from _events_from_chunk( + chunk, verbose=verbose, pending=pending, flush_log=flush_log + ) + flush_log() except CLIError: raise except Exception as exc: - # The graph can fail anywhere in the tool loop — a gateway 4xx/5xx, a tool raising, - # a langgraph recursion limit. Convert it to a CLIError so the cascade records and - # *surfaces* it (the engine shows it in the transcript) instead of the reply worker - # dying silently and the user getting no answer with no clue why. raise CLIError( f"the agent couldn't complete the turn: {exc}", error_type="agent_brain_error" ) from exc -def _drive_graph( - graph: CompiledAgent, - graph_input: dict[str, object], - on_tool: Callable[[str], None] | None = None, -) -> dict[str, object]: - """Invoke the graph, or stream it (when a tool sink is wired or under ``-v``) so - :func:`_log_flow` can surface each tool call as it lands.""" - if (on_tool is not None or debuglog.active()) and hasattr(graph, "stream"): - last: dict[str, object] = {} - seen = 0 - for chunk in graph.stream(graph_input, None, stream_mode="values"): - seen = _log_flow(chunk, seen, on_tool) - last = chunk - return last - return graph.invoke(graph_input) - - -def _log_flow( - state: dict[str, object], seen: int, on_tool: Callable[[str], None] | None = None -) -> int: - """Surface the tool calls/results added to ``state`` since the first ``seen`` messages. - - Feeds ``on_tool`` a speakable label as each tool call lands (the live UI's affordance) and, - under ``-v``, logs the call/result/interim line to stderr. Reuses the coding agent's - message→event vocabulary so it reads the same AIMessage/ToolMessage shapes the TUI does. - Returns the new high-water message count so the next snapshot only re-surfaces what it added. +def _stream_gated( + graph: _GatedGraph, + conversation: list[ChatCompletionMessageParam], + approver: Approver | None, + config: dict[str, object] | None, + *, + verbose: bool, + pending: list[str], + flush_log: Callable[[], None], +) -> Iterator[SpeechDelta | ToolNotice | ApprovalPause]: + """Stream a write-gated turn: each pause on a write asks ``approver`` and resumes. + + The graph pauses (before executing a gated write) by ending the ``messages`` stream with + a pending interrupt on the checkpointed state. We surface its action requests, bracket the + human decision with :class:`ApprovalPause` events, and resume with the approve/reject + ``Command`` — looping until the turn finishes without pausing. """ - from aai_cli.code_agent.events import message_events - - messages = state.get("messages") - if not isinstance(messages, list): - return seen - verbose = debuglog.active() - for message in messages[seen:]: - for event in message_events(message, announce_calls=True): - _surface_event(event, on_tool, verbose=verbose) - return len(messages) - - -def _surface_event(event: object, on_tool: Callable[[str], None] | None, *, verbose: bool) -> None: - """Surface one flow event: feed a tool call's label to ``on_tool``, and (under ``-v``) - log the call/result/interim line to stderr.""" - from aai_cli.code_agent.events import AssistantText, ToolCall, ToolResult - - if isinstance(event, ToolCall) and on_tool is not None: - on_tool(_tool_affordance(event.name, event.args)) - if not verbose: + from langgraph.types import Command + + graph_input: object = {"messages": conversation} + while True: + for chunk, _m in graph.stream(graph_input, config, stream_mode="messages"): + yield from _events_from_chunk( + chunk, verbose=verbose, pending=pending, flush_log=flush_log + ) + flush_log() + requests = _pending_writes(graph, config) + if not requests: + return + decisions: list[dict[str, object]] = [] + for request in requests: + yield ApprovalPause(active=True) + decisions.append(_decide(request, approver)) + yield ApprovalPause(active=False) + graph_input = Command(resume={"decisions": decisions}) + + +def _pending_writes( + graph: _GatedGraph, config: dict[str, object] | None +) -> list[dict[str, object]]: + """The action requests of a paused gated write (empty when the turn isn't paused). + + deepagents surfaces an approval pause as ``interrupts`` on the checkpointed state, each + interrupt's ``.value`` carrying the ``action_requests`` (the gated tool calls). + """ + state = graph.get_state(config) + requests: list[dict[str, object]] = [] + for interrupt in getattr(state, "interrupts", ()) or (): + value = getattr(interrupt, "value", None) + actions = value.get("action_requests") if isinstance(value, dict) else None + if isinstance(actions, list): + requests.extend(action for action in actions if isinstance(action, dict)) + return requests + + +def _decide(action: dict[str, object], approver: Approver | None) -> dict[str, object]: + """Ask the approver about one pending write and shape the resume decision (reject if none).""" + name = str(action.get("name", "")) + args = action.get("args") or {} + if not isinstance(args, dict): + args = {} + if approver is not None and approver(name, args): + return {"type": "approve"} + return {"type": "reject", "message": _DECLINED} + + +def _events_from_chunk( + chunk: object, *, verbose: bool, pending: list[str], flush_log: Callable[[], None] +) -> Iterator[SpeechDelta | ToolNotice]: + """Translate one streamed message chunk into speech/tool events (and verbose logs).""" + if type(chunk).__name__ == "ToolMessage": + flush_log() + if verbose: + content = _content_text(getattr(chunk, "content", "")) + _FLOW_LOG.info("tool result %s -> %s", getattr(chunk, "name", ""), _clip(content)) return - if isinstance(event, ToolCall): - _FLOW_LOG.info("tool call %s args=%s", event.name, event.args) - elif isinstance(event, ToolResult): - _FLOW_LOG.info("tool result %s -> %s", event.name, _clip(event.content)) - elif isinstance(event, AssistantText): - _FLOW_LOG.info("llm: %s", event.text) + for call in getattr(chunk, "tool_call_chunks", None) or []: + name = call.get("name") + if name: + flush_log() + if verbose: + _FLOW_LOG.info("tool call %s", name) + yield ToolNotice(_tool_label(name), _tool_fillers(name)) + text = _content_text(getattr(chunk, "content", "")) + if text: + pending.append(text) + yield SpeechDelta(text) def _clip(text: str) -> str: @@ -322,25 +488,6 @@ def _clip(text: str) -> str: return f"{flattened[:_RESULT_LOG_CAP]}… ({len(flattened)} chars)" -def _reply_text(result: dict[str, object]) -> str: - """The agent's final spoken reply: the last assistant message that carries text. - - A tool-using turn ends in an ``AIMessage`` whose ``content`` is the spoken answer, - but earlier ``AIMessage``\\s in the same turn (the tool-call requests) have empty - text — so we scan from the end for the last one with non-empty content. - """ - messages = result.get("messages") - if not isinstance(messages, list): - return "" - for message in reversed(messages): - if type(message).__name__ != "AIMessage": - continue - text = _content_text(getattr(message, "content", "")).strip() - if text: - return text - return "" - - def _content_text(content: object) -> str: """Coerce a message's content (a string, or a list of content blocks) to plain text.""" if isinstance(content, str): diff --git a/aai_cli/agent_cascade/config.py b/aai_cli/agent_cascade/config.py index bce18fc7..649d18f3 100644 --- a/aai_cli/agent_cascade/config.py +++ b/aai_cli/agent_cascade/config.py @@ -16,18 +16,25 @@ # `assembly live` defaults to a fast, low-latency gateway model (override with --model) — # a literal rather than llm.DEFAULT_MODEL so the live agent's default is independent of the # one-shot `assembly llm` default. Latency matters most for a spoken back-and-forth. -DEFAULT_MODEL = "claude-haiku-4-5-20251001" +DEFAULT_MODEL = "kimi-k2.5" DEFAULT_MAX_TOKENS = llm.DEFAULT_MAX_TOKENS # The realtime model the cascade transcribes with (same as the agent-cascade template). -DEFAULT_SPEECH_MODEL = "u3-rt-pro" +DEFAULT_SPEECH_MODEL = "universal-3-5-pro" DEFAULT_SYSTEM_PROMPT = ( - "You are a friendly, concise voice assistant. Keep replies short and " - "conversational. Your reply is read aloud by a text-to-speech engine, so " - "write plain spoken prose — no markdown, emoji, bullet lists, or code." + "You are a friendly, concise voice assistant. Keep replies as short as " + "possible — usually a single sentence, never more than two. Answer directly " + "without preamble or filler. Your reply is read aloud by a text-to-speech " + "engine, so write plain spoken prose — no markdown, emoji, bullet lists, or code." ) DEFAULT_GREETING = "Hi! I'm your AssemblyAI voice agent. What can I help you with?" # Sliding-window size: keep the last N messages of conversation as LLM context. DEFAULT_MAX_HISTORY = 40 +# Per-turn cap on how many tool calls the deepagents brain may make before it must answer. +# Enforced by a ToolCallLimitMiddleware with exit_behavior="continue": once the budget is hit, +# further tool calls are blocked and the model is forced to answer with what it has gathered — +# a graceful stop, never a GraphRecursionError. (langgraph's own recursion_limit stays at the +# deepagents default as a far-off safety backstop; this middleware is the real, soft cap.) +DEFAULT_TOOL_CALL_LIMIT = 10 @dataclass(frozen=True) @@ -39,6 +46,9 @@ class CascadeConfig: greeting: str = DEFAULT_GREETING model: str = DEFAULT_MODEL max_history: int = DEFAULT_MAX_HISTORY + # Per-turn tool-call budget: after this many tool calls the brain is forced to answer with + # what it has (a graceful stop), rather than looping until langgraph's recursion backstop errors. + tool_call_limit: int = DEFAULT_TOOL_CALL_LIMIT # TTS language (None lets the server pick from the voice). language: str | None = None # LLM: cap per-reply tokens and pass through any extra gateway request fields. @@ -55,3 +65,7 @@ class CascadeConfig: # Whether STT formats finalized turns. The reply trigger waits for the formatted # turn when on; with it off, an unformatted end-of-turn is the cue instead. format_turns: bool = True + # Opt-in: let the agent read/write files in the launch directory. Off by default keeps + # behavior unchanged (the default in-memory backend, no gating, nothing advertised); on + # swaps to a real-cwd FilesystemBackend and gates writes behind human approval. + files: bool = False diff --git a/aai_cli/agent_cascade/datetime_tool.py b/aai_cli/agent_cascade/datetime_tool.py new file mode 100644 index 00000000..f9df3fa4 --- /dev/null +++ b/aai_cli/agent_cascade/datetime_tool.py @@ -0,0 +1,54 @@ +"""A keyless local date/time tool for the `assembly live` voice agent. + +Reports the current local date and time so the live agent can answer "what time is +it?", "what's today's date?", or "what day is it?". It needs no network and no API +key — just the system clock — making it, like the weather tool, always present. + +The only non-determinism is the :data:`Clock` seam (a ``() -> datetime`` callable), +injected in tests so the flow is deterministic with no real clock. Everything else +(the spoken formatting) is pure and tested directly. There is no failure mode to +handle: reading the local clock cannot fail, so the tool returns unconditionally. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +# The registered tool name. ``brain.py`` keys its UI label and capability phrase off +# this, so a test pins it. +DATETIME_TOOL_NAME = "get_current_datetime" + +# A clock returns the current instant. Injected in tests (the only non-determinism). +Clock = Callable[[], datetime] + + +def _now() -> datetime: + """Return the current local time as a timezone-aware datetime (the default clock).""" + return datetime.now().astimezone() + + +def _format(now: datetime) -> str: + """Render ``now`` as one short, speakable date+time string. + + Uses only cross-platform ``strftime`` codes (no ``%-d``/``%-I``, which break on + Windows). Zero-padded day/hour is fine — the model reads the string aloud. + """ + return now.strftime("It's %A, %B %d, %Y at %I:%M %p %Z.") + + +def build_datetime_tool(now: Clock = _now) -> BaseTool: + """Wrap the local clock as the ``get_current_datetime`` tool (``now`` injectable).""" + from langchain_core.tools import tool + + @tool(DATETIME_TOOL_NAME) + def get_current_datetime() -> str: + """Get the current local date and time. Use when asked the date, the day of the + week, or the time.""" + return _format(now()) + + return get_current_datetime diff --git a/aai_cli/agent_cascade/engine.py b/aai_cli/agent_cascade/engine.py index 9c940f8d..494cb7ac 100644 --- a/aai_cli/agent_cascade/engine.py +++ b/aai_cli/agent_cascade/engine.py @@ -12,140 +12,61 @@ from __future__ import annotations import contextlib +import queue import threading -from abc import abstractmethod -from collections.abc import Callable, Iterable +import time +from collections.abc import Callable from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING from aai_cli.agent_cascade import brain +from aai_cli.agent_cascade._io import CascadeDeps, Player, Renderer +from aai_cli.agent_cascade._runtime import ( + REPLY_TIMEOUT_SECONDS as _REPLY_TIMEOUT_SECONDS, +) +from aai_cli.agent_cascade._runtime import ( + Done as _Done, +) +from aai_cli.agent_cascade._runtime import ( + Failure as _Failure, +) +from aai_cli.agent_cascade._runtime import ( + ReplyEvent as _ReplyEvent, +) +from aai_cli.agent_cascade._runtime import ( + Timeout as _Timeout, +) +from aai_cli.agent_cascade._runtime import ( + Worker as _Worker, +) +from aai_cli.agent_cascade._runtime import ( + detach_executor_threads_since as _detach_executor_threads_since, +) +from aai_cli.agent_cascade._runtime import ( + executor_threads as _executor_threads, +) +from aai_cli.agent_cascade._runtime import ( + new_history as _new_history, +) +from aai_cli.agent_cascade._runtime import ( + timeout_error as _timeout_error, +) from aai_cli.agent_cascade.config import CascadeConfig -from aai_cli.agent_cascade.text import split_sentences, trim_history -from aai_cli.core import client +from aai_cli.agent_cascade.text import pop_clauses, trim_history from aai_cli.core.errors import CLIError -from aai_cli.tts import session as tts_session -from aai_cli.tts.session import SpeakConfig from aai_cli.ui import output if TYPE_CHECKING: - from assemblyai.streaming.v3 import StreamingParameters from openai.types.chat import ChatCompletionMessageParam -# Streaming TTS synthesizes at 24 kHz, the rate the live player is opened at. -TTS_SAMPLE_RATE = 24000 +# engine is the cascade's public seam: it owns CascadeSession/run_cascade and deliberately +# re-exports the injection protocols that moved to _io (CascadeDeps/Renderer/Player), so callers +# keep importing them from here. __all__ marks the re-exports as explicit (mypy --no-implicit-reexport). +__all__ = ["CascadeDeps", "CascadeSession", "Player", "Renderer", "run_cascade"] - -class _Worker(Protocol): - """The slice of a thread the session drives: started already, queryable, joinable.""" - - @abstractmethod - def is_alive(self) -> bool: - """Whether the reply worker is still running.""" - - def join(self) -> None: - """Block until the reply worker finishes.""" - - -class Renderer(Protocol): - """The conversation-rendering surface the cascade drives (AgentRenderer satisfies it).""" - - def connected(self) -> None: - """Announce the session is live and listening.""" - - def user_partial(self, text: str) -> None: - """Show an interim user transcript.""" - - def user_final(self, text: str) -> None: - """Show a finalized user transcript.""" - - def tool_call(self, label: str) -> None: - """Show that the agent is using a tool (e.g. "Searching the web") while it thinks.""" - - def reply_started(self) -> None: - """Mark the start of an agent reply.""" - - def agent_transcript(self, text: str, *, interrupted: bool) -> None: - """Show a line of the agent's reply.""" - - def reply_done(self, *, interrupted: bool) -> None: - """Mark the end of an agent reply.""" - - -class Player(Protocol): - """The speaker the cascade enqueues TTS audio into (DuplexAudio/NullPlayer satisfy it).""" - - def start(self) -> None: - """Open the output stream.""" - - def enqueue(self, pcm: bytes) -> None: - """Queue PCM audio for playback.""" - - def flush(self) -> None: - """Drop any queued-but-unplayed audio (used on barge-in).""" - - def pending(self) -> int: - """How many unplayed samples are still queued (>0 while audio is audibly playing).""" - ... - - def close(self) -> None: - """Close the output stream.""" - - -def _new_history() -> list[ChatCompletionMessageParam]: - """Typed empty-history factory (ChatCompletionMessageParam is import-time-only).""" - return [] - - -def _spawn_thread(target: Callable[[], None]) -> _Worker: - """Start ``target`` on a daemon thread so a reply is generated without blocking - the STT reader (which must stay free to detect a barge-in).""" - thread = threading.Thread(target=target, daemon=True) # pragma: no mutate - thread.start() - return thread - - -@dataclass -class CascadeDeps: - """The cascade's three network legs plus its thread spawner, all injectable. - - ``CascadeDeps.real`` wires the live STT/LLM/TTS clients; tests pass fakes with - the same shapes (and a synchronous ``spawn``) to drive the orchestration. - """ - - run_stt: Callable[[Callable[[object], None]], None] - # complete_reply(messages, on_tool=None) -> spoken text; on_tool is fed a label per tool - # call so the front-end can show a "Searching the web…" affordance (brain.build_completer). - complete_reply: Callable[..., str] - synthesize: Callable[[str], bytes] - spawn: Callable[[Callable[[], None]], _Worker] = _spawn_thread - - @classmethod - def real( - cls, - api_key: str, - config: CascadeConfig, - *, - audio: Iterable[bytes], - stt_params: StreamingParameters, - ) -> CascadeDeps: - def run_stt(on_turn: Callable[[object], None]) -> None: - client.stream_audio(api_key, audio, params=stt_params, on_turn=on_turn) - - # The LLM leg is a deepagents graph (web search / URL fetch / docs tools), not a - # single completion, so a spoken turn can transparently use tools. - complete_reply = brain.build_completer(api_key, config) - - def synthesize(text: str) -> bytes: - spec = SpeakConfig( - text=text, - voice=config.voice, - language=config.language, - sample_rate=TTS_SAMPLE_RATE, - extra=config.tts_extra, - ) - return tts_session.synthesize(api_key, spec).pcm - - return cls(run_stt=run_stt, complete_reply=complete_reply, synthesize=synthesize) +# A clause is flushed to TTS on a soft separator (comma/semicolon/colon) only once it is at +# least this long, so we don't synthesize a choppy two-word fragment. Pinned by a text test. +_MIN_CLAUSE_CHARS = 25 @dataclass @@ -160,8 +81,28 @@ class CascadeSession: # First leg failure (LLM/TTS). Recorded on the reply worker thread, where raising # would dump a thread traceback, and re-raised from the main thread to fail cleanly. error: CLIError | None = None + # Routes a spoken approval during a --files pause (the live TUI's submit_voice_approval); None + # on the keyboard-only/headless paths, where a spoken transcript can't answer the gate. + on_approval_voice: Callable[[str], None] | None = None _reply: _Worker | None = field(default=None, init=False) # pragma: no mutate _stop: threading.Event = field(default_factory=threading.Event, init=False) # pragma: no mutate + # Set while a --files write/run awaits approval: the next final transcript answers the gate + # (voice) instead of starting a new turn. Armed/cleared by _consume on the ApprovalPause events. + _awaiting_approval: threading.Event = field( + default_factory=threading.Event, + init=False, # pragma: no mutate + ) + # Set only while a reply is in its audible speak-and-enqueue phase (not while it's still + # *thinking* — generating in a blocking graph call). A UI interrupt keys off this so Ctrl-C + # can quit while the agent thinks instead of being swallowed by a no-op "interrupt". + _speaking: threading.Event = field( + default_factory=threading.Event, init=False + ) # pragma: no mutate + # Rotates the per-tool spoken fillers across turns (fillers[_filler_index % len]) so the same + # tool doesn't repeat one phrase. The rotation test pins the exact phrase sequence, so a shifted + # default or mutated increment is caught; the field's `init=` is equivalent (never constructed + # positionally), like the sibling fields, hence the pragma. + _filler_index: int = field(default=0, init=False) # pragma: no mutate def greet(self) -> None: """Speak the opening greeting (if any) and seed it into the history so the @@ -172,7 +113,7 @@ def greet(self) -> None: self.history.append({"role": "assistant", "content": greeting}) self.renderer.agent_transcript(greeting, interrupted=False) try: - self.player.enqueue(self.deps.synthesize(greeting)) + self.deps.synthesize(greeting, self.player.enqueue) except CLIError as exc: self._record_error(exc) @@ -185,6 +126,14 @@ def on_turn(self, event: object) -> None: text = (getattr(event, "transcript", "") or "").strip() if not text: return + if self._awaiting_approval.is_set(): + # A --files write/run is waiting on approval: the next *final* transcript answers the + # gate by voice (interim partials are ignored), instead of barging in / starting a turn. + if _is_final_turn(event, format_turns=self.config.format_turns) and ( + self.on_approval_voice is not None + ): + self.on_approval_voice(text) + return if _is_final_turn(event, format_turns=self.config.format_turns): self.renderer.user_final(text) self._barge_in() @@ -195,30 +144,39 @@ def on_turn(self, event: object) -> None: self.renderer.user_partial(text) self._barge_in() - def _silence_if_speaking(self) -> bool: - """Cut the agent off if it's currently audible: signal the worker and flush audio. - - "Speaking" is broader than a live reply worker: it also covers the greeting (enqueued - with no worker) and the *tail* of a reply whose worker has already finished enqueuing - but whose audio is still draining from the player. In every case there is sound to - silence, so a barge-in or an interrupt should cut it — a bare ``_reply.is_alive()`` - check would leave the greeting (and a reply's last sentence) un-interruptible. Setting - the stop flag is harmless when no worker is running (the next ``_start_reply`` clears - it). Returns whether anything was silenced. + def _silence(self, *, audible_only: bool) -> bool: + """Cancel an in-flight reply — signal the worker and flush queued audio — and report + whether anything was cancelled. + + The audible cases are always cancelled: the greeting (enqueued with no worker), a reply + in its speak-and-enqueue phase (``_speaking``), and the *tail* of a reply whose worker + has finished enqueuing but whose audio is still draining (``pending() > 0``). + + ``audible_only`` decides whether the *thinking* phase counts too. A spoken barge-in + passes ``False`` to cancel even a reply still being generated — the user has moved on, + so it must not speak once it lands. A UI interrupt passes ``True`` to leave thinking + alone: there's no audio to cut and the blocking graph call can't observe the stop flag, + so cancelling would be a no-op — and crucially, returning False there lets the TUI's + Ctrl-C fall through to *quit* rather than be swallowed (you could otherwise never + Ctrl-C while the agent thinks). Setting the stop flag is harmless when nothing runs (the + next ``_start_reply`` clears it). """ - speaking = (self._reply is not None and self._reply.is_alive()) or self.player.pending() > 0 - if speaking: + in_flight = self._speaking.is_set() or self.player.pending() > 0 + if not audible_only: + in_flight = in_flight or (self._reply is not None and self._reply.is_alive()) + if in_flight: self._stop.set() self.player.flush() - return speaking + return in_flight def _barge_in(self) -> None: - """Stop whatever the agent is saying (reply, greeting, or a draining tail) and join.""" - self._silence_if_speaking() + """Stop whatever the agent is doing (a thinking or speaking reply, the greeting, or a + draining tail) and join — a new spoken turn supersedes it, thinking included.""" + self._silence(audible_only=False) self._join_reply() def interrupt_reply(self) -> bool: - """Signal an in-flight reply to stop, without waiting for it; True if one was playing. + """Silence a *speaking* reply without waiting for it; True if one was audible. The UI-thread-safe counterpart to a spoken barge-in: the live TUI's Escape/Ctrl-C calls this to silence the agent mid-reply (or mid-greeting) without the user having to @@ -227,8 +185,11 @@ def interrupt_reply(self) -> bool: listening (the STT loop keeps running, so the next spoken turn is handled normally). It deliberately does *not* join the worker — a join from the UI thread would deadlock against the worker's own ``call_from_thread`` render hops. + + It reports False (and does nothing) while the reply is merely *thinking*, so the TUI's + Ctrl-C falls through to quit instead of being swallowed by a no-op interrupt. """ - return self._silence_if_speaking() + return self._silence(audible_only=True) def _join_reply(self) -> None: """Wait for the current reply worker (if any) to unwind, then drop the handle.""" @@ -242,43 +203,212 @@ def _start_reply(self) -> None: self._reply = self.deps.spawn(self._generate_reply) def _generate_reply(self) -> None: - """Stream the LLM reply, speak it sentence-by-sentence, and record what was - actually spoken (so a barge-in still leaves the history alternating).""" + """Stream the LLM reply, speak each clause as it lands, and record what was spoken + (so a barge-in still leaves the history alternating).""" messages: list[ChatCompletionMessageParam] = [ {"role": "system", "content": self.config.system_prompt}, *self.history, ] + events: queue.Queue[_ReplyEvent] = queue.Queue() + before = _executor_threads() + + def produce() -> None: + self._pump(messages, events) + + producer = threading.Thread(target=produce, daemon=True) # pragma: no mutate + producer.start() + spoken: list[str] = [] + tail = self._consume(events, before, spoken) + # On a clean finish ``tail`` is the unspoken remainder to flush as one last clause; on + # any cut (barge-in, TTS/leg failure, timeout) it is None and nothing more is spoken. + if tail is not None and tail.strip(): + self._speak([tail.strip()], spoken) + # Always record what was spoken — even after a mid-turn leg failure — so the history + # stays alternating and the next turn has the partial answer as context. + self._record_spoken(spoken) + self._speaking.clear() + self.renderer.reply_done(interrupted=self._stop.is_set()) + + def _set_awaiting_approval(self, *, active: bool) -> None: + """Arm/disarm the voice-approval gate: while armed, ``on_turn`` routes the next final + transcript to the open write/run approval instead of starting a new turn.""" + if active: + self._awaiting_approval.set() + else: + self._awaiting_approval.clear() + + def _consume( + self, events: queue.Queue[_ReplyEvent], before: set[threading.Thread], spoken: list[str] + ) -> str | None: + """Drain the event queue, speaking each completed clause. Returns the unspoken tail to + flush on a clean finish, or ``None`` if the turn was cut short (a barge-in stop, a TTS + failure, or a leg failure/timeout — which also surfaces the error).""" + deadline: float | None = time.monotonic() + _REPLY_TIMEOUT_SECONDS + buffer = "" + spoke_filler = False # only the FIRST tool call of a turn says a spoken filler + used_tool = False # once a tool ran, hold text unspoken so only the final answer is read + while True: + item = self._next_event(events, deadline, before) + if isinstance(item, _Timeout): + self._surface_error(_timeout_error(), started=self._speaking.is_set()) + return None + if isinstance(item, _Failure): + self._surface_error(item.error, started=self._speaking.is_set()) + return None + if isinstance(item, _Done): + return buffer + if isinstance(item, brain.ApprovalPause): + deadline = _approval_deadline(item) + self._set_awaiting_approval(active=item.active) + continue + if isinstance(item, brain.ToolNotice): + if not self._handle_tool_notice(item, spoke_filler=spoke_filler): + return None + spoke_filler = True + used_tool = True + buffer = "" # drop any unspoken preamble — the answer comes after the tool + continue + if self._stop.is_set(): + return None + # item is a streamed SpeechDelta (every other case returned or continued above). + tail = self._speak_delta(item, buffer, spoken, used_tool=used_tool) + if tail is None: + return None + buffer = tail + + def _speak_delta( + self, item: brain.SpeechDelta, buffer: str, spoken: list[str], *, used_tool: bool + ) -> str | None: + """Fold one streamed delta into the running buffer and speak any completed clauses. + + Before any tool call, clauses stream out as they land (low-latency speech). *After* a tool + call (``used_tool``) the deep agent tends to narrate verbose planning between tool calls; + that text is held in the buffer unspoken and discarded at the next tool call, so only the + final answer — whatever remains buffered when the stream finishes — is ever read aloud. + + Marks the reply as speaking on the first spoken delta (so a UI interrupt can cut it). + Returns the new buffer, or ``None`` if a TTS failure cut the turn (the caller aborts).""" + if used_tool: + return buffer + item.text + self._mark_speaking() + buffer += item.text + chunks, buffer = pop_clauses(buffer, min_chars=_MIN_CLAUSE_CHARS) + if not self._speak(chunks, spoken): + return None + return buffer + + def _handle_tool_notice(self, item: brain.ToolNotice, *, spoke_filler: bool) -> bool: + """Show the tool affordance and, for the *first* tool call of a turn only, say a spoken + filler so a hands-free turn isn't dead air. Chained tool calls (``spoke_filler``) stay + silent. Returns False if the filler failed to synthesize (the caller aborts the turn).""" + self.renderer.tool_call(item.label) + if spoke_filler: + return True + return self._speak_filler(item.fillers) + + def _mark_speaking(self) -> None: + """Mark the reply as audibly speaking on its first audible output — a clause or a tool + filler. Sets ``_speaking`` (so a UI interrupt can cut it) and fires ``reply_started`` once.""" + if not self._speaking.is_set(): + self._speaking.set() + self.renderer.reply_started() + + def _speak_filler(self, fillers: tuple[str, ...]) -> bool: + """Say a short spoken filler ("Let me check") for the first tool call of a turn, so a + hands-free turn isn't dead air while the tool runs. + + Marks the reply speaking (the filler is the start of audible output, so a barge-in during + it is caught), picks the next variant — rotating across turns so the same tool doesn't + repeat one phrase — and feeds it to the player through the same ``_stop``-respecting path a + clause uses. Unlike :meth:`_speak`, the filler is conversational glue, not part of the + answer, so it is *never* recorded to ``spoken``/history. Returns False if synthesizing it + failed (the caller aborts the turn, same as a clause that can't synthesize), True otherwise. + """ + self._mark_speaking() + text = fillers[self._filler_index % len(fillers)] + self._filler_index += 1 try: - reply = self.deps.complete_reply(messages, on_tool=self.renderer.tool_call) + self.deps.synthesize(text, self._feed) except CLIError as exc: - # The reply leg failed (gateway/tool/graph error, now converted to a CLIError in - # brain._run_graph). Show it in the transcript so the turn doesn't just vanish — - # the user sees *why* there was no answer instead of silence. self._record_error(exc) - self.renderer.reply_started() - self.renderer.agent_transcript(f"(error: {exc.message})", interrupted=False) - self.renderer.reply_done(interrupted=False) - return - self.renderer.reply_started() - spoken: list[str] = [] - for sentence in split_sentences(reply): - if self._stop.is_set(): - break - self.renderer.agent_transcript(sentence, interrupted=False) + return False + return True + + def _next_event( + self, + events: queue.Queue[_ReplyEvent], + deadline: float | None, + before: set[threading.Thread], + ) -> _ReplyEvent | _Timeout: + """Block for the next streamed event until ``deadline`` (monotonic). Returns a + :class:`_Timeout` once the deadline has passed with nothing more arriving, detaching the + orphaned graph executor first so the abandoned producer can't wedge interpreter exit. + + ``deadline is None`` means the turn is paused awaiting human write-approval, so block + with no timeout until the next event (the approval answer) arrives.""" + if deadline is None: + return events.get() + remaining = deadline - time.monotonic() + if remaining > 0: + try: + return events.get(timeout=remaining) + except queue.Empty: + pass + # The producer is still blocked inside the graph's langchain ThreadPoolExecutor; detach + # that orphaned worker so it can't wedge interpreter exit before we surface the timeout. + _detach_executor_threads_since(before) + return _Timeout() + + def _pump( + self, messages: list[ChatCompletionMessageParam], events: queue.Queue[_ReplyEvent] + ) -> None: + """Drive the streaming reply leg on a throwaway thread, forwarding events to the + queue and ending with a _Done (or _Failure on a clean leg error).""" + try: + for event in self.deps.stream_reply(messages): + events.put(event) + events.put(_Done()) + except CLIError as exc: + events.put(_Failure(exc)) + + def _speak(self, chunks: list[str], spoken: list[str]) -> bool: + """Render and synthesize each clause, feeding frames to the player. Returns False when a + TTS failure cut the turn (the caller aborts); True otherwise. A barge-in stop mid-clause + stops appending (the half-heard clause is dropped from the record) and the consumer's own + stop check ends the turn on the next event.""" + for chunk in chunks: + self.renderer.agent_transcript(chunk, interrupted=False) try: - pcm = self.deps.synthesize(sentence) + self.deps.synthesize(chunk, self._feed) except CLIError as exc: self._record_error(exc) - break + return False if self._stop.is_set(): - break + break # barge-in landed: leave this clause unrecorded, let _consume abort + spoken.append(chunk) + return True + + def _feed(self, pcm: bytes) -> None: + """Enqueue one synthesized PCM frame, unless a barge-in has already landed (then the + remaining frames of the in-flight clause are dropped).""" + if not self._stop.is_set(): self.player.enqueue(pcm) - spoken.append(sentence) + + def _record_spoken(self, spoken: list[str]) -> None: + """Append what was actually spoken to the history (kept alternating after a barge-in).""" spoken_text = " ".join(spoken).strip() if spoken_text: self.history.append({"role": "assistant", "content": spoken_text}) trim_history(self.history, self.config.max_history) - self.renderer.reply_done(interrupted=self._stop.is_set()) + + def _surface_error(self, exc: CLIError, *, started: bool) -> None: + """Record a reply-leg failure (LLM/timeout). Before any audio, the error is also shown + inline in the transcript so the turn doesn't vanish; mid-speech it is only recorded (the + spoken text already explains the turn). The caller still finalizes the turn.""" + self._record_error(exc) + if not started: + self.renderer.reply_started() + self.renderer.agent_transcript(f"(error: {exc.message})", interrupted=False) def _record_error(self, exc: CLIError) -> None: """Keep the first leg failure (to re-raise on the main thread) and warn now, @@ -293,6 +423,13 @@ def shutdown(self) -> None: self._join_reply() +def _approval_deadline(pause: brain.ApprovalPause) -> float | None: + """The reply deadline across a write-approval pause: ``None`` (clock suspended) while the + user is deciding on a gated write — a slow y/n keypress must not trip the reply timeout — and + a fresh finite deadline once answered.""" + return None if pause.active else time.monotonic() + _REPLY_TIMEOUT_SECONDS + + def _is_final_turn(event: object, *, format_turns: bool) -> bool: """True for an end-of-turn that's the cue to generate a reply. @@ -312,6 +449,7 @@ def run_cascade( config: CascadeConfig, deps: CascadeDeps, on_session: Callable[[CascadeSession], None] | None = None, + on_approval_voice: Callable[[str], None] | None = None, ) -> None: """Run one terminal cascade conversation until STT closes or the user stops. @@ -321,7 +459,13 @@ def run_cascade( live TUI) can grab a handle to it — e.g. to wire a keyboard interrupt to :meth:`CascadeSession.interrupt_reply`. """ - session = CascadeSession(deps=deps, renderer=renderer, player=player, config=config) + session = CascadeSession( + deps=deps, + renderer=renderer, + player=player, + config=config, + on_approval_voice=on_approval_voice, + ) if on_session is not None: on_session(session) player.start() diff --git a/aai_cli/code_agent/firecrawl_search.py b/aai_cli/agent_cascade/firecrawl_search.py similarity index 100% rename from aai_cli/code_agent/firecrawl_search.py rename to aai_cli/agent_cascade/firecrawl_search.py diff --git a/aai_cli/code_agent/messages.py b/aai_cli/agent_cascade/messages.py similarity index 98% rename from aai_cli/code_agent/messages.py rename to aai_cli/agent_cascade/messages.py index 5dabefbf..66bb8e02 100644 --- a/aai_cli/code_agent/messages.py +++ b/aai_cli/agent_cascade/messages.py @@ -17,7 +17,7 @@ from rich.text import Text from textual.widgets import Static -from aai_cli.code_agent.summarize import summarize_call, summarize_result +from aai_cli.agent_cascade.summarize import summarize_call, summarize_result _DIM = "#8a8f98" # muted gray for tool lines / notes _ERROR = "#f04438" diff --git a/aai_cli/agent_cascade/modals.py b/aai_cli/agent_cascade/modals.py new file mode 100644 index 00000000..6a9df78e --- /dev/null +++ b/aai_cli/agent_cascade/modals.py @@ -0,0 +1,116 @@ +"""Bottom-docked approval modal for the live voice agent TUI. + +Split out of ``tui.py`` to keep each module under the file-length gate. The +``ApprovalScreen`` is a transparent ``ModalScreen`` docked at the bottom, so the +transcript stays visible above it (see the ``ModalScreen { background: transparent }`` +rule in :class:`~aai_cli.agent_cascade.tui.LiveAgentApp`). + +The keyboard path (``y / a / n / e``) is always available; under ``--files`` an open modal +can *also* be resolved by voice (:meth:`ApprovalScreen.try_voice`), the engine routing the +next spoken transcript here so the gate stays hands-free — except destructive commands, which +ignore the spoken answer and require a keypress. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from rich.markup import escape +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Label + +from aai_cli.agent_cascade import banner, risk +from aai_cli.agent_cascade.spoken_approval import spoken_decision +from aai_cli.agent_cascade.summarize import describe_args, full_args + +if TYPE_CHECKING: + from collections.abc import Mapping + + +class ApprovalScreen(ModalScreen[str]): + """A compact, bottom-docked prompt to approve/auto-approve/reject one tool call. + + Keyboard ``y / a / n`` (and ``e`` to expand the args). The transparent background + leaves the transcript visible, and a risky call (``rm -rf``, an internal fetch) + carries a warning. + """ + + DEFAULT_CSS = """ + ApprovalScreen { align: center bottom; background: transparent; } + /* width: 100% (not 1fr) so the box honors its 1-col side margins — a docked 1fr container + ignores horizontal margin and overflows the screen, clipping the right border off-edge. */ + ApprovalScreen #approvalbox { + dock: bottom; width: 100%; height: auto; + border: round #f59e0b; background: #000000; padding: 0 1; margin: 0 1 1 1; + } + ApprovalScreen #approvalbox Label { height: auto; } + """ + BINDINGS: ClassVar = [ + ("y", "approve", "Approve"), + ("a", "auto", "Auto-approve"), + ("n", "reject", "Reject"), + ("e", "expand", "Expand"), + # Escape / Ctrl-C dismiss the modal — declining the tool is the safe cancel. + ("escape,ctrl+c", "reject", "Cancel"), + ] + + def __init__(self, name: str, args: Mapping[str, object]) -> None: + super().__init__() + self._tool_name = name # not _name: that shadows Textual Widget's str|None attr + self._args = args + self._expanded = False # toggled by `e`; collapsed (one-line) by default + # Must start False so the first y/a/n decision dismisses; pinned by + # test_approval_screen_starts_unanswered (and the keyboard pilots). pragma: the mutation + # harness mis-selects covering tests for this Textual __init__ line (false survivor). + self._answered = False # pragma: no mutate — guards against a double dismiss + + def compose(self) -> ComposeResult: + with Vertical(id="approvalbox"): + warning = risk.risk_warning(self._tool_name, self._args) + if warning: + yield Label(f"[b #f04438]⚠ {escape(warning)}[/]", id="approvalwarn") + yield Label(self._detail_markup(), id="approvaldetail") + yield Label( + f"[b #22c55e]y[/] approve [b {banner.BRAND_HEX}]a[/] auto-approve " + "[b #f04438]n[/] reject [b]e[/] expand" + ) + + def _decide(self, decision: str) -> None: + """Dismiss once, whether the answer came by keypress or voice.""" + if self._answered: + return + self._answered = True + self.dismiss(decision) + + def try_voice(self, transcript: str) -> None: + """Resolve this open modal from a spoken transcript (the hands-free path). + + Destructive commands ignore the spoken answer (``spoken_decision`` returns None) so only + a keypress can green-light them; otherwise an unambiguous affirmative approves and + anything else (negation, bare "yes", unrelated speech) rejects — fail-safe. A no-op once + already answered (a keypress won the race).""" + decision = spoken_decision(self._tool_name, self._args, transcript) + if decision is None: + return + self._decide("approve" if decision else "reject") + + def _detail_markup(self) -> str: + """The 'Run tool X?' line — the compact arg, or the full args when expanded.""" + args = full_args(self._args) if self._expanded else describe_args(self._args) + return f"Run tool [b]{escape(self._tool_name)}[/b]? [dim]{escape(args)}[/dim]" + + def action_expand(self) -> None: + """Toggle between the compact identifying arg and the full args (``e``).""" + self._expanded = not self._expanded + self.query_one("#approvaldetail", Label).update(self._detail_markup()) + + def action_approve(self) -> None: + self._decide("approve") + + def action_auto(self) -> None: + self._decide("auto") + + def action_reject(self) -> None: + self._decide("reject") diff --git a/aai_cli/code_agent/model.py b/aai_cli/agent_cascade/model.py similarity index 100% rename from aai_cli/code_agent/model.py rename to aai_cli/agent_cascade/model.py diff --git a/aai_cli/agent_cascade/prompt.py b/aai_cli/agent_cascade/prompt.py new file mode 100644 index 00000000..27c94e80 --- /dev/null +++ b/aai_cli/agent_cascade/prompt.py @@ -0,0 +1,154 @@ +"""System-prompt construction for the live voice agent's deepagents brain. + +Split out of ``brain.py`` to keep each module within the file-length gate. The prompt is +tailored to the tools actually bound, so the model is only ever told about capabilities it +has — advertising a missing tool made it announce an action ("I'll search…") it then couldn't +take, leaving the turn hanging with no answer. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from aai_cli.agent_cascade import datetime_tool, weather_tool, webpage_tool +from aai_cli.agent_cascade.firecrawl_search import WEB_SEARCH_TOOL_NAME + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +# Closes every guidance variant: the reply is spoken, so it must stay short and plain. +_SPOKEN_TAIL = ( + "Your reply is read aloud, so keep it short and spoken — no markdown, lists, code, or raw URLs." +) + +# The persona is user-supplied and can pull against the operational rules — a verbose or +# strongly in-character persona ("a pirate who loves long tales") fights the spoken-brevity and +# honesty guidance. State once that the rules below outrank the persona's *style*, so a chatty +# persona can't override the constraints that keep the spoken agent short and truthful. +_PERSONA_LATCH = ( + "Stay in character, but the rules below override the persona's style when they conflict." +) + +# Advertised when --files is on, so the model knows it can touch the launch directory (and the +# spoken tail still keeps replies short). Writes pause for the user's y/n; reads are immediate. +_FILE_CAPABILITY = ( + "read, write, and search files in your working directory, run code to solve problems " + "and operate on this project, and delegate a bigger job to a helper" +) + +# When the session has *no* tools wired (e.g. no web search and the docs host is +# unreachable), the model must answer from its own knowledge — and crucially must not +# promise an action it can't take. Without this, telling it "you can search the web" while +# no search tool is bound makes it narrate "I'll search for that…" and then stop, so the +# answer never comes (the tool it announced was never actually available to call). +_NO_TOOLS_GUIDANCE = ( + "You have no external tools available, so answer from your own knowledge. Never say " + "you will search the web, look something up, or fetch a page — you can't do any of " + "that, so don't promise it; if a question needs information you don't have, say so " + "briefly instead. For example, say you don't have that handy rather than offering to " + f"look it up and then going quiet. {_SPOKEN_TAIL}" +) + +# Closes the guidance whenever tools are bound: a spoken agent that narrates a success it +# never achieved is worse than one that admits it couldn't, so it must report what the tools +# actually did rather than inventing the result it expected. +_HONESTY_GUIDANCE = ( + "Don't claim you've done something until the tool actually returns; if a tool fails or " + "finds nothing, say so briefly instead of inventing an answer. If a search or lookup comes " + "back empty or thin, try once more with different wording before giving up." +) + +# Added when --files is on: writing files and running code change the user's project and can't +# be undone by speaking, so the model must confirm first and not narrate a change as done +# before it has actually landed. +_FILE_SAFETY_GUIDANCE = ( + "Writing files and running code change this project and can't be undone — confirm out " + "loud before anything destructive or irreversible, and never say a change landed until it has. " + "Read a file before overwriting it, and prefer merging your change into what's there over " + "replacing the whole file unless asked." +) + + +def _join_clause(parts: list[str]) -> str: + """Join capability phrases into a readable clause: ``a``, ``a and b``, ``a, b, and c``.""" + *initial, last = parts + if not initial: + return last + # Oxford comma only once there are three-or-more items (two or more lead the last). + joiner = ", and " if initial[1:] else " and " + return f"{', '.join(initial)}{joiner}{last}" + + +def _tool_capabilities(tools: Sequence[BaseTool]) -> list[str]: + """The spoken-capability phrases backed by present built-in tools. + + The live agent's built-in legs are the keyless Open-Meteo weather tool, the read-a-URL + tool (web page or PDF), and the system-clock date/time tool (all always present) plus + Firecrawl web search (only when ``FIRECRAWL_API_KEY`` is set) — so the prompt advertises + each only when the agent can really do it. Advertising a missing tool made it announce + an action ("I'll search…") it then couldn't take. + """ + names = {tool.name for tool in tools} + capabilities: list[str] = [] + if WEB_SEARCH_TOOL_NAME in names: + capabilities.append("search the web for current or unfamiliar facts") + if weather_tool.WEATHER_TOOL_NAME in names: + capabilities.append("tell someone the current weather and short forecast for a place") + if webpage_tool.READ_URL_TOOL_NAME in names: + capabilities.append("read a web page or PDF you have the URL for") + if datetime_tool.DATETIME_TOOL_NAME in names: + capabilities.append("tell you the current date and time") + return capabilities + + +def _extra_capability(extra_tools: Sequence[BaseTool]) -> str | None: + """The spoken-capability phrase for user-configured MCP tools, listing them by name. + + The deepagents graph already shows the model each tool's schema, so this only has to + name the tools so the guidance doesn't claim "no external tools" when MCP tools are + bound — and so the model knows to reach for them. + """ + names = sorted(tool.name for tool in extra_tools) + if not names: + return None + return f"use your connected tools ({', '.join(names)})" + + +def build_system_prompt( + persona: str, + *, + tools: Sequence[BaseTool], + extra_tools: Sequence[BaseTool] = (), + files: bool = False, +) -> str: + """The live agent's system prompt: the user's persona plus tool guidance. + + The guidance is tailored to the bound tools so the model is only told about + capabilities it actually has — advertising a missing tool (web search without a + ``FIRECRAWL_API_KEY``) made the agent announce an action it then couldn't take, leaving + the turn hanging with no answer. ``tools`` are the built-in legs (web search, URL + fetch, AssemblyAI docs); ``extra_tools`` are user-configured MCP tools, advertised + generically by name. ``files`` advertises the launch-directory read/write capability + (the ``--files`` filesystem tools). With no capabilities at all the model answers from + its own knowledge. Whenever tools are bound the guidance also tells the model to report + tool outcomes honestly (never narrate a success the tool didn't return), and the + ``--files`` path adds a warning to confirm before irreversible writes or code execution. + """ + capabilities = _tool_capabilities(tools) + extra = _extra_capability(extra_tools) + if extra is not None: + capabilities.append(extra) + if files: + capabilities.append(_FILE_CAPABILITY) + if not capabilities: + return f"{persona}\n\n{_PERSONA_LATCH} {_NO_TOOLS_GUIDANCE}" + guidance = ( + f"You can use tools to help answer: {_join_clause(capabilities)}. Reach for a " + "tool when a question needs fresh or external information; answer directly and " + "instantly when you already know. Only offer to do what these tools allow — don't " + f"say you'll search the web or look something up unless it's listed here. {_HONESTY_GUIDANCE}" + ) + if files: + guidance = f"{guidance} {_FILE_SAFETY_GUIDANCE}" + return f"{persona}\n\n{_PERSONA_LATCH} {guidance} {_SPOKEN_TAIL}" diff --git a/aai_cli/code_agent/risk.py b/aai_cli/agent_cascade/risk.py similarity index 94% rename from aai_cli/code_agent/risk.py rename to aai_cli/agent_cascade/risk.py index 6c7b7e8e..84edf0ec 100644 --- a/aai_cli/code_agent/risk.py +++ b/aai_cli/agent_cascade/risk.py @@ -11,7 +11,9 @@ import re from collections.abc import Mapping -from aai_cli.code_agent.fetch_tool import FETCH_TOOL_NAME +# The fetch tool's name, inlined here — its defining module lived in the removed +# `assembly code` agent. Risk scoring is purely advisory. +FETCH_TOOL_NAME = "fetch_url" # Shell fragments that can destroy data, escalate privileges, or pipe a remote script straight # into a shell — the classic "are you sure?" cases. Word-ish boundaries avoid matching inside diff --git a/aai_cli/agent_cascade/sandbox.py b/aai_cli/agent_cascade/sandbox.py new file mode 100644 index 00000000..6cf132df --- /dev/null +++ b/aai_cli/agent_cascade/sandbox.py @@ -0,0 +1,224 @@ +"""OS-sandboxed shell execution for ``assembly live --files``. + +deepagents binds a functional ``execute`` tool only when the backend implements +``SandboxBackendProtocol``. :class:`SandboxedShellBackend` does — but its ``execute`` never +runs an unconfined host shell: it wraps the command in an OS sandbox (``sandbox-exec`` SBPL on +macOS, ``bwrap`` on Linux) that confines writes to cwd, denies the network, and read-denies +credential stores. On any other platform (or with the sandbox binary missing) it refuses and +runs nothing — never a fallback to unconfined execution. The policy renderers are pure and the +subprocess/capability boundaries are injected, so the suite asserts *what we would run* with no +real sandbox. +""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import tempfile +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Literal, Protocol + +from deepagents.backends.local_shell import LocalShellBackend +from deepagents.backends.protocol import ExecuteResponse + +from aai_cli.core.env import child_env + +# Credential dirs/files under $HOME, read-denied precisely on both platforms. +HOME_SECRETS: tuple[str, ...] = (".ssh", ".aws", ".gnupg", ".netrc", ".npmrc") +# Project-local secrets denied for reads even though cwd is otherwise readable. +CWD_READ_DENY: tuple[str, ...] = (".env", ".claude") +# Persistence paths denied for writes even inside cwd. +CWD_WRITE_DENY: tuple[str, ...] = (".git/hooks",) +# Shell rc files denied for writes (only inside the write region when cwd == $HOME). +SHELL_RC: tuple[str, ...] = (".bashrc", ".zshrc", ".profile", ".bash_profile") + + +def render_seatbelt_profile( + cwd: str, + tmp: str, + home: str, + *, + home_secrets: Sequence[str] = HOME_SECRETS, + cwd_read_deny: Sequence[str] = CWD_READ_DENY, + cwd_write_deny: Sequence[str] = CWD_WRITE_DENY, + shell_rc: Sequence[str] = SHELL_RC, +) -> str: + """Render an Apple Seatbelt (SBPL) profile: default-allow reads, deny secrets, writes only + in cwd + tmp, no network. Last-match-wins, so the denies override the broad allows.""" + lines = [ + "(version 1)", + "(deny default)", + "(allow process-exec*)", + "(allow process-fork)", + "(allow file-read*)", + ] + lines.extend(f'(deny file-read* (subpath "{home}/{name}"))' for name in home_secrets) + # .env and .env.* under cwd, denied via regex; .claude/ via subpath. + lines.append(f'(deny file-read* (regex #"^{cwd}/\\.env($|\\.)"))') + lines.extend( + f'(deny file-read* (subpath "{cwd}/{name}"))' for name in cwd_read_deny if name != ".env" + ) + lines.append(f'(allow file-write* (subpath "{cwd}") (subpath "{tmp}"))') + lines.extend(f'(deny file-write* (subpath "{cwd}/{name}"))' for name in cwd_write_deny) + lines.extend(f'(deny file-write* (subpath "{home}/{name}"))' for name in shell_rc) + return "\n".join(lines) + "\n" + + +def build_bwrap_argv( + cwd: str, + tmp: str, + command: str, + home: str, + *, + home_secrets: Sequence[str] = HOME_SECRETS, + cwd_read_deny: Sequence[str] = CWD_READ_DENY, + cwd_write_deny: Sequence[str] = CWD_WRITE_DENY, +) -> list[str]: + """Build a bubblewrap argv: whole FS read-only (default-allow reads), cwd + tmp read-write, + secret stores masked, ``.git/hooks`` read-only, network unshared. Path-based, so in-cwd + secret-file protection is coarser than Seatbelt's globbing (a documented asymmetry); the + directory-level credential stores are masked precisely on both.""" + argv = [ + "bwrap", + "--unshare-all", + "--die-with-parent", + "--ro-bind", + "/", + "/", + "--bind", + cwd, + cwd, + "--bind", + tmp, + tmp, + ] + # Mask credential stores under $HOME (tmpfs hides their contents). + for name in home_secrets: + argv += ["--tmpfs", f"{home}/{name}"] + # Project-local secrets: mask each path (best-effort; coarser than Seatbelt). + for name in cwd_read_deny: + argv += ["--ro-bind", "/dev/null", f"{cwd}/{name}"] + # Block writes to persistence paths inside cwd by re-binding them read-only. + for name in cwd_write_deny: + argv += ["--ro-bind", f"{cwd}/{name}", f"{cwd}/{name}"] + argv += ["--chdir", cwd, "/bin/sh", "-c", command] + return argv + + +Capability = Literal["seatbelt", "bwrap", "none"] + +DEFAULT_TIMEOUT_SECONDS = 120 # pragma: no mutate +MAX_TIMEOUT_SECONDS = 600 # pragma: no mutate +CPU_LIMIT_SECONDS = 60 # pragma: no mutate +ADDRESS_LIMIT_KB = 4_000_000 # pragma: no mutate +_TIMEOUT_EXIT = 124 # conventional timeout exit code + + +def detect_capability( + *, + system: Callable[[], str] = platform.system, + which: Callable[[str], str | None] = shutil.which, +) -> Capability: + """Resolve the sandbox mechanism for this host: ``seatbelt`` (macOS + ``sandbox-exec``), + ``bwrap`` (Linux + ``bwrap``), else ``none`` — the refuse-don't-fall-back signal.""" + name = system() + if name == "Darwin" and which("sandbox-exec"): + return "seatbelt" + if name == "Linux" and which("bwrap"): + return "bwrap" + return "none" + + +class CompletedProcessLike(Protocol): + """The slice of a finished process the backend reads: combined output + exit code.""" + + output: str + returncode: int | None + + +class _Result: + """Concrete :class:`CompletedProcessLike` the default runner returns.""" + + def __init__(self, output: str, returncode: int | None) -> None: + self.output = output + self.returncode = returncode + + +Runner = Callable[[list[str], str, int], CompletedProcessLike] + + +def default_runner(argv: list[str], cwd: str, timeout: int) -> CompletedProcessLike: + """Run ``argv`` with combined output, in ``cwd``, time-bounded, with a minimal child env. + + A timeout returns the partial output + a sentinel exit code (information, not a crash); a + launch failure is left to raise so the caller turns it into an apology string.""" + try: + proc = subprocess.run( + argv, + cwd=cwd, + timeout=timeout, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=child_env(), + check=False, + ) + except subprocess.TimeoutExpired as exc: + out = exc.output or "" + text = out.decode() if isinstance(out, bytes) else out + return _Result(text + f"\n[timed out after {timeout}s]", _TIMEOUT_EXIT) + return _Result(proc.stdout or "", proc.returncode) + + +NO_SANDBOX_MESSAGE = "I can't run code on this system." +LAUNCH_FAILURE_MESSAGE = "I couldn't start a sandbox to run that." + + +def _ulimit_wrap(command: str) -> str: + """Cap CPU + address space so a runaway can't peg the box inside the timeout.""" + return f"ulimit -t {CPU_LIMIT_SECONDS}; ulimit -v {ADDRESS_LIMIT_KB}; {command}" # pragma: no mutate + + +class SandboxedShellBackend(LocalShellBackend): + """A ``LocalShellBackend`` whose ``execute`` runs through an OS sandbox, never the host shell. + + Inherits the cwd-rooted file operations (``read_file``/``write_file``/``edit_file``/``ls``/ + ``glob``/``grep``) unchanged; implementing ``SandboxBackendProtocol`` (via the base) is what + makes deepagents auto-add the ``execute`` tool. The override confines every run to cwd, denies + the network, and refuses outright when no sandbox is available.""" + + def __init__( + self, + *, + root_dir: str, + virtual_mode: bool = True, + runner: Runner | None = None, + capability: Capability | None = None, + tmp: str | None = None, + home: str | None = None, + ) -> None: + super().__init__(root_dir=root_dir, virtual_mode=virtual_mode) + self._runner: Runner = runner or default_runner + self._capability: Capability = capability if capability is not None else detect_capability() + self._tmp = tmp if tmp is not None else tempfile.gettempdir() + self._home = home if home is not None else str(Path("~").expanduser()) + + def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: + """Run ``command`` confined to cwd via the OS sandbox; refuse when none is available.""" + if self._capability == "none": + return ExecuteResponse(output=NO_SANDBOX_MESSAGE, exit_code=None) + cwd = str(self.cwd) + wrapped = _ulimit_wrap(command) + if self._capability == "seatbelt": + profile = render_seatbelt_profile(cwd, self._tmp, self._home) + argv = ["sandbox-exec", "-p", profile, "/bin/sh", "-c", wrapped] + else: + argv = build_bwrap_argv(cwd, self._tmp, wrapped, self._home) + bounded = min(timeout or DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS) + try: + result = self._runner(argv, cwd, bounded) + except (OSError, ValueError, subprocess.SubprocessError): + return ExecuteResponse(output=LAUNCH_FAILURE_MESSAGE, exit_code=None) + return ExecuteResponse(output=result.output, exit_code=result.returncode) diff --git a/aai_cli/agent_cascade/spoken_approval.py b/aai_cli/agent_cascade/spoken_approval.py new file mode 100644 index 00000000..fb57d908 --- /dev/null +++ b/aai_cli/agent_cascade/spoken_approval.py @@ -0,0 +1,57 @@ +"""Spoken-approval grammar for ``assembly live --files`` (the hands-free approval gate). + +During a write/run approval pause the engine may answer the gate with the user's *next spoken +transcript* instead of a keypress. STT is noisy and a mis-heard "yes" must never green-light a +mutation, so this grammar is **fail-safe to reject**: only an unambiguous, action-bearing +affirmative ("approve", "yes, run it", "go ahead and run it") counts as approval. A bare "yes", +any negation, an unrelated utterance, or empty text all read as reject. Pure functions so they +unit-test cleanly; the risk-tier keyboard fallback (destructive commands need a keypress) lives +in the engine, which consults ``risk.py`` before trusting a spoken yes. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Mapping + +from aai_cli.agent_cascade import risk + +# A negation anywhere flips the whole utterance to reject — so "no, don't run it" can't approve +# just because it contains "run it". Checked first, before the affirmative patterns. +_NEGATION = re.compile(r"\b(no|nope|don'?t|do not|stop|cancel|never|reject|deny|wait)\b", re.I) + +# Unambiguous, action-bearing affirmatives. Deliberately excludes bare "yes"/"yeah"/"sure"/"ok", +# which STT confuses with "no"/"go" — approval must carry an explicit action or the word "approve". +_AFFIRMATIVE = re.compile(r"\b(approve|approved|run it|do it|go ahead|go for it)\b", re.I) + + +def interpret_spoken_approval(transcript: str) -> bool: + """True only for an unambiguous spoken approval; everything else is False (fail-safe reject). + + Rejects on any negation, on a bare "yes" (no action word), on unrelated/empty speech — so a + mis-heard token can never approve a mutation. A genuine affirmative ("approve", "yes, run it", + "go ahead and run it") with no negation returns True. + """ + text = transcript or "" + if _NEGATION.search(text): + return False + return bool(_AFFIRMATIVE.search(text)) + + +def spoken_decision( + name: str, + args: Mapping[str, object], + transcript: str, + *, + warn: Callable[[str, Mapping[str, object]], str | None] = risk.risk_warning, +) -> bool | None: + """How a spoken transcript should resolve an open approval: True approve, False reject, or + None *ignore the voice* (the destructive tier — require the keyboard). + + Destructive tier (``risk.risk_warning`` fires, e.g. ``rm -rf``/``sudo``) → None, so an STT + mishearing can never green-light it; the keypress is the only channel. Otherwise the grammar + decides: an unambiguous affirmative approves, everything else rejects (fail-safe). + """ + if warn(name, args) is not None: + return None + return interpret_spoken_approval(transcript) diff --git a/aai_cli/agent_cascade/subagents.py b/aai_cli/agent_cascade/subagents.py new file mode 100644 index 00000000..0d571610 --- /dev/null +++ b/aai_cli/agent_cascade/subagents.py @@ -0,0 +1,34 @@ +"""The general-purpose subagent for ``assembly live --files`` (deepagents' ``task`` tool). + +One subagent the live agent delegates a focused multi-step subtask to. It OMITS ``model`` (so it +inherits the AssemblyAI gateway-bound model — never a ``provider:model`` string) and ``tools`` (so +it inherits the main sandboxed toolset, keeping its ``execute`` OS-confined). Its ``interrupt_on`` +mirrors the main agent's write tools, so the subagent's own mutations prompt through the same +approval loop (verified to surface at the parent gate — see the HITL regression test). +""" + +from __future__ import annotations + +_SYSTEM_PROMPT = ( + "You are a focused coworker handling one delegated subtask in the user's project. Work in the " + "current directory, use the available tools to research or make a contained change, and return " + "a concise, spoken-length summary of what you did or found — not a transcript." +) + + +def general_purpose_subagent(interrupt_on: dict[str, bool]) -> dict[str, object]: + """The ``task`` subagent spec: gateway-bound (no ``model``), full sandboxed tools (no ``tools``), + with ``interrupt_on`` mirroring the caller's write tools so its mutations stay gated. + + ``interrupt_on`` is a parameter (not a local constant) so this module needn't import + ``brain._WRITE_TOOLS`` — that would be a circular import, since ``brain`` imports this. + """ + return { + "name": "general-purpose", + "description": ( + "Delegate a focused multi-step subtask — research, gather context, or implement a " + "contained change — and get back a short summary. Keeps the main voice turn lean." + ), + "system_prompt": _SYSTEM_PROMPT, + "interrupt_on": interrupt_on, + } diff --git a/aai_cli/code_agent/summarize.py b/aai_cli/agent_cascade/summarize.py similarity index 99% rename from aai_cli/code_agent/summarize.py rename to aai_cli/agent_cascade/summarize.py index ecb4a0c7..53ba3c07 100644 --- a/aai_cli/code_agent/summarize.py +++ b/aai_cli/agent_cascade/summarize.py @@ -61,7 +61,7 @@ def full_args(args: Mapping[str, object]) -> str: Values are shown whole (newlines preserved) but each is capped at ``_EXPANDED_VALUE`` so a huge file can't make the modal unbounded; :func:`describe_args` is the collapsed view. """ - lines = [] + lines: list[str] = [] for key, value in args.items(): text = str(value) if len(text) > _EXPANDED_VALUE: diff --git a/aai_cli/agent_cascade/text.py b/aai_cli/agent_cascade/text.py index cfd2226f..e336fc2a 100644 --- a/aai_cli/agent_cascade/text.py +++ b/aai_cli/agent_cascade/text.py @@ -10,6 +10,45 @@ # answer is synthesized; a sentence ends at one of these terminators. _TERMINATORS = ".!?" +# Soft clause separators: a comma/semicolon/colon ends a *speakable* chunk too, but only +# once the pending clause is long enough (see pop_clauses) — flushing "Yes," on its own +# makes choppy TTS. Hard terminators (_TERMINATORS) always end a clause. +_SOFT_SEPARATORS = ",;:" + + +def _is_boundary(text: str, index: int) -> bool: + """True when the char at ``index`` ends a clause: a terminator/separator that is the + last char or is followed by whitespace (so a '.' inside "$3.50" never splits).""" + return index + 1 == len(text) or text[index + 1].isspace() + + +def pop_clauses(buffer: str, *, min_chars: int) -> tuple[list[str], str]: + """Pull complete speakable clauses off the front of ``buffer`` for incremental TTS. + + A hard terminator (``.``/``!``/``?``) followed by whitespace (or end-of-buffer) always + ends a clause; a soft separator (``,``/``;``/``:``) ends one only when the clause built + since the last boundary is at least ``min_chars`` long, so a tiny fragment ("Yes,") + isn't synthesized on its own. Returns the flushed clauses (each stripped, never blank) + and the still-incomplete remainder to keep buffering. The caller flushes the final tail + at end-of-stream. + """ + clauses: list[str] = [] + start = 0 + for index, char in enumerate(buffer): + is_hard = char in _TERMINATORS + is_soft = char in _SOFT_SEPARATORS + if not (is_hard or is_soft) or not _is_boundary(buffer, index): + continue + # +1/+2 are equivalent here: _is_boundary guarantees text[index+1] is whitespace or EOF, + # so any extra char a +2 would include is stripped off the clause anyway. + clause = buffer[start : index + 1].strip() # pragma: no mutate + if is_soft and len(clause) < min_chars: + continue # too short to speak on its own — keep accumulating + if clause: + clauses.append(clause) + start = index + 1 + return clauses, buffer[start:] + def split_sentences(text: str) -> list[str]: """Split ``text`` into sentences, each ending in ``.``/``!``/``?``. diff --git a/aai_cli/agent_cascade/tui.py b/aai_cli/agent_cascade/tui.py index 3051928e..d807b468 100644 --- a/aai_cli/agent_cascade/tui.py +++ b/aai_cli/agent_cascade/tui.py @@ -15,21 +15,24 @@ import contextlib import itertools +import threading from typing import TYPE_CHECKING, ClassVar from textual.app import App, ComposeResult from textual.containers import VerticalScroll from textual.css.query import NoMatches +from textual.screen import ModalScreen from textual.widgets import Static -from aai_cli.code_agent import banner, tui_status -from aai_cli.code_agent.messages import ( +from aai_cli.agent_cascade import banner, tui_status +from aai_cli.agent_cascade.messages import ( AssistantMessage, ErrorMessage, Note, ToolAffordance, UserMessage, ) +from aai_cli.agent_cascade.modals import ApprovalScreen from aai_cli.core.errors import CLIError if TYPE_CHECKING: @@ -42,8 +45,8 @@ # Splash intro copy (the code agent's banner copy is code-specific, so `live` carries its own). _READY_LINE = "Listening… start talking when you're ready." _TIP_LINE = "Use headphones — the mic stays open while the agent speaks." -# The one-line footer: a hands-free session, so the controls are interrupt-and-quit. -_STATUS_LINE = "Esc/Ctrl-C to interrupt · Ctrl-Q to quit" +# The one-line footer: Space starts/stops listening (mutes the mic), Esc/Ctrl-C interrupts. +_STATUS_LINE = "Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit" def _call_on_ui_thread(app: App[None], fn: Callable[..., None], *args: object) -> None: @@ -111,6 +114,9 @@ class LiveAgentApp(App[None]): #status {{ dock: bottom; height: 1; background: #000000; padding: 0 1; }} /* Blank line above each agent reply (and the greeting), so turns don't run together. */ AssistantMessage {{ margin-top: 1; }} + /* The --files write-approval modal docks at the bottom and stays see-through, so the + transcript shows above it (overriding ModalScreen's opaque DEFAULT_CSS). */ + ModalScreen {{ background: transparent; }} /* First tool line of a turn keeps a top margin (lifts the block off the prompt); a consecutive call adds `-tight` to drop it, so a multi-tool turn stays compact. */ ToolAffordance {{ margin-top: 1; }} @@ -124,6 +130,7 @@ class LiveAgentApp(App[None]): # hatch, so a stuck reply can never trap the session). Quitting closes the audio, which # unblocks the cascade worker. BINDINGS: ClassVar = [ + ("space", "toggle_listen", "Start / stop listening"), ("escape", "interrupt", "Interrupt"), ("ctrl+c", "interrupt_or_quit", "Interrupt / Quit"), ("ctrl+q", "stop", "Quit"), @@ -134,15 +141,24 @@ def __init__( *, run_conversation: Callable[[Renderer], None], on_stop: Callable[[], None], + on_toggle_listen: Callable[[], bool], web_note: str | None = None, ) -> None: super().__init__() self._run_conversation = run_conversation # blocking; runs the cascade given a Renderer self._on_stop = on_stop # closes the audio so a quit unblocks the cascade worker + # Mutes/unmutes the mic in place (returns the new listening state); Space toggles it. + self._on_toggle_listen = on_toggle_listen + self._listening = True # mic open by default; muted shows the bar as "paused" self._web_note = web_note # The cascade's reply-interrupt, wired once its session exists (see set_interrupt); # None until then, so an early keypress is a harmless no-op. self._interrupt: Callable[[], bool] | None = None + # Set once the user picks "auto" on a --files write prompt; later writes then skip the modal. + self._auto_approve_writes = False + # The currently-open approval modal, so the engine can resolve it by voice (None when no + # write is awaiting a decision); see submit_voice_approval. + self._approval_screen: ApprovalScreen | None = None self._voice_phase = "listening" self._voice_frames = itertools.cycle(tui_status.VOICE_FRAMES) self._voice_timer: Timer | None = None @@ -234,10 +250,20 @@ def show_tool_call(self, label: str) -> None: self._scroll_end() def begin_reply(self) -> None: - """Open a fresh reply widget the agent's sentences stream into; switch to speaking.""" + """Start a fresh reply: drop the previous reply widget and switch to the speaking phase. + The new widget is *not* mounted here — it is created lazily on the first streamed sentence + (:meth:`show_agent_sentence`), so it always lands *after* the turn's tool affordances. + + Clearing ``_reply_msg`` is what makes the next sentence open a new widget rather than + appending to the last one. That matters because the greeting also streams through + ``show_agent_sentence`` (with no ``reply_done`` after it), so without this reset the first + turn's answer would be appended to the greeting line. ``reply_started`` fires on the turn's + first audible output — for a tool-using turn that's the spoken filler *during the first + tool call* — so mounting eagerly here would wedge an empty widget above the later tool + lines and stream the answer into it; deferring the mount keeps the answer below them. + """ + self._reply_msg = None self._set_phase("speaking") - self._reply_msg = AssistantMessage() - self._mount(self._reply_msg) def show_agent_sentence(self, text: str) -> None: """Append one spoken sentence to the in-flight reply.""" @@ -276,7 +302,18 @@ def _render_voicebar(self) -> None: bar = self.query_one("#voicebar", Static) except NoMatches: return - bar.update(tui_status.voicebar_markup(self._voice_phase, next(self._voice_frames))) + bar.update(tui_status.voicebar_markup(self._display_phase(), next(self._voice_frames))) + + def _display_phase(self) -> str: + """The phase the voice bar shows: ``paused`` when the mic is muted while idle. + + A muted mic would otherwise sit on ``listening`` hearing nothing, so it reads as + paused instead. A reply still in flight keeps ``speaking``/``thinking`` — muting + gates the user's input, never the agent's voice. + """ + if self._voice_phase == "listening" and not self._listening: + return "paused" + return self._voice_phase def _tick_voice(self) -> None: """Advance the voice-bar meter one frame (the animation timer's callback).""" @@ -304,6 +341,50 @@ def _scroll_end(self) -> None: # --- interrupt / quit ----------------------------------------------------- + def _modal_result[T](self, screen: ModalScreen[T], default: T) -> T: + """Push a modal from the cascade worker thread and block until it's dismissed.""" + done = threading.Event() + box: dict[str, T] = {"value": default} + + def _store(result: T | None) -> None: + if result is not None: + box["value"] = result + done.set() + + self.call_from_thread(self.push_screen, screen, _store) + done.wait() + return box["value"] + + def approve_write(self, name: str, args: dict[str, object]) -> bool: + """Decide a gated --files write by a y/n keypress; True to allow. + + Called on the cascade worker thread (via the brain's approver). Blocks on a bottom-docked + approval modal — the one place the hands-free session pauses for the keyboard. "Auto" + approves every later write this session, so a multi-file edit isn't a y per file. + """ + if self._auto_approve_writes: + return True + screen = ApprovalScreen(name, args) + self._approval_screen = screen # let the engine resolve it by voice while it's open + try: + decision = self._modal_result(screen, default="reject") + finally: + self._approval_screen = None + if decision == "auto": + self._auto_approve_writes = True + return True + return decision == "approve" + + def submit_voice_approval(self, transcript: str) -> None: + """Resolve an open --files approval modal from a spoken transcript (no-op if none is open). + + The engine routes the next final transcript here during an approval pause; the modal's + own ``try_voice`` applies the grammar (and ignores voice for destructive commands). Hops + to the UI thread since the engine calls this from the STT reader thread.""" + screen = self._approval_screen + if screen is not None: + self.call_from_thread(screen.try_voice, transcript) + def set_interrupt(self, interrupt: Callable[[], bool]) -> None: """Wire the session's reply-interrupt once the cascade has built its session. @@ -312,6 +393,15 @@ def set_interrupt(self, interrupt: Callable[[], bool]) -> None: """ self._interrupt = interrupt + def action_toggle_listen(self) -> None: + """Space: start/stop listening by muting the mic in place, keeping the session live. + + The cascade stays connected while muted (the agent can still finish a reply), so + resuming is instant — no reconnect. Repaints the voice bar to reflect the new state. + """ + self._listening = self._on_toggle_listen() + self._render_voicebar() + def action_interrupt(self) -> None: """Escape: silence a playing reply and return to listening (a no-op when idle).""" self._do_interrupt() diff --git a/aai_cli/code_agent/tui_status.py b/aai_cli/agent_cascade/tui_status.py similarity index 58% rename from aai_cli/code_agent/tui_status.py rename to aai_cli/agent_cascade/tui_status.py index 95fca7ae..542d0930 100644 --- a/aai_cli/code_agent/tui_status.py +++ b/aai_cli/agent_cascade/tui_status.py @@ -1,4 +1,4 @@ -"""Pure text helpers for the coding-agent TUI's status line and working indicator. +"""Pure text helpers for the live voice-agent TUI's voice bar and key legend. Split out of `tui.py` (to keep it under the file-length gate) and free of any Textual imports, so they unit-test as plain functions. @@ -6,7 +6,6 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING import pyperclip @@ -20,12 +19,17 @@ # Animated meter for the voice bar — a 3-cell block-char pulse (BMP, single-width, no emoji). # Public: both the `code` and `live` TUIs cycle it for their bar animation. VOICE_FRAMES = ("▁▃▅", "▃▅▇", "▅▇▆", "▆▇▅", "▇▅▃", "▅▃▁") # pragma: no mutate +# The at-rest meter shown while paused: a flat, non-animating frame (same width/alphabet as +# VOICE_FRAMES) so a muted mic reads as idle rather than as an active, pulsing meter. +VOICE_FLAT = "▁▁▁" # The voice phases the bar distinguishes, each (label, accent color). Shared by the `code` # and `live` TUIs so both read the same: blue while listening, amber thinking, green speaking. _VOICE_PHASES: dict[str, tuple[str, str]] = { "listening": ("Listening — speak your request", theme.BRAND), "thinking": ("Thinking…", "#f59e0b"), "speaking": ("Speaking…", "#22c55e"), + # `live`'s mic is muted (start/stop listening) — dimmed so a paused session reads as idle. + "paused": ("Paused — press space to resume listening", "#6b7280"), } @@ -36,14 +40,11 @@ def voicebar_markup(phase: str, frame: str, *, hint: str = "") -> str: label is escaped so a phase string can't inject styling. """ label, color = _VOICE_PHASES[phase] + if phase == "paused": + frame = VOICE_FLAT # a muted mic shows a flat meter, not the animated pulse it was handed return f"[{color}]{frame}[/] {escape(label)}{hint}" -def _spinner_text(elapsed_s: int, frame: str) -> str: - """The working-indicator line: a spinner glyph and the elapsed seconds.""" - return f"{frame} Working… ({elapsed_s}s)" - - def keyhints_text(*, voice: bool) -> str: """The dim key-legend footer for the `code` TUI — the shortcuts worth surfacing. @@ -74,42 +75,3 @@ def copy_note(reply: str, copier: Callable[[str], None]) -> str: except pyperclip.PyperclipException: return "(couldn't copy: no clipboard available)" return "(copied last reply to clipboard)" - - -def _abbrev_home(path: Path) -> str: - """Render ``path`` with the home directory collapsed to ``~``.""" - try: - return f"~/{path.relative_to(Path.home())}" - except ValueError: - return str(path) - - -def _git_branch(start: Path) -> str | None: - """The current git branch for ``start`` (walking up to the repo root), or None.""" - for directory in (start, *start.parents): - head = directory / ".git" / "HEAD" - if head.is_file(): - ref = head.read_text(encoding="utf-8").strip() - return ref.removeprefix("ref: refs/heads/") if ref.startswith("ref: ") else ref[:8] - return None - - -def _status_text(cwd: Path, *, auto_approve: bool, voice_state: str | None = None) -> str: - """The two-row bottom footer: a status line, and a dim key-legend beneath it. - - Row one is a mode badge, the working directory, the git branch, and voice state; row two - is :func:`keyhints_text`. ``voice_state`` is ``"on"``/``"off"`` when the session has a - voice front-end (so the Ctrl-V toggle shows its effect, and the legend lists it), or - ``None`` when voice isn't wired up at all. - """ - mode = "auto" if auto_approve else "manual" - badge = f"[black on #f59e0b] {mode} [/]" - parts = [badge, f"[dim]{_abbrev_home(cwd)}[/dim]"] - branch = _git_branch(cwd) - if branch: - parts.append(f"[dim]↗ {branch}[/dim]") - if voice_state is not None: - # A filled/hollow dot (BMP glyphs, like the rest of the UI — no double-width emoji). - glyph, color = ("●", "#22c55e") if voice_state == "on" else ("○", "#6b7280") - parts.append(f"[{color}]{glyph} voice {voice_state}[/]") - return " ".join(parts) + "\n" + keyhints_text(voice=voice_state is not None) diff --git a/aai_cli/agent_cascade/weather_tool.py b/aai_cli/agent_cascade/weather_tool.py new file mode 100644 index 00000000..10589859 --- /dev/null +++ b/aai_cli/agent_cascade/weather_tool.py @@ -0,0 +1,222 @@ +"""A keyless live-weather tool for the `assembly live` voice agent. + +Backed by Open-Meteo, which needs no API key — so unlike the optional Firecrawl +search, this tool is *always* present, giving every live session at least one real +capability. The flow is geocode (place name -> coordinates) -> forecast (current + +a short daily outlook) -> a single short string the agent reads aloud. + +The only network seam is :data:`Fetcher` (a ``url -> parsed JSON`` callable), +injected in tests so the whole flow runs with no sockets — the same shape +other URL-fetch tools in the live agent use. Everything else (the WMO-code text, the spoken +formatting) is pure and tested directly. Failures never raise out to the graph: +``get_weather`` catches them and returns a short spoken apology so a weather +outage can't sink a live turn. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +from aai_cli.core import jsonshape + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +# The registered tool name. ``brain.py`` detects weather availability and labels the +# live-UI affordance by this name, so a test pins it. +WEATHER_TOOL_NAME = "get_weather" + +# A fetcher GETs a URL and returns parsed JSON. Injected in tests (the only net seam). +# This is the same pattern used by the URL-fetch tools in the live agent. +Fetcher = Callable[[str], object] + +_GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" +_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +_TIMEOUT = 15.0 # pragma: no mutate — a tuning knob; ±a few seconds is equivalent +_FORECAST_DAYS = 3 # today + the next two days (the two spoken outlook lines) + +# WMO weather-interpretation codes -> short spoken phrases. A code not listed here +# (Open-Meteo can add more) falls back in :func:`describe_weather_code` rather than +# raising, so an unfamiliar code never sinks a turn. +_WMO_DESCRIPTIONS: dict[int, str] = { + 0: "clear sky", + 1: "mainly clear", + 2: "partly cloudy", + 3: "overcast", + 45: "fog", + 48: "freezing fog", + 51: "light drizzle", + 53: "drizzle", + 55: "heavy drizzle", + 61: "light rain", + 63: "rain", + 65: "heavy rain", + 66: "freezing rain", + 67: "heavy freezing rain", + 71: "light snow", + 73: "snow", + 75: "heavy snow", + 77: "snow grains", + 80: "light showers", + 81: "showers", + 82: "heavy showers", + 85: "light snow showers", + 86: "heavy snow showers", + 95: "thunderstorms", + 96: "thunderstorms with hail", + 99: "severe thunderstorms with hail", +} + +# Spoken labels for the next two forecast days (index 1 and 2 of the daily arrays). +_DAY_LABELS = ("Tomorrow", "Then") + + +def describe_weather_code(code: int) -> str: + """Return a short spoken phrase for a WMO weather code, or a generic fallback.""" + return _WMO_DESCRIPTIONS.get(code, "unsettled weather") + + +def _c_to_f(celsius: float) -> int: + """Convert Celsius to a rounded Fahrenheit integer for the spoken report.""" + return round(celsius * 9 / 5 + 32) + + +def _get_json(url: str) -> object: + """GET ``url`` and return its parsed JSON body (the default network seam).""" + import httpx + + response = httpx.get(url, timeout=_TIMEOUT) + response.raise_for_status() + return response.json() + + +def _geocode(name: str, *, fetch: Fetcher) -> tuple[str, float, float] | None: + """Resolve a place name to ``(display name, latitude, longitude)``, or None. + + Asks Open-Meteo's geocoding endpoint for the single best match. No match (an + empty or absent ``results`` list) returns None so the tool can speak a clear + "couldn't find that place" instead of guessing. + """ + query = urlencode({"name": name, "count": 1, "language": "en", "format": "json"}) + payload = jsonshape.as_mapping(fetch(f"{_GEOCODE_URL}?{query}")) + results = jsonshape.mapping_list(payload.get("results")) if payload is not None else [] + if not results: + return None + top = results[0] + return ( + str(top.get("name", name)), + jsonshape.as_float(top.get("latitude")), + jsonshape.as_float(top.get("longitude")), + ) + + +def _forecast(lat: float, lon: float, *, fetch: Fetcher) -> dict[str, object]: + """Fetch the current conditions plus a short daily outlook for coordinates.""" + query = urlencode( + { + "latitude": lat, + "longitude": lon, + "current": ( + "temperature_2m,relative_humidity_2m,apparent_temperature," + "weather_code,wind_speed_10m" + ), + "daily": ( + "temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max" + ), + "forecast_days": _FORECAST_DAYS, + "timezone": "auto", + } + ) + return jsonshape.as_mapping(fetch(f"{_FORECAST_URL}?{query}")) or {} + + +def _current_line(name: str, current: dict[str, object]) -> str: + """The current-conditions sentence: temperature (both units), feels-like, humidity, wind.""" + temp = jsonshape.as_float(current.get("temperature_2m")) + feels = round(jsonshape.as_float(current.get("apparent_temperature"))) + humidity = round(jsonshape.as_float(current.get("relative_humidity_2m"))) + wind = round(jsonshape.as_float(current.get("wind_speed_10m"))) + desc = describe_weather_code(jsonshape.as_int(current.get("weather_code"))) + return ( + f"In {name} it's {round(temp)}°C ({_c_to_f(temp)}°F), feels like {feels}°C, {desc}. " + f"Humidity {humidity}%, wind {wind} km/h." + ) + + +def _today_line(daily: dict[str, object]) -> str | None: + """Today's own high/low, rain chance, and condition — None if today's data is absent. + + This is the line the old report dropped: it started the daily outlook at *tomorrow*, + so "what's the high today?" had no datum and the model guessed from the current temp. + """ + highs = jsonshape.object_list(daily.get("temperature_2m_max")) + lows = jsonshape.object_list(daily.get("temperature_2m_min")) + codes = jsonshape.object_list(daily.get("weather_code")) + if not (highs and lows and codes): + return None + low = round(jsonshape.as_float(lows[0])) + high = round(jsonshape.as_float(highs[0])) + cond = describe_weather_code(jsonshape.as_int(codes[0])) + probs = jsonshape.object_list(daily.get("precipitation_probability_max")) + rain = f"{round(jsonshape.as_float(probs[0]))}% chance of rain, " if probs else "" + return f"Today {low} to {high}°C, {rain}{cond}." + + +def _forecast_lines(daily: dict[str, object]) -> list[str]: + """The spoken outlook lines for the next days, e.g. ``Tomorrow 9 to 17°C, rain.``""" + highs = jsonshape.object_list(daily.get("temperature_2m_max")) + lows = jsonshape.object_list(daily.get("temperature_2m_min")) + codes = jsonshape.object_list(daily.get("weather_code")) + lines: list[str] = [] + for offset, label in enumerate(_DAY_LABELS, start=1): + if offset < len(highs) and offset < len(lows) and offset < len(codes): + low = round(jsonshape.as_float(lows[offset])) + high = round(jsonshape.as_float(highs[offset])) + cond = describe_weather_code(jsonshape.as_int(codes[offset])) + lines.append(f"{label} {low} to {high}°C, {cond}.") + return lines + + +def format_report(name: str, data: dict[str, object]) -> str: + """Render the Open-Meteo forecast as one compact, model-readable string. + + This text is the *tool result* fed back to the live agent's LLM (not spoken + verbatim), so it carries every interesting datum Open-Meteo returns — current + conditions (temperature in both units, feels-like, humidity, wind), today's own + high/low and rain chance, then a two-day outlook — and lets the model pick out + whatever the user asked for. The current temperature is given in both units; the + daily lines stay in °C to keep the reply short. + """ + current = jsonshape.as_mapping(data.get("current")) or {} + daily = jsonshape.as_mapping(data.get("daily")) or {} + lines = [_current_line(name, current)] + today = _today_line(daily) + if today is not None: + lines.append(today) + lines.extend(_forecast_lines(daily)) + return " ".join(lines) + + +def build_weather_tool(fetch: Fetcher = _get_json) -> BaseTool: + """Wrap the Open-Meteo lookup as the ``get_weather`` tool (``fetch`` injectable).""" + from langchain_core.tools import tool + + @tool(WEATHER_TOOL_NAME) + def get_weather(location: str) -> str: + """Get the current weather and a short forecast for a place by name (e.g. a + city). Use when asked about the weather, temperature, or forecast somewhere.""" + try: + located = _geocode(location, fetch=fetch) + if located is None: + return f"I couldn't find a place called '{location}'." + name, lat, lon = located + return format_report(name, _forecast(lat, lon, fetch=fetch)) + except Exception: + # Best-effort: a transient Open-Meteo outage (the fetch seam raises) must + # not bubble into brain's "couldn't complete the turn" path and kill the + # spoken reply — speak a short apology instead. Mirrors mcp_tools._safe_load. + return "I couldn't get the weather right now." + + return get_weather diff --git a/aai_cli/agent_cascade/webpage_tool.py b/aai_cli/agent_cascade/webpage_tool.py new file mode 100644 index 00000000..3ec54f15 --- /dev/null +++ b/aai_cli/agent_cascade/webpage_tool.py @@ -0,0 +1,75 @@ +"""A keyless read-a-URL tool for the `assembly live` voice agent. + +Reads a web page or PDF the agent has a URL for and returns its readable text, so +the live agent can read an article the user names or a link surfaced by web search. +It reuses :func:`aai_cli.core.webpage.fetch_article` — the same trafilatura HTML +extraction and pypdf PDF text extraction that backs ``assembly speak --url`` — so no +API key is needed and every live session has this capability. + +The only network seam is :data:`Reader` (a ``url -> Article`` callable), injected in +tests so the whole flow runs with no sockets — the same shape ``weather_tool`` uses. +Failures never raise out to the graph: ``read_url`` catches them and returns a short +spoken apology so a fetch outage can't sink a live turn. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from aai_cli.core.errors import UsageError + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + + from aai_cli.core.webpage import Article + +# The registered tool name. ``brain.py`` detects availability and labels the live-UI +# affordance by this name, so a test pins it. +READ_URL_TOOL_NAME = "read_url" + +# A reader GETs a URL and returns the extracted Article. Injected in tests (the only net seam). +Reader = Callable[[str], "Article"] + +# Cap the returned text so a long article or multi-page PDF can't blow the model's context +# budget. The body is source for the model to summarize aloud, so the exact cap is a tuning +# knob — a +-1 shift is behaviorally equivalent, so no test can kill that mutant. +_MAX_CHARS = 16000 # pragma: no mutate + + +def _read(url: str) -> Article: + """Fetch and extract ``url`` via core.webpage (imported lazily to stay off startup).""" + from aai_cli.core.webpage import fetch_article + + return fetch_article(url) + + +def _format(article: Article) -> str: + """Render the article as ``title + readable text``, truncated to ``_MAX_CHARS``.""" + body = article.text + if len(body) > _MAX_CHARS: + body = body[:_MAX_CHARS] + "\n…[truncated]" + if article.title: + return f"{article.title}\n\n{body}" + return body + + +def build_read_url_tool(read: Reader = _read) -> BaseTool: + """Wrap the URL reader as the ``read_url`` tool (``read`` injectable for tests).""" + from langchain_core.tools import tool + + @tool(READ_URL_TOOL_NAME) + def read_url(url: str) -> str: + """Read a web page or PDF by URL and return its text. Use to read an article, + document, or page you have the URL for (e.g. from a web-search result).""" + try: + return _format(read(url)) + except UsageError: + # Bad URL or no readable text (scanned/image-only PDF, paywalled/JS page). + return "I couldn't find readable text on that page." + except Exception: + # Any fetch failure (APIError: DNS/timeout/non-2xx, or anything else) must not + # bubble into brain's "couldn't complete the turn" path. Mirrors weather_tool. + return "I couldn't read that page right now." + + return read_url diff --git a/aai_cli/app/transcribe/run.py b/aai_cli/app/transcribe/run.py index 06224572..c5d61dbc 100644 --- a/aai_cli/app/transcribe/run.py +++ b/aai_cli/app/transcribe/run.py @@ -26,6 +26,12 @@ from aai_cli.core.errors import UsageError from aai_cli.ui import output +# The default batch model when no `--speech-model` (or `--config speech_model(s)=…`) +# is given. `universal-3-5-pro` is not a member of the SDK's `SpeechModel` enum +# (which backs `--speech-model`), so it's delivered through the plural `speech_models` +# list field, which takes raw model-id strings. +DEFAULT_BATCH_SPEECH_MODELS = ("universal-3-5-pro",) + def out_payload( transcript: aai.Transcript, @@ -355,6 +361,10 @@ def run_transcribe(opts: TranscribeOptions, state: AppState, *, json_mode: bool) merged = config_builder.merge_transcribe_config( flags=flags, overrides=opts.config_kv, config_file=opts.config_file ) + # Apply the default model only when the request specifies none, so an explicit + # `--speech-model`, `--config speech_model(s)=…`, or config file still wins. + if "speech_model" not in merged and "speech_models" not in merged: + merged["speech_models"] = list(DEFAULT_BATCH_SPEECH_MODELS) transcribe_validate.validate_speakers_expected(merged) batch_sources = transcribe_sources.expand_sources( diff --git a/aai_cli/code_agent/__init__.py b/aai_cli/code_agent/__init__.py deleted file mode 100644 index 8ced023c..00000000 --- a/aai_cli/code_agent/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""`assembly code` — a terminal coding agent built on the deepagents SDK. - -A bespoke port of langchain-ai/deepagents' `code` agent, wired so it **only** -talks to the AssemblyAI LLM Gateway (an OpenAI-compatible endpoint reached via -`langchain_openai.ChatOpenAI`; see `model.py`). The agent gets deepagents' -built-in filesystem + shell tools — rooted at the working directory through a -`LocalShellBackend` — plus a custom `assembly` tool that invokes this very CLI, -so it can transcribe/stream/run-LLM as part of a coding task (`cli_tool.py`). - -The pieces are split so the orchestration (`session.py`) is unit-tested against -a fake chat model driving the *real* deepagents graph, with no network: `agent.py` -builds the graph, `render.py` draws the conversation, and the Typer command in -`aai_cli/commands/code/` wires the gateway model + real CLI runner in. -""" - -from __future__ import annotations diff --git a/aai_cli/code_agent/_config_root.py b/aai_cli/code_agent/_config_root.py deleted file mode 100644 index f046be12..00000000 --- a/aai_cli/code_agent/_config_root.py +++ /dev/null @@ -1,24 +0,0 @@ -"""The coding-agent config root, shared by the skills and memory backends. - -`assembly setup` and the agent's middleware both anchor their on-disk state under -the coding-agent config root (`$CLAUDE_CONFIG_DIR` or `~/.claude`). Skills and -long-term memory each root their own `FilesystemBackend` there, so the resolution -lives here once rather than being duplicated per backend. - -Mirrors `aai_cli.app.coding_agent.skills_root`'s root resolution without importing -the app layer (a feature slice stays below it). -""" - -from __future__ import annotations - -from pathlib import Path - -from aai_cli.core import env - -_CLAUDE_CONFIG_DIR = "CLAUDE_CONFIG_DIR" - - -def claude_config_root() -> Path: - """The coding-agent config root: ``$CLAUDE_CONFIG_DIR`` if set, else ``~/.claude``.""" - config_dir = env.get(_CLAUDE_CONFIG_DIR) - return Path(config_dir) if config_dir else Path.home() / ".claude" diff --git a/aai_cli/code_agent/agent.py b/aai_cli/code_agent/agent.py deleted file mode 100644 index edb53161..00000000 --- a/aai_cli/code_agent/agent.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Assemble the deepagents graph for `assembly code`. - -Wires the gateway model to deepagents' built-in coding toolset (filesystem + shell, -rooted at the working directory via a `LocalShellBackend`), plus the custom `assembly` -CLI tool and any MCP/docs tools, the installed-skills middleware, and human-in-the-loop -approval on the mutating tools. The compiled graph is driven turn-by-turn from -`session.py`; an `InMemorySaver` checkpointer gives both conversation memory and the -interrupt/resume the approval flow needs. -""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from pathlib import Path -from typing import TYPE_CHECKING, Protocol - -from aai_cli.code_agent.cli_tool import CLI_TOOL_NAME -from aai_cli.code_agent.fetch_tool import FETCH_TOOL_NAME -from aai_cli.code_agent.prompt import build_system_prompt - -if TYPE_CHECKING: - from langchain.agents.middleware import AgentMiddleware - from langchain_core.language_models.chat_models import BaseChatModel - from langchain_core.tools import BaseTool - from langgraph.checkpoint.base import BaseCheckpointSaver - -# The tools whose effects reach outside the model — file writes, edits, arbitrary -# shell, the AssemblyAI CLI (which can spend account credits), and URL fetches (which -# can reach internal/SSRF targets). Each is gated behind human approval unless the -# session opts into --auto. -MUTATING_TOOLS = ("write_file", "edit_file", "execute", CLI_TOOL_NAME, FETCH_TOOL_NAME) - - -class CompiledAgent(Protocol): - """The slice of the compiled langgraph graph the session drives. - - A structural type so we needn't name langgraph's deeply-generic - ``CompiledStateGraph`` (and don't drag its type params through our code). - """ - - def invoke( - self, input: object, config: Mapping[str, object] | None = None - ) -> dict[str, object]: - """Run one step of the graph, returning the updated state (incl. messages).""" - - -def _interrupt_config(*, auto_approve: bool) -> dict[str, bool] | None: - """The ``interrupt_on`` map: approve every mutating tool, or ``None`` under --auto.""" - if auto_approve: - return None - return dict.fromkeys(MUTATING_TOOLS, True) - - -def build_agent( - *, - model: BaseChatModel, - root_dir: Path, - tools: Sequence[BaseTool] = (), - middlewares: Sequence[AgentMiddleware] = (), - checkpointer: BaseCheckpointSaver | None = None, - auto_approve: bool = False, -) -> CompiledAgent: - """Compile the coding agent over ``root_dir`` with ``tools`` and ``middlewares``. - - ``model`` is the only network seam — tests pass a fake chat model so the real - deepagents graph (filesystem + shell tools, approval, checkpointing) runs offline. - ``checkpointer`` defaults to an in-memory saver (one ephemeral session); the command - passes a SQLite saver for persistent, resumable sessions. - """ - from deepagents import create_deep_agent - from deepagents.backends import LocalShellBackend - from langgraph.checkpoint.memory import InMemorySaver - - # virtual_mode=True maps the model's "/"-rooted paths under root_dir and blocks - # traversal escapes, so file ops and shell stay inside the working directory. - backend = LocalShellBackend(root_dir=str(root_dir), virtual_mode=True) - - return create_deep_agent( - model=model, - backend=backend, - system_prompt=build_system_prompt(str(root_dir)), - tools=list(tools), - middleware=list(middlewares), - interrupt_on=_interrupt_config(auto_approve=auto_approve), - checkpointer=checkpointer if checkpointer is not None else InMemorySaver(), - ) diff --git a/aai_cli/code_agent/ask_tool.py b/aai_cli/code_agent/ask_tool.py deleted file mode 100644 index 34b6754d..00000000 --- a/aai_cli/code_agent/ask_tool.py +++ /dev/null @@ -1,51 +0,0 @@ -"""An `ask_user` tool so the agent can ask the user a question mid-task. - -deepagents-code ships an AskUser middleware; base deepagents does not, so we add a -small tool. The actual prompting is injected through an :class:`AskBridge`: the Rich -REPL reads a line, the Textual TUI pops an input modal, and tests script the answer — -the tool itself just calls the bridge, so it stays framework-agnostic. It is *not* -approval-gated (it is itself the user interaction). -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from langchain_core.tools import BaseTool - -ASK_TOOL_NAME = "ask_user" - - -def _unanswered(_question: str) -> str: - """Default handler before a front-end registers one: no human is attached.""" - return "No user is available to answer; proceed with your best judgment." - - -@dataclass -class AskBridge: - """A late-bound seam for asking the user a question. - - The agent (and its tools) are built before the front-end exists, so the tool - captures this bridge and the REPL/TUI sets :attr:`handler` once it's running. - """ - - handler: Callable[[str], str] = field(default=_unanswered) - - def ask(self, question: str) -> str: - return self.handler(question) - - -def build_ask_tool(bridge: AskBridge) -> BaseTool: - """Wrap an :class:`AskBridge` as the ``ask_user`` tool.""" - from langchain_core.tools import tool - - @tool(ASK_TOOL_NAME) - def ask_user(question: str) -> str: - """Ask the user a clarifying question and return their answer. Use when you - genuinely need information only the user has before continuing.""" - return bridge.ask(question) - - return ask_user diff --git a/aai_cli/code_agent/cli_tool.py b/aai_cli/code_agent/cli_tool.py deleted file mode 100644 index a7255a35..00000000 --- a/aai_cli/code_agent/cli_tool.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Expose the AssemblyAI CLI to the agent as a tool. - -The agent gets an ``assembly`` tool that runs *this* CLI as a subprocess -(``python -m aai_cli …``), so a coding task can transcribe a file, run an LLM -transform, list transcripts, etc. without the model hand-rolling shell quoting. - -Secrets never ride argv (the project-wide rule): the resolved API key is injected -into the child's environment, never appended to the argument list, so it can't leak -into ``ps`` or the model's own transcript of the command it ran. -""" - -from __future__ import annotations - -import subprocess -import sys -from collections.abc import Callable -from typing import TYPE_CHECKING - -from aai_cli.core import config, env - -if TYPE_CHECKING: - from langchain_core.tools import BaseTool - -# The tool name the model calls and the approval flow gates on. -CLI_TOOL_NAME = "assembly" - -# Cap captured output so a chatty command can't blow the model's context window. -_MAX_OUTPUT_CHARS = 20000 -# Backstop so a hung command (e.g. a stuck network call) can't wedge the session. -_DEFAULT_TIMEOUT = 600 - -# A runner takes the CLI argument list and returns the combined, formatted output. -CliRunner = Callable[[list[str]], str] - - -def _truncate(text: str) -> str: - """Clip captured output to the context-window budget, marking that we did.""" - if len(text) <= _MAX_OUTPUT_CHARS: - return text - return text[:_MAX_OUTPUT_CHARS] + "\n…[output truncated]" - - -def _format_result(proc: subprocess.CompletedProcess[str]) -> str: - """Render a finished CLI run as text the model can read: exit code + both streams.""" - parts = [f"exit code: {proc.returncode}"] - if proc.stdout: - parts.append(f"stdout:\n{proc.stdout.rstrip()}") - if proc.stderr: - parts.append(f"stderr:\n{proc.stderr.rstrip()}") - return _truncate("\n".join(parts)) - - -def run_assembly(args: list[str], *, api_key: str, timeout: float = _DEFAULT_TIMEOUT) -> str: - """Run ``assembly `` as a subprocess and return its formatted output. - - Invoked as ``python -m aai_cli`` so it's the very CLI in use, independent of - whatever ``assembly`` may (or may not) be on PATH. The key is passed through the - environment, never argv. - """ - proc = subprocess.run( - [sys.executable, "-m", "aai_cli", *args], - capture_output=True, - text=True, - stdin=subprocess.DEVNULL, - env=env.child_env(**{config.ENV_API_KEY: api_key}), - timeout=timeout, - check=False, - ) - return _format_result(proc) - - -def build_cli_tool(runner: CliRunner) -> BaseTool: - """Wrap a :data:`CliRunner` as the ``assembly`` LangChain tool the agent can call. - - The runner is injected so the orchestration is tested without spawning a real - subprocess; the command layer passes :func:`run_assembly` bound to the session's key. - """ - from langchain_core.tools import tool - - @tool(CLI_TOOL_NAME) - def assembly(arguments: list[str]) -> str: - """Run the AssemblyAI CLI. Pass CLI arguments as a list of strings, e.g. - ["transcribe", "audio.mp3", "--json"]. Returns the command's exit code and - output. Do not include an API key — it is provided via the environment.""" - return runner(arguments) - - return assembly diff --git a/aai_cli/code_agent/docs_mcp.py b/aai_cli/code_agent/docs_mcp.py deleted file mode 100644 index 3aa7a0c2..00000000 --- a/aai_cli/code_agent/docs_mcp.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Load the AssemblyAI docs MCP server's tools for the agent. - -`assembly setup` registers the same hosted docs server with Claude Code over HTTP; -here we connect to it directly through ``langchain-mcp-adapters`` and hand its tools -to deepagents, so the coding agent can search the AssemblyAI documentation while it -works. Connecting is best-effort: a sandbox that blocks the host, or an offline run, -degrades to "no docs tools" with a caller-visible warning rather than a hard failure. -""" - -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from langchain_core.tools import BaseTool - -# The hosted docs MCP server (HTTP transport) — the same endpoint `assembly setup` -# wires into Claude Code. -DOCS_MCP_URL = "https://mcp.assemblyai.com/docs" -DOCS_MCP_NAME = "assemblyai-docs" - - -async def _fetch(url: str) -> list[BaseTool]: - from langchain_mcp_adapters.client import MultiServerMCPClient - - client = MultiServerMCPClient({DOCS_MCP_NAME: {"transport": "streamable_http", "url": url}}) - return await client.get_tools() - - -def load_docs_tools(url: str = DOCS_MCP_URL) -> list[BaseTool]: - """Connect to the docs MCP server and return its tools, or ``[]`` if unreachable. - - The adapter's ``get_tools`` is async; we drive it with ``asyncio.run`` since the - command path is synchronous. Any connection/transport failure is swallowed and - surfaced as an empty list so a blocked network never aborts a coding session. - """ - try: - return asyncio.run(_fetch(url)) - except Exception: - return [] diff --git a/aai_cli/code_agent/events.py b/aai_cli/code_agent/events.py deleted file mode 100644 index ed480bbd..00000000 --- a/aai_cli/code_agent/events.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Turn the agent's langchain messages into framework-agnostic display events. - -Both the Rich renderer and the Textual TUI consume the same small event vocabulary, -so the message-shape knowledge (AIMessage tool_calls, ToolMessage results) lives here -once rather than in each front-end. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from langchain_core.messages import BaseMessage - - -@dataclass(frozen=True) -class AssistantText: - """A chunk of the assistant's natural-language reply.""" - - text: str - - -@dataclass(frozen=True) -class AssistantDelta: - """One streamed token of the in-progress reply, shown live then superseded by AssistantText. - - Emitted from langgraph's per-token ``messages`` stream so the front-end can render the - reply as it's generated; the authoritative full text still arrives as an AssistantText - when the step lands, so a consumer that ignores deltas (the headless renderer) loses - nothing. - """ - - text: str - - -@dataclass(frozen=True) -class ToolCall: - """The agent's request to run a tool (announced when not gated by approval).""" - - name: str - args: dict[str, object] = field(default_factory=dict) - - -@dataclass(frozen=True) -class ToolResult: - """A tool's returned output, surfaced back into the conversation.""" - - name: str - content: str - - -@dataclass(frozen=True) -class ErrorText: - """A turn failed (e.g. the gateway errored); shown instead of crashing the UI.""" - - text: str - - -Event = AssistantText | AssistantDelta | ToolCall | ToolResult | ErrorText - - -def assistant_delta(payload: object) -> AssistantDelta | None: - """Extract a streaming assistant-text token from a ``messages``-mode stream payload. - - langgraph's ``messages`` mode yields ``(message_chunk, metadata)``; we surface only the - AI message's text tokens (tool-call requests and tool results carry no prose, and other - message kinds aren't the assistant talking), so the live region streams just the reply. - """ - chunk = payload[0] if isinstance(payload, tuple) and payload else payload - if type(chunk).__name__ not in ("AIMessage", "AIMessageChunk"): - return None - text = _text_of(getattr(chunk, "content", "")) - return AssistantDelta(text) if text else None - - -def _text_of(content: object) -> str: - """Coerce a message's content (str, or a list of content blocks) to plain text.""" - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [ - block.get("text", "") if isinstance(block, dict) else str(block) for block in content - ] - return "".join(parts) - return str(content) - - -def message_events(message: BaseMessage, *, announce_calls: bool) -> list[Event]: - """Display events for one new message. - - Assistant text always shows; tool calls show only when ``announce_calls`` (the - --auto path, where no approval prompt announced them); tool results always show. - A human message produces nothing — the UI already echoed the user's own input. - """ - kind = type(message).__name__ - if kind == "ToolMessage": - return [ - ToolResult( - name=getattr(message, "name", "") or "tool", content=_text_of(message.content) - ) - ] - if kind == "AIMessage": - events: list[Event] = [] - text = _text_of(message.content).strip() - if text: - events.append(AssistantText(text)) - if announce_calls: - events.extend( - ToolCall(name=call.get("name", ""), args=call.get("args", {})) - for call in getattr(message, "tool_calls", None) or [] - ) - return events - return [] - - -def new_messages(result: dict[str, object], already_seen: int) -> list[BaseMessage]: - """The messages added to the conversation since ``already_seen`` were rendered.""" - messages = result.get("messages") - if not isinstance(messages, list): - return [] - return messages[already_seen:] - - -def interrupt_request(result: dict[str, object]) -> dict[str, object] | None: - """The pending human-in-the-loop request (action_requests), or ``None``. - - deepagents surfaces an approval pause as ``__interrupt__`` — a list of Interrupt - objects whose ``.value`` is the HITL request. We only ever raise one such interrupt - per turn, so the first one carries every gated tool call. - """ - interrupts = result.get("__interrupt__") - if not isinstance(interrupts, (list, tuple)) or not interrupts: - return None - value = getattr(interrupts[0], "value", None) - return value if isinstance(value, dict) else None diff --git a/aai_cli/code_agent/fetch_tool.py b/aai_cli/code_agent/fetch_tool.py deleted file mode 100644 index b4738973..00000000 --- a/aai_cli/code_agent/fetch_tool.py +++ /dev/null @@ -1,48 +0,0 @@ -"""A URL-fetch tool for the coding agent (deepagents-code parity). - -Distinct from web *search* (Firecrawl): this fetches a specific URL the agent already -knows and returns its text. It is approval-gated (see ``MUTATING_TOOLS``) because an -arbitrary fetch can reach internal/SSRF targets, so the user confirms each one. -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from langchain_core.tools import BaseTool - -FETCH_TOOL_NAME = "fetch_url" - -# Keep fetched pages inside the model's context budget. -_MAX_CHARS = 20000 -_TIMEOUT = 30.0 - -# A fetcher takes a URL and returns the response text (injected for hermetic tests). -Fetcher = Callable[[str], str] - - -def fetch_url(url: str, *, timeout: float = _TIMEOUT) -> str: - """GET ``url`` and return its (truncated) text body.""" - import httpx - - response = httpx.get(url, timeout=timeout, follow_redirects=True) - response.raise_for_status() - text = response.text - if len(text) <= _MAX_CHARS: - return text - return text[:_MAX_CHARS] + "\n…[truncated]" - - -def build_fetch_tool(fetcher: Fetcher = fetch_url) -> BaseTool: - """Wrap a :data:`Fetcher` as the ``fetch_url`` tool (injectable for tests).""" - from langchain_core.tools import tool - - @tool(FETCH_TOOL_NAME) - def fetch_url_tool(url: str) -> str: - """Fetch a URL over HTTP(S) and return its text content. Use for reading a - specific page or API response you already have the URL for.""" - return fetcher(url) - - return fetch_url_tool diff --git a/aai_cli/code_agent/memory.py b/aai_cli/code_agent/memory.py deleted file mode 100644 index 124f9b5f..00000000 --- a/aai_cli/code_agent/memory.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Long-term agent memory (deepagents-code parity). - -deepagents' `MemoryMiddleware` loads memory files into the system prompt and lets the -agent persist learnings with `edit_file`. Like skills, it reads through a backend; we -give it its own `FilesystemBackend` rooted at a memories directory under the CLI's -config root, independent of the cwd-scoped file tools, so memory survives across -sessions and projects. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from aai_cli.code_agent._config_root import claude_config_root - -if TYPE_CHECKING: - from deepagents.middleware.memory import MemoryMiddleware - - -def memory_root() -> Path: - """Directory where the agent's long-term memory files live (created on demand).""" - return claude_config_root() / "code-memory" - - -# The single memory file the agent reads and appends learnings to. MemoryMiddleware -# loads each source as a *file* (a directory like "/" makes it raise is_directory), so -# this is a concrete path, not a folder. -_MEMORY_FILE = "memory.md" - - -def build_memory_middleware(root: Path | None = None) -> MemoryMiddleware: - """A `MemoryMiddleware` reading/appending a single memory file under ``root``.""" - root = root if root is not None else memory_root() - root.mkdir(parents=True, exist_ok=True) - # Touch the file so the very first session has something to load (and a target to - # append to); an absent source is skipped, but an empty file reads cleanly. - (root / _MEMORY_FILE).touch(exist_ok=True) - - from deepagents.backends import FilesystemBackend - from deepagents.middleware.memory import MemoryMiddleware - - backend = FilesystemBackend(root_dir=str(root), virtual_mode=True) - return MemoryMiddleware(backend=backend, sources=[f"/{_MEMORY_FILE}"]) diff --git a/aai_cli/code_agent/modals.py b/aai_cli/code_agent/modals.py deleted file mode 100644 index 041cd1c4..00000000 --- a/aai_cli/code_agent/modals.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Bottom-docked modal screens for the coding-agent TUI: tool approval and agent questions. - -Split out of `tui.py` to keep each module under the file-length gate. Both are transparent -``ModalScreen``s docked at the bottom, so the transcript stays visible above them (see the -``ModalScreen { background: transparent }`` rule in :class:`~aai_cli.code_agent.tui.CodeAgentApp`). - -In voice mode each modal is also **spoken and voice-answerable**: when constructed with a -``voice`` IO it speaks the prompt and listens for a spoken reply (approve / auto / reject, or a -free-text answer), off the UI thread. The keyboard path always stays available as a fallback. -""" - -from __future__ import annotations - -import re -import threading -from typing import TYPE_CHECKING, ClassVar - -from rich.markup import escape -from textual.app import ComposeResult -from textual.containers import Vertical -from textual.screen import ModalScreen -from textual.widgets import Input, Label - -from aai_cli.code_agent import banner, risk -from aai_cli.code_agent.summarize import describe_args, full_args -from aai_cli.core import errors - -if TYPE_CHECKING: - from collections.abc import Callable, Mapping - - from aai_cli.code_agent.voice_ui import _VoiceIO - - -def _spawn(target: Callable[[], None]) -> None: - """Run ``target`` on a daemon thread — the voice legs block, so they stay off the UI thread.""" - threading.Thread(target=target, daemon=True).start() # pragma: no mutate - - -# Spoken-answer vocabulary. "auto" wins first (it implies approval); an unclear answer falls -# back to "reject" — the same safe default as the keyboard, so a tool never runs on a guess. -_REJECT_WORDS = frozenset({"no", "reject", "deny", "stop", "cancel", "nope", "nah"}) -_APPROVE_WORDS = frozenset({"yes", "approve", "yeah", "yep", "yup", "sure", "ok", "okay"}) - - -def approval_from_speech(text: str) -> str: - """Map a spoken reply to ``"approve"`` / ``"auto"`` / ``"reject"`` (unclear → reject).""" - lowered = text.lower() - words = set(re.findall(r"[a-z]+", lowered)) - if "auto" in lowered or "always" in lowered: - return "auto" - if words & _REJECT_WORDS or "don't" in lowered or "do not" in lowered: - return "reject" - if words & _APPROVE_WORDS or "go ahead" in lowered or "do it" in lowered: - return "approve" - return "reject" - - -class ApprovalScreen(ModalScreen[str]): - """A compact, bottom-docked prompt to approve/auto-approve/reject one tool call. - - Keyboard ``y / a / n`` (and ``e`` to expand the args); in voice mode it also speaks the - prompt and accepts a spoken approve/auto/reject. The transparent background leaves the - transcript visible, and a risky call (``rm -rf``, an internal fetch) carries a warning. - """ - - DEFAULT_CSS = """ - ApprovalScreen { align: center bottom; background: transparent; } - /* width: 100% (not 1fr) so the box honors its 1-col side margins — a docked 1fr container - ignores horizontal margin and overflows the screen, clipping the right border off-edge. */ - ApprovalScreen #approvalbox { - dock: bottom; width: 100%; height: auto; - border: round #f59e0b; background: #000000; padding: 0 1; margin: 0 1 1 1; - } - ApprovalScreen #approvalbox Label { height: auto; } - """ - BINDINGS: ClassVar = [ - ("y", "approve", "Approve"), - ("a", "auto", "Auto-approve"), - ("n", "reject", "Reject"), - ("e", "expand", "Expand"), - # Escape / Ctrl-C dismiss the modal — declining the tool is the safe cancel. - ("escape,ctrl+c", "reject", "Cancel"), - ] - - def __init__( - self, name: str, args: Mapping[str, object], *, voice: _VoiceIO | None = None - ) -> None: - super().__init__() - self._tool_name = name # not _name: that shadows Textual Widget's str|None attr - self._args = args - self._expanded = False # toggled by `e`; collapsed (one-line) by default - self._voice = voice # when set, the prompt is spoken and a spoken answer is accepted - self._answered = False # guards against a voice answer and a keypress both dismissing - - def compose(self) -> ComposeResult: - with Vertical(id="approvalbox"): - warning = risk.risk_warning(self._tool_name, self._args) - if warning: - yield Label(f"[b #f04438]⚠ {escape(warning)}[/]", id="approvalwarn") - yield Label(self._detail_markup(), id="approvaldetail") - yield Label( - f"[b #22c55e]y[/] approve [b {banner.BRAND_HEX}]a[/] auto-approve " - "[b #f04438]n[/] reject [b]e[/] expand" - ) - - def on_mount(self) -> None: - if (voice := self._voice) is not None: # drive the decision by voice, off the UI thread - _spawn(lambda: self._drive_by_voice(voice)) - - def _drive_by_voice(self, voice: _VoiceIO) -> None: - """Speak the prompt and accept a spoken approve/auto/reject (keyboard still works).""" - try: - voice.speak(self._spoken_prompt()) - transcript = voice.listen() - except errors.CLIError: - return # mic/STT failed: leave the keyboard hint as the way to answer - if transcript: # silence (None) must not auto-reject a tool — wait for speech or a key - self.app.call_from_thread(self._decide, approval_from_speech(transcript)) - - def _spoken_prompt(self) -> str: - """The read-aloud version of the prompt: the tool, its arg, any warning, the options.""" - parts = [f"Run {self._tool_name}."] - detail = describe_args(self._args) - if detail: - parts.append(f"{detail}.") - warning = risk.risk_warning(self._tool_name, self._args) - if warning: - parts.append(f"Warning: {warning}") - parts.append("Say approve, auto-approve, or reject.") - return " ".join(parts) - - def _decide(self, decision: str) -> None: - """Dismiss once, whether the answer came by spoken reply or keypress.""" - if self._answered: - return - self._answered = True - self.dismiss(decision) - - def _detail_markup(self) -> str: - """The 'Run tool X?' line — the compact arg, or the full args when expanded.""" - args = full_args(self._args) if self._expanded else describe_args(self._args) - return f"Run tool [b]{escape(self._tool_name)}[/b]? [dim]{escape(args)}[/dim]" - - def action_expand(self) -> None: - """Toggle between the compact identifying arg and the full args (``e``).""" - self._expanded = not self._expanded - self.query_one("#approvaldetail", Label).update(self._detail_markup()) - - def action_approve(self) -> None: - self._decide("approve") - - def action_auto(self) -> None: - self._decide("auto") - - def action_reject(self) -> None: - self._decide("reject") - - -class AskScreen(ModalScreen[str]): - """A bottom-docked prompt that relays a question from the agent and returns the answer. - - In voice mode it speaks the question and takes a spoken answer; otherwise the user types. - """ - - DEFAULT_CSS = """ - AskScreen { align: center bottom; background: transparent; } - AskScreen #askbox { - dock: bottom; width: 100%; height: auto; - border: round #3a3f55; background: #000000; padding: 0 1; margin: 0 1 1 1; - } - """ - # Escape / Ctrl-C dismiss the question with no answer. - BINDINGS: ClassVar = [("escape,ctrl+c", "cancel", "Cancel")] - - def __init__(self, question: str, *, voice: _VoiceIO | None = None) -> None: - super().__init__() - self._question = question - self._voice = voice - self._answered = False - - def compose(self) -> ComposeResult: - with Vertical(id="askbox"): - yield Label(f"[b]The agent asks:[/b] {escape(self._question)}") - yield Input(id="answer", placeholder="Type your answer and press Enter…") - - def on_mount(self) -> None: - if (voice := self._voice) is not None: - _spawn(lambda: self._drive_by_voice(voice)) - - def _drive_by_voice(self, voice: _VoiceIO) -> None: - """Speak the question and submit a spoken answer (typing still works).""" - try: - voice.speak(f"The agent asks: {self._question}") - transcript = voice.listen() - except errors.CLIError: - return - if transcript: - self.app.call_from_thread(self._answer, transcript) - - def _answer(self, text: str) -> None: - """Dismiss once with the answer, whether spoken or typed.""" - if self._answered: - return - self._answered = True - self.dismiss(text) - - def action_cancel(self) -> None: - """Escape / Ctrl-C: dismiss with no answer (the agent gets an empty reply).""" - self._answer("") - - def on_input_submitted(self, event: Input.Submitted) -> None: - self._answer(event.value) diff --git a/aai_cli/code_agent/prompt.py b/aai_cli/code_agent/prompt.py deleted file mode 100644 index c704607e..00000000 --- a/aai_cli/code_agent/prompt.py +++ /dev/null @@ -1,43 +0,0 @@ -"""System prompt and model defaults for the `assembly code` agent.""" - -from __future__ import annotations - -# A capable gateway model by default; override with `--model`. The gateway is the -# source of truth for what's accepted, so this is only a sensible default. -DEFAULT_MODEL = "gpt-5.1" -# Generous ceiling so long edits/explanations aren't clipped; the gateway only bills -# tokens actually generated, so a high cap costs nothing on short replies. -DEFAULT_MAX_TOKENS = 8192 - -_TEMPLATE = """\ -You are the AssemblyAI coding agent, running in a terminal in the user's project. - -Working directory: {root_dir} -All file and shell tools operate inside this directory. - -You have these capabilities: -- Filesystem tools (read_file, write_file, edit_file, ls, glob, grep) scoped to the - working directory. -- A shell tool (execute) for running commands like tests and builds. -- write_todos for planning multi-step work — use it to track non-trivial tasks. -- An `assembly` tool that runs the AssemblyAI CLI itself (e.g. transcribe, llm, - stream, transcripts). Prefer it over raw shell for any AssemblyAI work; pass the - CLI arguments as a list, e.g. {{"arguments": ["transcribe", "audio.mp3", "--json"]}}. - Never pass an API key on the argument list — the key is supplied via the - environment automatically. -- Reference tools when available: search the AssemblyAI documentation (docs MCP) - for API/SDK questions, and web search for anything else. Prefer the docs for - AssemblyAI specifics. - -Be concise — and especially so out loud. Your prose is read aloud by a text-to-speech -engine, so keep replies to a sentence or two of plain, simple spoken language: no -markdown, lists, symbols, URLs, or code in the prose. Put any code in fenced code blocks -(the readback skips them). Make focused edits, briefly say what you changed, and run -commands to verify your work when it helps. Stop and ask before destructive or -far-reaching actions.\ -""" - - -def build_system_prompt(root_dir: str) -> str: - """The agent's system prompt, anchored to the working directory it operates in.""" - return _TEMPLATE.format(root_dir=root_dir) diff --git a/aai_cli/code_agent/render.py b/aai_cli/code_agent/render.py deleted file mode 100644 index f2470b07..00000000 --- a/aai_cli/code_agent/render.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Rich rendering for the coding agent's non-TUI / headless path. - -The Textual TUI (`tui.py`) is the primary front-end; this renders the same -:mod:`~aai_cli.code_agent.events` to the plain Rich consoles for piped/headless runs -and as the simple fallback. Errors/notices go to stderr so stdout stays clean. -""" - -from __future__ import annotations - -from rich.markdown import Markdown -from rich.markup import escape - -from aai_cli.code_agent.events import AssistantText, ErrorText, Event, ToolCall, ToolResult -from aai_cli.code_agent.summarize import summarize_call, summarize_result -from aai_cli.ui import output - - -class RichRenderer: - """An :data:`~aai_cli.code_agent.session.EventSink` that prints to the Rich console.""" - - def __call__(self, event: Event) -> None: - # escape() dynamic content so a model/tool string with "[" can't inject Rich - # markup or raise MarkupError (matches the inline-escape convention in output.py). - if isinstance(event, AssistantText): - # Render as Markdown so fenced code blocks are syntax-highlighted (and lists/ - # headings format) instead of showing raw ``` markers — Markdown parses its own - # syntax, not console markup, so no escape()/injection concern. - output.console.print(Markdown(event.text)) - elif isinstance(event, ToolCall): - output.console.print( - f"[aai.muted]→ {escape(summarize_call(event.name, event.args))}[/aai.muted]" - ) - elif isinstance(event, ToolResult): - preview = escape(summarize_result(event.content)) - output.console.print(f"[aai.muted] {escape(event.name)}: {preview}[/aai.muted]") - elif isinstance(event, ErrorText): - output.error_console.print(output.fail(escape(event.text))) - - def notice(self, text: str) -> None: - """A dim advisory on stderr (so it never pollutes piped stdout).""" - output.error_console.print(output.hint(text)) diff --git a/aai_cli/code_agent/session.py b/aai_cli/code_agent/session.py deleted file mode 100644 index b3ce738f..00000000 --- a/aai_cli/code_agent/session.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Drive the compiled agent turn-by-turn, framework-agnostically. - -`CodeSession.send` runs one user turn: it invokes the graph, resolves any -human-in-the-loop approval interrupts (asking the injected ``approver``), and emits -display events to the injected ``sink``. Both the Rich renderer and the Textual TUI -sit behind those two callables, so the orchestration here is unit-tested with a fake -chat model and plain functions — no terminal, no framework. -""" - -from __future__ import annotations - -import threading -from collections.abc import Callable, Iterator, Mapping -from dataclasses import dataclass, field -from typing import Protocol, runtime_checkable - -from aai_cli.code_agent.agent import CompiledAgent -from aai_cli.code_agent.events import ( - ErrorText, - Event, - assistant_delta, - interrupt_request, - message_events, - new_messages, -) - -# Given a pending tool's name and arguments, decide whether to run it. -Approver = Callable[[str, dict[str, object]], bool] -# Receives each display event as the turn unfolds. -EventSink = Callable[[Event], None] - -# Lines that end the interactive loop. -QUIT_COMMANDS = frozenset({"/exit", "/quit", "exit", "quit"}) - -_DECLINED = "User declined to run this tool." - - -@runtime_checkable -class _SupportsStream(Protocol): - """An agent that can stream its run as incremental state snapshots. - - The real compiled graph supports this; the unit-test fakes that only implement - ``invoke`` don't, so :meth:`CodeSession._run` falls back to a single emit for them. - """ - - def stream( - self, - graph_input: object, - config: Mapping[str, object] | None, - *, - stream_mode: list[str], - ) -> Iterator[tuple[str, object]]: - """Yield ``(mode, payload)`` pairs — ``"values"`` state snapshots and ``"messages"`` deltas. - - With a *list* ``stream_mode`` langgraph tags each yield with its mode, so the caller - can render off the per-super-step ``"values"`` state while still seeing the frequent - per-token ``"messages"`` deltas (used only as a fine-grained cancellation checkpoint). - """ - - -@dataclass -class CodeSession: - """One coding conversation: a compiled agent plus the I/O seams that render it.""" - - agent: CompiledAgent - sink: EventSink - approver: Approver - thread_id: str = "code" - auto_approve: bool = False - _seen: int = field(default=0, init=False) - _cancel: threading.Event = field( - default_factory=threading.Event, - init=False, # pragma: no mutate - ) - - def _config(self) -> dict[str, object]: - return {"configurable": {"thread_id": self.thread_id}} - - def request_cancel(self) -> None: - """Ask the running turn to stop its agent loop at the next step boundary. - - Set from another thread (the TUI's Ctrl-C / Escape); the streaming loop in - :meth:`_run` and the approval loop both check it, so a long tool sequence stops - without having to kill the worker thread mid-step. - """ - self._cancel.set() - - def send(self, text: str) -> None: - """Run one user turn, resolving approvals and emitting events as each step lands. - - Events stream out incrementally (responsive UI) and :meth:`request_cancel` can stop - the loop early. A failure inside the graph (a gateway 5xx, a tool blowing up) is - surfaced as an ``ErrorText`` event rather than propagating — a single bad turn must - not crash the TUI worker or the REPL; the user can just try again. - """ - self._cancel.clear() - config = self._config() - try: - result = self._run({"messages": [{"role": "user", "content": text}]}, config) - self._resolve_interrupts(result, config) - except KeyboardInterrupt: - raise - except Exception as exc: - self.sink(ErrorText(f"{type(exc).__name__}: {exc}")) - return - - def _run(self, graph_input: object, config: dict[str, object]) -> dict[str, object]: - """Drive one graph segment, emitting events as each step completes; return the end state. - - We render the finished messages from the per-super-step ``"values"`` snapshots, and - stream the ``"messages"`` (per-token) deltas alongside them for two reasons: a live - front-end shows the reply as it's generated (emitted as ``AssistantDelta``), and the - frequent deltas give :meth:`request_cancel` a checkpoint *within* a long step — a - single model generation is one super-step, so a values-only loop couldn't break until - the whole reply landed. A double that only implements ``invoke`` (the TUI/REPL test - fakes) emits once at the end instead. - """ - if isinstance(self.agent, _SupportsStream): - last: dict[str, object] = {} - for mode, payload in self.agent.stream( - graph_input, config, stream_mode=["values", "messages"] - ): - if self._cancel.is_set(): - break - if mode == "values" and isinstance(payload, dict): - self._emit_new(payload) - last = payload - elif mode == "messages": - delta = assistant_delta(payload) - if delta is not None: - self.sink(delta) - return last - result = self.agent.invoke(graph_input, config) - self._emit_new(result) - return result - - def _resolve_interrupts( - self, result: dict[str, object], config: dict[str, object] - ) -> dict[str, object]: - """Loop approving/rejecting gated tool calls until the turn no longer pauses.""" - from langgraph.types import Command - - while True: - if self._cancel.is_set(): - return result - request = interrupt_request(result) - if request is None: - return result - actions = request.get("action_requests") - actions = actions if isinstance(actions, list) else [] - decisions = [self._decide(action) for action in actions] - result = self._run(Command(resume={"decisions": decisions}), config) - - def _decide(self, action: dict[str, object]) -> dict[str, object]: - """Ask the approver about one pending tool call and shape the resume decision.""" - name = str(action.get("name", "")) - args = action.get("args") or {} - if not isinstance(args, dict): - args = {} - if self.approver(name, args): - return {"type": "approve"} - return {"type": "reject", "message": _DECLINED} - - def _emit_new(self, result: dict[str, object]) -> None: - """Emit display events for every message added during the turn.""" - for message in new_messages(result, self._seen): - for event in message_events(message, announce_calls=self.auto_approve): - self.sink(event) - messages = result.get("messages") - if isinstance(messages, list): - self._seen = len(messages) - - -def run_repl( - session: CodeSession, *, read_line: Callable[[], str | None], initial: str | None = None -) -> None: - """Run the read-eval loop: send ``initial`` (if any), then each line ``read_line`` yields. - - Stops on EOF (``read_line`` returns ``None``) or a quit command. Blank lines are - skipped. The reader is injected so tests script a conversation without a TTY. - """ - if initial: - session.send(initial) - while True: - line = read_line() - if line is None: - return - line = line.strip() - if not line: - continue - if line in QUIT_COMMANDS: - return - session.send(line) diff --git a/aai_cli/code_agent/skills.py b/aai_cli/code_agent/skills.py deleted file mode 100644 index b0183914..00000000 --- a/aai_cli/code_agent/skills.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Import installed agent skills (notably the `assemblyai` skill) into the agent. - -`assembly setup` installs skills under the coding-agent config root -(`~/.claude/skills//SKILL.md`, honoring `CLAUDE_CONFIG_DIR`). deepagents can -surface skills to the model via progressive disclosure, but its `SkillsMiddleware` reads -them through a backend — and our main file backend is confined to the working directory. -So we give skills their *own* `FilesystemBackend` rooted at the skills directory. - -deepagents' stock skills prompt tells the model to open each `SKILL.md` with `read_file`, -but that tool is bound to the cwd-scoped backend and so can't reach a skill living under -`~/.claude/skills` (the model just gets ``File '/aai-cli/SKILL.md' not found``). We close -that gap with a dedicated read-only `read_skill` tool bound to the skills directory, and a -prompt that points the model at it instead of `read_file`. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from aai_cli.code_agent._config_root import claude_config_root - -if TYPE_CHECKING: - from langchain.agents.middleware import AgentMiddleware - from langchain_core.tools import BaseTool - -READ_SKILL_TOOL_NAME = "read_skill" - -# Skills prompt fragment. Must keep the three slots deepagents substitutes at runtime -# (`{skills_locations}`, `{skills_load_warnings}`, `{skills_list}`); the constructor -# raises if any is missing. The one behavioral change from deepagents' stock prompt is -# steering the model to `read_skill` — skills live outside the cwd sandbox, so the -# ordinary `read_file` tool can't open them. -_SKILLS_PROMPT = """## Skills - -You have a library of skills — specialized instructions and workflows for specific tasks. - -{skills_locations}{skills_load_warnings} -**Available skills:** - -{skills_list} - -**How to use a skill (progressive disclosure):** you see each skill's name, description, and -path above, but read its full instructions only when a skill matches the task. Read it with -the `read_skill` tool, passing the path shown above — e.g. `read_skill("/assemblyai/SKILL.md")` -— then follow what it says. Do **not** use `read_file` for these paths: skills live outside the -working directory, so only `read_skill` can reach them.""" - - -def skills_root() -> Path: - """Directory holding installed skills (one subdir per skill, each with SKILL.md).""" - return claude_config_root() / "skills" - - -def _has_skills(root: Path) -> bool: - """True when at least one ``//SKILL.md`` exists.""" - return root.is_dir() and any(child.joinpath("SKILL.md").is_file() for child in root.iterdir()) - - -def _read_skill_file(root: Path, path: str) -> str: - """Read ``path`` (as surfaced in the skills list) from under ``root``, guarding traversal. - - ``path`` is the backend-virtual path shown in the prompt (e.g. ``/assemblyai/SKILL.md``), - so it is resolved relative to ``root``. A path that escapes ``root`` (``..`` segments) or - names a missing file returns an error string the model can recover from rather than raising. - """ - target = (root / path.lstrip("/")).resolve() - if not target.is_relative_to(root.resolve()): - return f"Error: '{path}' is outside the skills directory." - if not target.is_file(): - return f"Error: skill file '{path}' not found." - return target.read_text(encoding="utf-8") - - -def build_skill_reader(root: Path) -> BaseTool: - """Wrap :func:`_read_skill_file` as the ``read_skill`` tool, bound to ``root``.""" - from langchain_core.tools import tool - - @tool(READ_SKILL_TOOL_NAME) - def read_skill(path: str) -> str: - """Read a skill's file (e.g. its SKILL.md) by the path shown in the skills list. - Use this — not read_file — for any path under the skills library.""" - return _read_skill_file(root, path) - - return read_skill - - -def build_skills(root: Path | None = None) -> tuple[AgentMiddleware, BaseTool] | None: - """The skills ``(middleware, read_skill tool)`` pair, or ``None`` if no skills are present. - - Returns ``None`` (rather than an empty middleware) so the caller simply omits both from - the stack when the user has run no `assembly setup` — the agent then starts with no skills - section and no `read_skill` tool instead of empty ones. The tool is paired with the - middleware because the prompt the middleware injects directs the model to it. - """ - root = root if root is not None else skills_root() - if not _has_skills(root): - return None - - from deepagents.backends import FilesystemBackend - from deepagents.middleware.skills import SkillsMiddleware - - backend = FilesystemBackend(root_dir=str(root), virtual_mode=True) - middleware = SkillsMiddleware(backend=backend, sources=["/"], system_prompt=_SKILLS_PROMPT) - return middleware, build_skill_reader(root) diff --git a/aai_cli/code_agent/store.py b/aai_cli/code_agent/store.py deleted file mode 100644 index 01b218da..00000000 --- a/aai_cli/code_agent/store.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Conversation persistence for `assembly code` (deepagents-code parity). - -deepagents-code persists sessions in a SQLite checkpoint store so a conversation can -be resumed. We do the same: a SQLite saver under the CLI's config root, keyed by a -session name (reuse the name to resume; pick a new one to start clean). Falling back to -an in-memory saver gives a single ephemeral session. -""" - -from __future__ import annotations - -import uuid -from pathlib import Path -from typing import TYPE_CHECKING - -import platformdirs - -if TYPE_CHECKING: - from langgraph.checkpoint.base import BaseCheckpointSaver - -_APP = "assemblyai" - -# Length of a generated session id — short enough to read off the splash and retype as -# ``--session `` to resume, with ample uniqueness for one user's sessions. -_SESSION_ID_LEN = 12 - - -def new_session_id() -> str: - """A fresh, unique session id so each run starts a clean conversation by default. - - `assembly code` no longer reuses a fixed ``"default"`` thread (which silently resumed the - previous conversation); each run gets its own id unless ``--session NAME`` names one to - resume. Shown on the splash as ``Thread: `` so it can be resumed later. - """ - return uuid.uuid4().hex[:_SESSION_ID_LEN] - - -def sessions_db_path() -> Path: - """Path to the SQLite file holding persisted coding sessions (dir created).""" - root = Path(platformdirs.user_data_dir(_APP)) / "code-sessions" - root.mkdir(parents=True, exist_ok=True) - return root / "sessions.sqlite" - - -def build_checkpointer(*, persist: bool) -> BaseCheckpointSaver: - """A SQLite checkpoint saver (resumable) when ``persist``, else in-memory.""" - if not persist: - from langgraph.checkpoint.memory import InMemorySaver - - return InMemorySaver() - - import sqlite3 - - from langgraph.checkpoint.sqlite import SqliteSaver - - # check_same_thread=False: the TUI drives the graph from a worker thread. - conn = sqlite3.connect(str(sessions_db_path()), check_same_thread=False) - return SqliteSaver(conn) diff --git a/aai_cli/code_agent/tui.py b/aai_cli/code_agent/tui.py deleted file mode 100644 index d5734cf6..00000000 --- a/aai_cli/code_agent/tui.py +++ /dev/null @@ -1,499 +0,0 @@ -"""A Textual terminal UI for the coding agent, modeled on deepagents-code. - -deepagents' own `code` CLI is a Textual app (a scrolling conversation transcript with -a bottom input and modal tool-approval prompts); this mirrors that design on top of -the same :class:`~aai_cli.code_agent.session.CodeSession`. The agent runs on a thread -worker (its `invoke` is synchronous), streaming display events back onto the UI thread -via ``call_from_thread``; tool approvals pause the worker on a modal screen. -""" - -from __future__ import annotations - -import itertools -import threading -import time -from pathlib import Path -from typing import TYPE_CHECKING, ClassVar - -from rich.markup import escape -from textual.app import ComposeResult -from textual.containers import Horizontal, VerticalScroll -from textual.css.query import NoMatches -from textual.screen import ModalScreen -from textual.widgets import Input, Static -from textual.worker import Worker - -from aai_cli.code_agent import banner -from aai_cli.code_agent.agent import CompiledAgent -from aai_cli.code_agent.ask_tool import AskBridge -from aai_cli.code_agent.events import ( - AssistantDelta, - AssistantText, - ErrorText, - Event, - ToolCall, - ToolResult, -) -from aai_cli.code_agent.messages import ( - AssistantMessage, - ErrorMessage, - Note, - ToolCallLine, - ToolOutput, - UserMessage, -) -from aai_cli.code_agent.modals import ApprovalScreen, AskScreen -from aai_cli.code_agent.session import CodeSession -from aai_cli.code_agent.tui_status import ( - VOICE_FRAMES, - _spinner_text, - _status_text, - copy_note, - voicebar_markup, -) -from aai_cli.code_agent.voice_ui import _VoiceIO, _VoiceLegs - -if TYPE_CHECKING: - from textual.timer import Timer - -# Glyphs cycled by the working indicator's animation (purely cosmetic). -_SPIN_FRAMES = "✶✷✸✹✺" # pragma: no mutate -# Seconds the Ctrl-C "press again to quit" hint stays armed (deepagents-code uses 3s too). -_QUIT_HINT_SECONDS = 3 # pragma: no mutate - - -class CodeAgentApp(_VoiceLegs): - """The coding-agent TUI: conversation transcript + prompt + approval/ask modals.""" - - # Flat pure-black canvas — no panel fills/gray, just the bordered prompt and a status - # line, matching the deepagents-code look (wordmark in the AssemblyAI brand blue). - CSS = f""" - Screen {{ background: #000000; }} - /* The approval/ask modals must stay see-through so the transcript shows above their - docked prompt. Their own DEFAULT_CSS sets `background: transparent`, but app CSS beats - a widget's DEFAULT_CSS — without this rule the `Screen` canvas above paints the modal - opaque black (it matches every Screen subclass) and blanks the transcript behind it. */ - ModalScreen {{ background: transparent; }} - /* The transcript is a scroll container of mounted message widgets (not a RichLog), so the - reply streams in place and tool output can expand/collapse. */ - #log {{ height: 1fr; border: none; background: #000000; padding: 1 2; }} - /* width: 100% (not the 1fr default) so the bordered box fits inside its 1-col side margins; - a docked 1fr container ignores horizontal margin and overflows, clipping the right border. */ - #promptbar {{ dock: bottom; height: 3; width: 100%; background: #000000; border: round #3a3f55; margin: 1 1; }} - #promptmark {{ width: 3; color: {banner.BRAND_HEX}; content-align: center middle; }} - #prompt {{ border: none; background: #000000; padding: 0; }} - /* Shown in place of the prompt while voice capture is on (Ctrl-V brings the prompt back). */ - #voicebar {{ dock: bottom; height: 3; background: #000000; border: round {banner.BRAND_HEX}; margin: 1 1; content-align: center middle; display: none; }} - /* In normal flow below the 1fr log, so it sits just above the docked prompt bar. */ - #spinner {{ height: 1; background: #000000; padding: 0 2; color: {banner.BRAND_HEX}; display: none; }} - /* Two rows: the mode/cwd/branch/voice line and the dim key-legend below it. */ - #status {{ dock: bottom; height: 2; background: #000000; padding: 0 1; }} - """ - TITLE = "AssemblyAI Code" - # Ctrl-C quits (in addition to Ctrl-Q); the built-in command palette is removed. - ENABLE_COMMAND_PALETTE = False - # Interrupt/quit keys follow deepagents-code: Escape interrupts the running turn (or, in - # voice mode, the active listen/readback), and Ctrl-C interrupts a running turn or active - # voice, or — when idle — quits only on a confirmed double-press. - BINDINGS: ClassVar = [ - ("escape", "interrupt", "Interrupt"), - ("ctrl+c", "quit_or_interrupt", "Interrupt / Quit"), - ("ctrl+q", "quit", "Quit"), - ("ctrl+y", "copy_last", "Copy last reply"), - ("ctrl+v", "toggle_voice", "Toggle voice"), - ("ctrl+o", "toggle_output", "Expand/collapse output"), - ] - # The voice-bar meter's animation cadence; a cosmetic value, so it's mutation-exempt. - _TICK_SECONDS: ClassVar[float] = 0.3 # pragma: no mutate - - def __init__( - self, - *, - agent: CompiledAgent, - ask_bridge: AskBridge | None = None, - auto_approve: bool = False, - initial: str | None = None, - thread_id: str = "default", - cwd: Path | None = None, - web_note: str | None = None, - voice: _VoiceIO | None = None, - ) -> None: - super().__init__() - self._agent = agent - self._ask_bridge = ask_bridge if ask_bridge is not None else AskBridge() - self._auto_approve = auto_approve - self._initial = initial - self._voice = voice # when set, spoken turns drive the prompt and replies are read back - self._voice_typed = False # flips once the mic is ruled out; then input is typed only - self._voice_paused = False # user-toggled off via Ctrl-V (distinct from a mic failure) - self._voice_phase = "listening" # listening / thinking / speaking, shown in the voice bar - self._voice_frames = itertools.cycle(VOICE_FRAMES) - self._voice_timer: Timer | None = None # animates the voice-bar meter while it's shown - self._streaming_msg: AssistantMessage | None = None # the reply widget tokens stream into - self._last_tool_output: ToolOutput | None = None # the row Ctrl+O expands/collapses - self._session_name = thread_id # not _thread_id: that shadows Textual App's int - self._cwd = cwd if cwd is not None else Path.cwd() - self._web_note = web_note - self._last_reply = "" - self._quit_pending = False # armed by a first idle Ctrl-C; a second confirms quit - self._spin_frames = itertools.cycle(_SPIN_FRAMES) - self._spin_timer: Timer | None = None - self._turn_started = 0.0 # pragma: no mutate — always reset by _start_spinner first - self._session = CodeSession( - agent=agent, - sink=self._emit_event, - approver=self._approve, - thread_id=thread_id, - auto_approve=auto_approve, - ) - - def compose(self) -> ComposeResult: - # No Header/Footer chrome — the splash is the title and the bottom status line - # the only footer, so the screen stays a flat dark canvas. - yield VerticalScroll(id="log") - # Docked before the prompt bar, so the working indicator sits just above the input. - yield Static("", id="spinner") - with Horizontal(id="promptbar"): - yield Static(">", id="promptmark") - yield Input(id="prompt", placeholder="Ask the agent to build something…") - yield Static("", id="voicebar") # filled by _render_voicebar when voice mode is shown - yield Static( - _status_text( - self._cwd, auto_approve=self._auto_approve, voice_state=self._voice_state() - ), - id="status", - ) - - def _write_splash(self) -> None: - # The whole splash is fixed copy except the session name, so this markup is safe to - # parse (only the session name — a --session value — is escaped). - rows = [f"[bold {banner.BRAND_HEX}]{row}[/]" for row in banner.wordmark()] - rows += [ - f"[dim]{banner.version()}[/dim]", - "", - f"[dim]Thread: {escape(self._session_name)}[/dim]", - "", - f"[{banner.BRAND_HEX}]{banner.READY_LINE}[/]", - f"[dim]{banner.TIP_LINE}[/dim]", - ] - self._mount("\n".join(rows)) - - def _mount(self, widget: Static | str) -> None: - """Append a transcript widget (or a markup string) and scroll it into view.""" - log = self.query_one("#log", VerticalScroll) - log.mount(Static(widget) if isinstance(widget, str) else widget) - log.scroll_end(animate=False) # pragma: no mutate — cosmetic; animate flag is unassertable - - def _note(self, text: str) -> None: - """Append a dim transcript aside (cancelling / copied / voice-off).""" - self._mount(Note(text)) - - def on_mount(self) -> None: - # Route the agent's ask_user tool through a modal (the bridge is shared with - # the tool built before this app existed). - self._ask_bridge.handler = self._ask - self._write_splash() - if self._web_note: - self.notify(self._web_note, title="Web search disabled", severity="warning", timeout=10) - # Put the cursor in the prompt so the user can type immediately (RichLog would - # otherwise hold focus and swallow keystrokes). - self.query_one("#prompt", Input).focus() - self._sync_input_mode() # in voice mode, swap the prompt for the listening affordance - if self._initial: - self._submit(self._initial) - else: - # Defer the first mic open until *after* the splash has painted. Opening PortAudio - # is a GIL-holding C call; run inline on mount it races Textual's initial render and - # the banner never flushes — it stays blank until a resize/focus forces a full - # repaint. call_after_refresh runs once the screen is on-screen, so the splash wins. - self.call_after_refresh(self._begin_listening) # in voice mode, capture first turn - - # --- event rendering (always called on the UI thread) --------------------- - - def _emit_event(self, event: Event) -> None: - """Sink for :class:`CodeSession`; marshaled onto the UI thread by the worker.""" - self.call_from_thread(self._write_event, event) - - def _write_event(self, event: Event) -> None: - if isinstance(event, AssistantDelta): - # Stream the token into the live reply widget (mounting one on the first token), - # updated in place until the authoritative AssistantText finalizes it below. - if self._streaming_msg is None: - self._streaming_msg = AssistantMessage() - self._mount(self._streaming_msg) - self._streaming_msg.stream(event.text) - self.query_one("#log", VerticalScroll).scroll_end(animate=False) # pragma: no mutate - elif isinstance(event, AssistantText): - self._last_reply = event.text # keep the raw text for clipboard copy - self._finalize_reply(event.text) - elif isinstance(event, ToolCall): - self._mount(ToolCallLine(event.name, event.args)) - elif isinstance(event, ToolResult): - self._last_tool_output = ToolOutput(event.name, event.content) - self._mount(self._last_tool_output) - elif isinstance(event, ErrorText): - self._mount(ErrorMessage(event.text)) - - def _finalize_reply(self, text: str) -> None: - """Commit the reply: finalize the streamed widget in place, or mount a fresh one.""" - if self._streaming_msg is not None: - self._streaming_msg.finalize(text) - self._streaming_msg = None - else: - msg = AssistantMessage() - self._mount(msg) - msg.finalize(text) - - def action_copy_last(self) -> None: - """Copy the most recent assistant reply to the system clipboard, noting the outcome.""" - import pyperclip - - self._note(copy_note(self._last_reply, pyperclip.copy)) - - def action_toggle_output(self) -> None: - """Ctrl-O: expand/collapse the most recent tool output (a no-op if there's none).""" - if self._last_tool_output is not None: - self._last_tool_output.toggle() - - # --- approval / ask (called on the worker thread) ------------------------- - - def _modal_result[T](self, screen: ModalScreen[T], default: T) -> T: - """Push a modal from the worker thread and block until it's dismissed.""" - done = threading.Event() - box: dict[str, T] = {"value": default} - - def _store(result: T | None) -> None: - if result is not None: - box["value"] = result - done.set() - - self.call_from_thread(self.push_screen, screen, _store) - done.wait() - return box["value"] - - def _approve(self, name: str, args: dict[str, object]) -> bool: - """Decide whether to run a gated tool, prompting unless auto-approve is on. - - Once the user picks "Auto-approve", later tool calls skip the modal entirely — - functionally the same as starting with ``--auto``. - """ - if self._auto_approve: - return True - screen = ApprovalScreen(name, args, voice=self._modal_voice()) - decision = self._modal_result(screen, default="reject") - if decision == "auto": - self._enable_auto_approve() - return True - return decision == "approve" - - def _enable_auto_approve(self) -> None: - """Switch the session to auto-approve and refresh the mode badge.""" - self._auto_approve = True - self._session.auto_approve = True - self.call_from_thread(self._refresh_status) - - def _refresh_status(self) -> None: - """Re-render the bottom status line (e.g. after the mode flips to auto or voice toggles).""" - self.query_one("#status", Static).update( - _status_text( - self._cwd, auto_approve=self._auto_approve, voice_state=self._voice_state() - ) - ) - - def _voice_state(self) -> str | None: - """``"on"``/``"off"`` for the status badge, or ``None`` when voice isn't wired up.""" - if self._voice is None: - return None - return "on" if self._voice_active() else "off" - - def action_toggle_voice(self) -> None: - """Ctrl-V: turn spoken input/readback on or off for the session. - - A no-op notice when no voice front-end exists (e.g. a piped/typed run). Re-enabling - kicks off listening again unless a turn is mid-flight (the post-turn followup will). - """ - if self._voice is None: - self.notify("Voice isn't available in this session", severity="warning") - return - self._voice_paused = not self._voice_paused - self._refresh_status() - self._sync_input_mode() # show/hide the text box vs. the listening affordance - if self._voice_paused: - self._voice.cancel() # release the mic now, don't leave a capture running unseen - self.notify("Voice off — type your request") - elif not self._turn_running(): - self.notify("Voice on — listening") - self._begin_listening() - - def _sync_input_mode(self) -> None: - """Swap the text prompt for the 'listening' affordance while voice capture is active. - - The Input stays mounted either way (it still holds the spoken transcript and the - turn-running ``disabled`` flag); only the bars' visibility flips. The prompt regains - focus whenever it's the visible input. - """ - listening = self._voice_active() - self.query_one("#promptbar", Horizontal).display = not listening - self.query_one("#voicebar", Static).display = listening - if listening: - self._render_voicebar() - if self._voice_timer is None: # animate the meter only while the bar is shown - self._voice_timer = self.set_interval(self._TICK_SECONDS, self._render_voicebar) - else: - if self._voice_timer is not None: - self._voice_timer.stop() - self._voice_timer = None - self.query_one("#prompt", Input).focus() - - def _set_voice_phase(self, phase: str) -> None: - """Switch the voice bar between listening / thinking / speaking and repaint it.""" - self._voice_phase = phase - self._render_voicebar() - - def _render_voicebar(self) -> None: - """Paint/advance the voice bar (the 0.3s timer's callback); no-op once the bar is gone.""" - try: - bar = self.query_one("#voicebar", Static) - except NoMatches: - return # a tick can fire during teardown after the bar is removed; the repaint is moot - hint = " [dim](Ctrl-V to type)[/dim]" if self._voice_phase == "listening" else "" - bar.update(voicebar_markup(self._voice_phase, next(self._voice_frames), hint=hint)) - - def _ask(self, question: str) -> str: - """Block the worker on a modal input screen and return the user's answer.""" - return self._modal_result(AskScreen(question, voice=self._modal_voice()), default="") - - def _modal_voice(self) -> _VoiceIO | None: - """The voice IO to drive a modal by speech, or ``None`` when voice isn't active.""" - return self._voice if self._voice_active() else None - - # --- interrupt / quit ----------------------------------------------------- - # Mirrors deepagents-code: Escape interrupts a running turn; Ctrl-C interrupts a running - # turn or, when idle, quits only on a confirmed double-press (so it never drops the - # conversation by accident). Ctrl-Q stays an unconditional one-press quit. - - def _turn_running(self) -> bool: - """Whether an agent turn is in flight (the prompt is disabled while one runs).""" - return self.query_one("#prompt", Input).disabled - - def _cancel_turn(self) -> bool: - """Ask the session to stop its agent loop if a turn is running; True if one was. - - Cooperative: the worker keeps running until the streaming loop sees the flag at - the next step boundary, then finishes and re-enables the prompt — so we never kill - the thread mid-step (which Textual can't do safely anyway). - """ - if not self._turn_running(): - return False - self._session.request_cancel() - self._note("cancelling…") - return True - - def _stop_voice_activity(self) -> None: - """Stop in-flight voice (a no-op when none is active). - - Interrupting the readback (speaking) stops it and resumes listening — the cancelled - speak() returns and the loop captures the next turn. Interrupting while listening - pauses voice to the text prompt, after which a second press falls through to quit. - """ - if self._voice is None or not self._voice_active(): - return - self._voice.cancel() - if self._voice_phase == "speaking": # stop talking, stay in voice mode -> re-listen - self._note("stopped — listening…") - return - self._voice_paused = True - self._refresh_status() - self._sync_input_mode() # active leg stopped -> bring the text prompt back - self._note("voice interrupted (Ctrl-V to talk again)") - - def action_interrupt(self) -> None: - """Escape: interrupt a running agent turn or in-flight voice (a no-op when idle).""" - if not self._cancel_turn(): - self._stop_voice_activity() - - def action_quit_or_interrupt(self) -> None: - """Ctrl-C: interrupt a running turn or active voice, else quit on a second press.""" - if self._cancel_turn(): - self._quit_pending = False - return - # A second press always quits — checked before stopping voice so a spoken turn can - # never trap you (the first press stops the readback and arms; the second exits). - if self._quit_pending: - self.exit() - return - self._stop_voice_activity() # stop a readback/listen if one's active (a no-op otherwise) - self._arm_quit_pending() - - def _arm_quit_pending(self) -> None: - """Arm Ctrl-C double-press-to-quit, showing a hint that expires after a few seconds.""" - self._quit_pending = True - self.notify("Press Ctrl-C again to quit", timeout=_QUIT_HINT_SECONDS) - self.set_timer(_QUIT_HINT_SECONDS, self._clear_quit_pending) - - def _clear_quit_pending(self) -> None: - self._quit_pending = False # pragma: no mutate — timer-fired reset; timing-unassertable - - # --- input loop ----------------------------------------------------------- - - def on_input_submitted(self, event: Input.Submitted) -> None: - text = event.value.strip() - event.input.value = "" - if text: - self._submit(text) - - def _submit(self, text: str) -> None: - self._mount(UserMessage(text)) - self.query_one("#prompt", Input).disabled = True - self._set_voice_phase("thinking") # voice bar reflects the turn (no-op when bar hidden) - self._start_spinner() - self._run_turn(text) - - def _run_turn(self, text: str) -> Worker[None]: - return self.run_worker( - lambda: self._session.send(text), thread=True, exclusive=True, name="agent-turn" - ) - - # --- working indicator (spinner + elapsed) -------------------------------- - - def _start_spinner(self) -> None: - """Show the working indicator and animate it while the turn runs. - - Skipped in voice mode — the voice bar already shows a "Thinking…" state, so a second - spinner would just be redundant chrome. - """ - self._turn_started = time.monotonic() - if self._voice_active(): - return - self.query_one("#spinner", Static).display = True - self._tick() - self._spin_timer = self.set_interval(0.25, self._tick) # pragma: no mutate - - def _tick(self) -> None: - """Advance the spinner one frame and refresh the elapsed-seconds readout.""" - elapsed = int(time.monotonic() - self._turn_started) - self.query_one("#spinner", Static).update(_spinner_text(elapsed, next(self._spin_frames))) - - def _stop_spinner(self) -> None: - """Stop the animation and hide the working indicator.""" - if self._spin_timer is not None: - self._spin_timer.stop() - self._spin_timer = None - self.query_one("#spinner", Static).display = False - - def on_worker_state_changed(self, event: Worker.StateChanged) -> None: - # is_running guard: a worker finishing after teardown would hit an unmounted DOM. - if event.worker.is_finished and self.is_running: - self._finish_turn() - - def _finish_turn(self) -> None: - """Wind down a completed turn: stop the spinner, re-enable input, resume voice.""" - self._stop_spinner() - if self._streaming_msg is not None: # a cancelled generation: keep what streamed in - self._finalize_reply(self._streaming_msg.text) - self.query_one("#prompt", Input).disabled = False - self._sync_input_mode() # focus the prompt (text mode) or show the listening bar - self._voice_followup() # read a spoken summary back, then listen for the next turn - - # The off-thread voice legs (_voice_active, _begin_listening, _capture_voice_turn, …) are - # inherited from _VoiceLegs; the render/toggle side stays above. diff --git a/aai_cli/code_agent/voice.py b/aai_cli/code_agent/voice.py deleted file mode 100644 index ffed3efc..00000000 --- a/aai_cli/code_agent/voice.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Voice I/O for `assembly code`: speak your request, hear the reply. - -The coding agent's default interactive mode (a TTY) captures one spoken turn via -streaming STT and reads each assistant reply back via streaming TTS. Both legs are -injected so the loop is unit-tested with fakes — no microphone, speaker, or socket. - -Readback needs streaming TTS, which only the sandbox environment exposes -(`tts.session.is_available`); in production, voice *input* still works and replies -stay on screen as text. Microphone (STT) input works in every environment. -""" - -from __future__ import annotations - -import re -import threading -from collections.abc import Callable, Iterable, Iterator -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, NoReturn, Protocol - -from aai_cli.core import client, config_builder, errors -from aai_cli.core.microphone import MicrophoneSource -from aai_cli.tts import session as tts_session -from aai_cli.tts.audio import PcmPlayer -from aai_cli.tts.session import SpeakConfig - -if TYPE_CHECKING: - from assemblyai.streaming.v3 import StreamingParameters - -# The audio-device CLIError types listen() raises when no usable microphone is present; -# the command degrades to typed input on these (see _exec._voice_read_line). They mirror -# the error_type values core.microphone attaches to its mic-open failures. -AUDIO_ERROR_TYPES = frozenset({"mic_missing", "mic_error", "audio_input_error"}) - -# Streaming TTS synthesizes at 24 kHz, the rate the readback player is opened at. -_TTS_SAMPLE_RATE = 24000 - -# The streaming STT model used to transcribe a spoken turn — the same realtime default -# `assembly stream` and `assembly agent-cascade` use. -_SPEECH_MODEL = "u3-rt-pro" - -# Reading code aloud over TTS is useless, so the readback speaks only the prose. These -# strip fenced and inline code, and the spoken summary is capped so a long reply stays brief. -_FENCED_CODE = re.compile(r"```.*?```", re.DOTALL) -_INLINE_CODE = re.compile(r"`[^`]+`") -_MAX_SPOKEN_CHARS = 600 # pragma: no mutate — a cosmetic cap on how much prose is read aloud -_ALL_CODE_READBACK = "I've updated the code — see the transcript for the details." - - -class _ReadbackInterrupted(errors.CLIError): - """Internal sentinel: raised inside the readback feed when ``cancel()`` fires mid-playback. - - Subclasses ``CLIError`` so streaming TTS re-raises it unchanged (``synthesize`` passes - ``CLIError`` straight through), letting ``speak`` abort the player and stop promptly instead - of draining the rest of the clip. It never reaches the user — ``speak`` always catches it. - """ - - def __init__(self) -> None: - # No exit_code: speak() always catches this, so the inherited default never surfaces. - super().__init__("readback interrupted", error_type="readback_interrupted") - - -def _abort_readback() -> NoReturn: - """Raise the readback sentinel — the cancel signal ``speak``'s feed acts on mid-playback.""" - raise _ReadbackInterrupted - - -def spoken_summary(text: str) -> str: - """Reduce an assistant reply to the prose worth reading aloud. - - Drops fenced and inline code, collapses whitespace, and caps the length. When the reply - was essentially all code (nothing but blocks), returns a short generic note so the - readback still says *something* rather than going silent. - """ - prose = _INLINE_CODE.sub(" ", _FENCED_CODE.sub(" ", text)) - prose = " ".join(prose.split()).strip() - if not prose: - return _ALL_CODE_READBACK - if len(prose) > _MAX_SPOKEN_CHARS: - return prose[:_MAX_SPOKEN_CHARS].rstrip() + "…" - return prose - - -class Microphone(Protocol): - """The microphone slice the listen loop drives: an iterable of PCM at a known rate.""" - - sample_rate: int - - def __iter__(self) -> Iterator[bytes]: - """Yield captured PCM16 chunks until the stream ends.""" - - -class StreamFn(Protocol): - """The streaming-STT call: ``client.stream_audio`` satisfies it structurally.""" - - def __call__( - self, - api_key: str, - source: Iterable[bytes], - *, - params: StreamingParameters, - on_turn: Callable[[object], None], - ) -> None: - """Stream ``source`` and forward each Turn event to ``on_turn``.""" - - -class SynthFn(Protocol): - """The streaming-TTS call: ``tts.session.synthesize`` satisfies it structurally. - - The return is typed ``object`` because the readback path discards it (it plays each - chunk through ``on_audio`` as it arrives), which also lets a test inject a fake that - returns nothing meaningful. - """ - - def __call__( - self, - api_key: str, - config: SpeakConfig, - *, - on_audio: Callable[[bytes, int], None], - ) -> object: - """Synthesize ``config.text``, handing each PCM chunk to ``on_audio``.""" - - -class Player(Protocol): - """The readback player: a context manager that ``feed``s PCM chunks (PcmPlayer).""" - - def __enter__(self) -> Player: - """Enter the playback context (opens the device lazily on first feed).""" - - def __exit__(self, exc_type: object, *exc: object) -> object: - """Drain on a clean exit, abort otherwise; never suppress.""" - - def feed( - self, pcm: bytes, sample_rate: int, *, cancelled: Callable[[], bool] | None = None - ) -> None: - """Play one PCM chunk, polling ``cancelled`` between writes to stop mid-chunk.""" - - -def _stt_params(sample_rate: int) -> StreamingParameters: - """StreamingParameters for capturing one spoken turn at ``sample_rate``. - - ``format_turns`` is on so the finalized turn reads like a typed prompt (punctuated - and cased) rather than raw lowercase tokens. - """ - merged = config_builder.merge_streaming_params( - flags={"speech_model": _SPEECH_MODEL, "format_turns": True, "sample_rate": sample_rate} - ) - return config_builder.construct_streaming_params(merged) - - -@dataclass -class VoiceSession: - """Speak-to-it / read-it-back I/O for one coding session, with injectable legs.""" - - api_key: str - readback: bool - mic_factory: Callable[[], Microphone] = MicrophoneSource - stream_fn: StreamFn = client.stream_audio - synth_fn: SynthFn = tts_session.synthesize - player_factory: Callable[[], Player] = PcmPlayer - _cancel: threading.Event = field( - default_factory=threading.Event, - init=False, # pragma: no mutate - ) - - def cancel(self) -> None: - """Stop an in-flight ``listen``/``speak`` so the current voice activity ends promptly. - - Set from another thread (the TUI's Ctrl-C / Escape, since the legs block on a daemon - thread): the mic gate in :meth:`listen` and the readback feed in :meth:`speak` both - check it between chunks, so listening or playback stops within a chunk rather than - running to completion. Each leg clears it on entry, so a stale cancel never preempts - the next turn. - """ - self._cancel.set() - - def listen(self) -> str | None: - """Capture one spoken turn and return its finalized transcript. - - Returns the text of the first end-of-turn the server finalizes, or ``None`` when - the microphone stream ends without one (EOF — e.g. a finite source in tests, or a - :meth:`cancel` mid-capture). The microphone is gated shut the moment a turn finalizes, - so exactly one utterance is captured per call; a real mic blocks until you speak. - """ - self._cancel.clear() - mic = self.mic_factory() - done = threading.Event() - captured: list[str] = [] - - def on_turn(event: object) -> None: - text = (getattr(event, "transcript", "") or "").strip() - if text and getattr(event, "end_of_turn", False): - captured.append(text) - done.set() - - def gated() -> Iterator[bytes]: - for chunk in mic: - if done.is_set() or self._cancel.is_set(): - return - yield chunk - - self.stream_fn(self.api_key, gated(), params=_stt_params(mic.sample_rate), on_turn=on_turn) - return " ".join(captured).strip() or None - - def speak(self, text: str) -> None: - """Read ``text`` back via streaming TTS, when readback is available. - - A no-op when readback is off (production, where streaming TTS has no host) or the - text is blank — so the caller can route every assistant reply here unconditionally. - A :meth:`cancel` from another thread stops playback promptly: the feed raises an - internal sentinel that aborts the player (discarding buffered audio) and ends synthesis. - """ - text = text.strip() - if not self.readback or not text: - return - self._cancel.clear() - config = SpeakConfig(text=text, sample_rate=_TTS_SAMPLE_RATE) - try: - with self.player_factory() as player: - - def feed(pcm: bytes, sample_rate: int) -> None: - if self._cancel.is_set(): - _abort_readback() - # Poll cancel *during* playback too: a chunk can be seconds of audio, and - # in the TUI the only cancel signal is this flag set from another thread. - player.feed(pcm, sample_rate, cancelled=self._cancel.is_set) - if self._cancel.is_set(): - _abort_readback() - - self.synth_fn(self.api_key, config, on_audio=feed) - except _ReadbackInterrupted: - pass # cancel() asked us to stop; the player aborted on the way out - - -def build_voice_session(api_key: str) -> VoiceSession: - """A voice session for the active environment. - - Readback is enabled only where streaming TTS is available (the sandbox); microphone - input is wired regardless. - """ - return VoiceSession(api_key=api_key, readback=tts_session.is_available()) diff --git a/aai_cli/code_agent/voice_ui.py b/aai_cli/code_agent/voice_ui.py deleted file mode 100644 index c4b6ad2d..00000000 --- a/aai_cli/code_agent/voice_ui.py +++ /dev/null @@ -1,132 +0,0 @@ -"""The voice front-end legs for the coding-agent TUI, split out to keep `tui.py` small. - -These are the speak-to-it / read-back mechanics that run *off* the UI thread (mic capture and -TTS readback block), marshaling back via ``call_from_thread``. They live in a mixin that -:class:`~aai_cli.code_agent.tui.CodeAgentApp` inherits, so the app stays one ``App`` with the -voice methods folded in. The render/toggle side (the voice bar, Ctrl-V) stays in `tui.py`. -""" - -from __future__ import annotations - -import threading -from typing import TYPE_CHECKING, Protocol - -from textual.app import App -from textual.widgets import Input - -from aai_cli.code_agent.voice import spoken_summary -from aai_cli.core import errors - -if TYPE_CHECKING: - from collections.abc import Callable - - -class _VoiceIO(Protocol): - """The speak-to-it / read-back slice the TUI drives; :class:`VoiceSession` satisfies it.""" - - def listen(self) -> str | None: - """Capture one spoken turn and return its transcript (``None`` on no speech).""" - - def speak(self, text: str) -> None: - """Read ``text`` back aloud (a no-op when readback is unavailable).""" - - def cancel(self) -> None: - """Stop an in-flight listen/readback so the current voice activity ends promptly.""" - - -class _VoiceLegs(App[None]): - """Mixin holding the off-thread voice capture/readback legs for ``CodeAgentApp``. - - Extends ``App`` so the inherited ``query_one``/``call_from_thread`` are typed; the voice - state and the few app methods it leans on (``_set_voice_phase``/``_sync_input_mode``/ - ``_submit``) are provided by the concrete app and declared here for the type checker. - """ - - if TYPE_CHECKING: # provided by CodeAgentApp (state set in __init__, methods defined there) - _voice: _VoiceIO | None - _voice_typed: bool - _voice_paused: bool - _last_reply: str - - def _set_voice_phase(self, phase: str) -> None: ... - def _sync_input_mode(self) -> None: ... - def _submit(self, text: str) -> None: ... - def _note(self, text: str) -> None: ... - - def _voice_active(self) -> bool: - """Voice capture is on: a session exists, the mic isn't ruled out, and it isn't paused.""" - return self._voice is not None and not self._voice_typed and not self._voice_paused - - def _spawn(self, target: Callable[[], None]) -> None: - """Run ``target`` on a daemon thread — voice legs block, so they stay off the UI thread.""" - thread = threading.Thread( - target=lambda: self._run_leg(target), - daemon=True, # pragma: no mutate — daemon flag only affects process exit, unassertable - ) - thread.start() - - def _run_leg(self, target: Callable[[], None]) -> None: - """Run one voice leg, dropping the callback error a torn-down app raises mid-flight. - - A leg calls back onto the UI thread (``call_from_thread``); if the app stops — a quit, - or a test's ``run_test`` block exiting — while the leg is mid-call, that callback raises - ``RuntimeError`` in this daemon thread, which would otherwise surface as an unhandled - thread exception (a flaky Windows CI failure). The spoken turn is moot once the app is - gone, so swallow it then; a genuine failure while the app is still live still propagates. - """ - try: - target() - except Exception: - if self.is_running: - raise - - def _begin_listening(self) -> None: - """Capture the next spoken turn on a background thread (no-op when voice is off).""" - if not self._voice_active(): - return - self._spawn(self._capture_voice_turn) - - def _voice_followup(self) -> None: - """After a turn finishes: read back a spoken summary, then listen for the next turn.""" - voice = self._voice - if voice is None or self._voice_paused: # paused via Ctrl-V: no readback, no listen - return - self._spawn(lambda: self._speak_then_listen(voice)) - - def _speak_then_listen(self, voice: _VoiceIO) -> None: - """Read a summary of the last reply aloud (no code), then capture the next spoken turn.""" - self.call_from_thread(self._set_voice_phase, "speaking") - voice.speak(spoken_summary(self._last_reply)) - self._capture_voice_turn() - - def _capture_voice_turn(self) -> None: - """Listen for one spoken turn; enter it into the prompt, or degrade to typing.""" - voice = self._voice - if voice is None or self._voice_typed or self._voice_paused: - return - self.call_from_thread(self._set_voice_phase, "listening") - try: - transcript = voice.listen() - except errors.CLIError as exc: - # A capture failure (no mic, STT error) drops voice for the rest of the session - # rather than wedging it — the user just types instead. - self._voice_typed = True - self.call_from_thread(self._notice_voice_off, exc.message) - return - # Re-check after listen(): the user may have switched to text (Ctrl-V) or interrupted - # (Escape/Ctrl-C) while this capture was blocking, in which case a turn that finalized - # in that window must not be submitted behind their back. - if transcript and self._voice_active(): - self.call_from_thread(self._enter_and_submit, transcript) - - def _notice_voice_off(self, detail: str) -> None: - """Tell the user voice input stopped and that input is now typed (UI thread).""" - self._note(f"voice input off: {detail}; type your request instead") - self._sync_input_mode() # mic ruled out -> bring the text box back - - def _enter_and_submit(self, text: str) -> None: - """Show the spoken text in the prompt, then submit it as a turn (UI thread).""" - prompt = self.query_one("#prompt", Input) - prompt.value = text - self._submit(text) - prompt.value = "" diff --git a/aai_cli/commands/agent_cascade/__init__.py b/aai_cli/commands/agent_cascade/__init__.py index afa46c3e..8265b7b8 100644 --- a/aai_cli/commands/agent_cascade/__init__.py +++ b/aai_cli/commands/agent_cascade/__init__.py @@ -61,6 +61,10 @@ def _emit_voice_list(_state: AppState, json_mode: bool) -> None: "Add your own MCP servers (none load by default)", "assembly --sandbox live --mcp-config ~/.config/mcp/servers.json", ), + ( + "Let the agent read and write files in the current directory", + "assembly --sandbox live --files", + ), ("See available voices", "assembly --sandbox live --list-voices"), ( "Print equivalent Python instead of running", @@ -167,6 +171,12 @@ def live( dir_okay=False, rich_help_panel=_PANEL_TOOLS, ), + files: bool = typer.Option( + False, + "--files", + help="Let the agent read, write, and run code in the current directory, sandboxed (writes and runs need confirmation)", + rich_help_panel=_PANEL_TOOLS, + ), device: int | None = typer.Option(None, "--device", help="Microphone device index"), list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit"), json_out: bool = options.json_option("Emit newline-delimited JSON events"), @@ -232,6 +242,7 @@ def live( language=language, tts_config=tuple(tts_config or ()), mcp_config=tuple(mcp_config or ()), + files=files, show_code=show_code, ) run_with_options(ctx, agent_cascade_exec.run_agent_cascade, opts, json=json_out) diff --git a/aai_cli/commands/agent_cascade/_exec.py b/aai_cli/commands/agent_cascade/_exec.py index d177241b..1b8e2567 100644 --- a/aai_cli/commands/agent_cascade/_exec.py +++ b/aai_cli/commands/agent_cascade/_exec.py @@ -18,12 +18,11 @@ from aai_cli import code_gen from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from aai_cli.agent.render import AgentRenderer -from aai_cli.agent_cascade import engine, mcp_tools, voices +from aai_cli.agent_cascade import engine, firecrawl_search, mcp_tools, voices from aai_cli.agent_cascade.config import DEFAULT_MAX_HISTORY, CascadeConfig from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt from aai_cli.app.agent_shared import validate_voice from aai_cli.app.context import AppState -from aai_cli.code_agent import firecrawl_search from aai_cli.core import choices, client, config_builder, env, errors, llm, signals, stdio from aai_cli.core.errors import UsageError from aai_cli.streaming import turn_presets @@ -76,6 +75,8 @@ class AgentCascadeOptions: tts_config: tuple[str, ...] # Tools: opt-in standard mcpServers JSON config files (none load by default). mcp_config: tuple[Path, ...] + # Let the agent read/write files in the launch directory (writes confirmed; off by default). + files: bool # Print the equivalent Python instead of running a conversation. show_code: bool @@ -139,6 +140,17 @@ def _warn_without_web_search(*, json_mode: bool) -> None: output.emit_warning(note, json_mode=json_mode) +def _deny_writes(name: str, args: dict[str, object]) -> bool: + """Approver for non-interactive ``--files`` runs: deny every gated write. + + File/--json/non-TTY runs have no keyboard channel to confirm a write, so writes are + declined (the model is told and moves on). Reads stay ungated — they never reach an + approver — so a piped or file-driven ``--files`` session can still read and search. + """ + del name, args + return False + + def _resolve_mcp_servers(mcp_config: tuple[Path, ...]) -> dict[str, Mapping[str, object]]: """The MCP servers for this run: only those from ``--mcp-config`` files (none by default). @@ -194,6 +206,7 @@ def _print_show_code(opts: AgentCascadeOptions, system_prompt_text: str) -> None language=opts.language, max_tokens=opts.max_tokens, format_turns=opts.format_turns, + files=opts.files, ) output.print_code(code_gen.agent_cascade(config, speech_model=opts.speech_model)) @@ -226,7 +239,15 @@ def _run_live_tui(api_key: str, opts: AgentCascadeOptions, config: CascadeConfig duplex = DuplexAudio(target_rate=SAMPLE_RATE, device=opts.device) stt_params = _build_stt_params(opts, SAMPLE_RATE) - deps = engine.CascadeDeps.real(api_key, config, audio=duplex.mic, stt_params=stt_params) + + # The TUI confirms --files writes with a y/n keypress; the closure resolves ``app`` at + # call time (it's assigned below, before any reply — hence before any approver call). + def approve_write(name: str, args: dict[str, object]) -> bool: + return app.approve_write(name, args) + + deps = engine.CascadeDeps.real( + api_key, config, audio=duplex.mic, stt_params=stt_params, approver=approve_write + ) def run_conversation(renderer: engine.Renderer) -> None: # Hand the app the session's reply-interrupt so Escape/Ctrl-C can silence a reply @@ -237,11 +258,15 @@ def run_conversation(renderer: engine.Renderer) -> None: config=config, deps=deps, on_session=lambda session: app.set_interrupt(session.interrupt_reply), + # Hands-free --files approvals: the engine routes the next spoken transcript during an + # approval pause to the open modal (which applies the grammar / destructive-tier gate). + on_approval_voice=app.submit_voice_approval, ) app = LiveAgentApp( run_conversation=run_conversation, on_stop=duplex.close, + on_toggle_listen=duplex.toggle_listening, web_note=_web_search_note(), ) app.run(mouse=False) @@ -305,6 +330,7 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: llm_extra=llm_extra, tts_extra=tts_extra, mcp_servers=mcp_servers, + files=opts.files, ) if _should_use_tui(from_file=from_file, json_mode=json_mode, text_mode=text_mode): @@ -318,7 +344,10 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: renderer, source=opts.source, sample=opts.sample, device=opts.device, from_file=from_file ) stt_params = _build_stt_params(opts, sample_rate) - deps = engine.CascadeDeps.real(api_key, config, audio=audio, stt_params=stt_params) + # Non-interactive (file/--json/non-TTY): writes can't be confirmed, so deny them; reads work. + deps = engine.CascadeDeps.real( + api_key, config, audio=audio, stt_params=stt_params, approver=_deny_writes + ) try: # SIGTERM stops the cascade as cleanly as Ctrl-C, so an external supervisor # (Hammerspoon, a service manager, a wrapper's `kill`) can end the session. diff --git a/aai_cli/commands/code/__init__.py b/aai_cli/commands/code/__init__.py deleted file mode 100644 index 6045e4f9..00000000 --- a/aai_cli/commands/code/__init__.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import typer - -from aai_cli import command_registry, help_panels -from aai_cli.app.context import run_with_options -from aai_cli.code_agent import store -from aai_cli.code_agent.prompt import DEFAULT_MODEL -from aai_cli.commands.code import _exec as code_exec -from aai_cli.core import llm as gateway -from aai_cli.ui.help_text import examples_epilog - -app = typer.Typer() - -SPEC = command_registry.CommandModuleSpec( - panel=help_panels.CODE, - order=10, # pragma: no mutate -- sparse rank; a +-1 shift is order-equivalent - commands=("code",), -) - - -@app.command( - rich_help_panel=help_panels.CODE, - epilog=examples_epilog( - [ - ("Start a coding session in the current directory", "assembly code"), - ("Kick off with an initial task", 'assembly code "add a --verbose flag"'), - ("Run without approval prompts", 'assembly code --auto "fix the failing test"'), - ("Point at another project", "assembly code --dir ../service"), - ] - ), -) -def code( - ctx: typer.Context, - prompt: str | None = typer.Argument( - None, help="Initial task for the agent. Omit to just open the session" - ), - model: str = typer.Option( - DEFAULT_MODEL, "--model", help="LLM Gateway model", autocompletion=gateway.complete_model - ), - directory: Path = typer.Option( - Path(), - "--dir", - "-C", - help="Working directory the agent's file and shell tools operate in", - file_okay=False, - exists=True, - ), - auto: bool = typer.Option( - False, "--auto", "-y", help="Skip approval prompts and run every tool automatically" - ), - docs: bool = typer.Option( - True, "--docs/--no-docs", help="Connect to the AssemblyAI docs MCP server for reference" - ), - skills: bool = typer.Option( - True, "--skills/--no-skills", help="Load installed agent skills (e.g. the assemblyai skill)" - ), - web: bool = typer.Option( - True, "--web/--no-web", help="Enable Firecrawl web search when FIRECRAWL_API_KEY is set" - ), - memory: bool = typer.Option( - True, "--memory/--no-memory", help="Load and persist the agent's long-term memory" - ), - session: str | None = typer.Option( - None, - "--session", - help="Resume a named session. Default: a new unique session each run", - ), - persist: bool = typer.Option( - True, "--persist/--fresh", help="Persist the session to disk (--fresh: ephemeral)" - ), - tui: bool = typer.Option( - True, "--tui/--no-tui", help="Use the full-screen TUI (off: a plain read-eval loop)" - ), - voice: bool = typer.Option( - True, - "--voice/--no-voice", - help="Speak to the agent and hear replies read back (readback needs the sandbox)", - ), -) -> None: - """Run a terminal coding agent backed by the AssemblyAI LLM Gateway - - An autonomous coding agent (built on the deepagents SDK) that reads, writes, - and edits files, runs shell commands, searches the AssemblyAI docs, and can - invoke the 'assembly' CLI itself — all in the working directory. It talks - only to the AssemblyAI LLM Gateway. Mutating actions ask for approval unless - you pass --auto. - - In an interactive terminal it defaults to voice: speak your request (mic -> - streaming STT) and the agent's replies are read back aloud (sandbox only). - Pass --no-voice for the keyboard TUI, or pipe input for the headless loop. - """ - opts = code_exec.CodeOptions( - prompt=prompt, - model=model, - root_dir=directory, - auto=auto, - docs=docs, - skills=skills, - web=web, - memory=memory, - # No --session given -> a fresh unique id, so each run starts a clean conversation - # instead of silently resuming the previous one. - session=session if session is not None else store.new_session_id(), - persist=persist, - tui=tui, - voice=voice, - ) - run_with_options(ctx, code_exec.run_code, opts, json=False) diff --git a/aai_cli/commands/code/_exec.py b/aai_cli/commands/code/_exec.py deleted file mode 100644 index 43610fc3..00000000 --- a/aai_cli/commands/code/_exec.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Run logic for `assembly code`: the options/run split (see AGENTS.md). - -The command module parses argv into a frozen ``CodeOptions`` and hands it here. This -assembles the gateway model; the agent's tools (the `assembly` CLI tool, the docs MCP, -web search, URL fetch, ask-user); the skills + long-term-memory middleware; a persistent -SQLite checkpointer; and the compiled deepagents graph, then drives it through one of -three front-ends: a voice loop (the default in a TTY — speak your request, hear the -reply), the full-screen Textual TUI, or a plain Rich read-eval loop (headless). -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING - -import typer -from rich.markup import escape - -from aai_cli.app.context import AppState -from aai_cli.code_agent.agent import CompiledAgent, build_agent -from aai_cli.code_agent.ask_tool import AskBridge, build_ask_tool -from aai_cli.code_agent.cli_tool import build_cli_tool, run_assembly -from aai_cli.code_agent.docs_mcp import load_docs_tools -from aai_cli.code_agent.events import AssistantText, Event -from aai_cli.code_agent.fetch_tool import build_fetch_tool -from aai_cli.code_agent.firecrawl_search import FIRECRAWL_API_KEY_ENV, build_web_search_tool -from aai_cli.code_agent.memory import build_memory_middleware -from aai_cli.code_agent.model import build_model -from aai_cli.code_agent.prompt import DEFAULT_MODEL -from aai_cli.code_agent.render import RichRenderer -from aai_cli.code_agent.session import CodeSession, EventSink, run_repl -from aai_cli.code_agent.skills import build_skills -from aai_cli.code_agent.store import build_checkpointer -from aai_cli.code_agent.voice import ( - AUDIO_ERROR_TYPES, - VoiceSession, - build_voice_session, - spoken_summary, -) -from aai_cli.core import env, errors, stdio -from aai_cli.ui import output - -if TYPE_CHECKING: - from langchain.agents.middleware import AgentMiddleware - from langchain_core.tools import BaseTool - - -@dataclass(frozen=True) -class CodeOptions: - """Every `assembly code` flag as plain data.""" - - prompt: str | None - model: str = DEFAULT_MODEL - root_dir: Path = Path() - auto: bool = False - docs: bool = True - skills: bool = True - web: bool = True - memory: bool = True - session: str = "default" - persist: bool = True - tui: bool = True - voice: bool = True - - -def _assemble_tools(api_key: str, opts: CodeOptions, bridge: AskBridge) -> list[BaseTool]: - """The agent's extra tools: the CLI tool, docs MCP, web search, URL fetch, ask-user.""" - tools: list[BaseTool] = [ - build_cli_tool(lambda args: run_assembly(args, api_key=api_key)), - build_fetch_tool(), - build_ask_tool(bridge), - ] - if opts.docs: - tools.extend(load_docs_tools()) - if opts.web: - search = build_web_search_tool() - if search is not None: - tools.append(search) - return tools - - -def _assemble_middlewares(opts: CodeOptions) -> list[AgentMiddleware]: - """The long-term memory middleware (skills are wired in :func:`_build_agent`, since the - skills middleware pairs with a tool).""" - middlewares: list[AgentMiddleware] = [] - if opts.memory: - middlewares.append(build_memory_middleware()) - return middlewares - - -def _build_agent(api_key: str, opts: CodeOptions, bridge: AskBridge) -> CompiledAgent: - """Wire the gateway model + tools + middlewares + checkpointer into the agent.""" - tools = _assemble_tools(api_key, opts, bridge) - middlewares = _assemble_middlewares(opts) - # Skills add both a middleware (the skills prompt section) and the `read_skill` tool the - # prompt directs the model to; load the middleware ahead of memory to match prior order. - skills = build_skills() if opts.skills else None - if skills is not None: - middleware, reader = skills - middlewares.insert(0, middleware) - tools.append(reader) - return build_agent( - model=build_model(api_key, model=opts.model), - root_dir=opts.root_dir.resolve(), - tools=tools, - middlewares=middlewares, - checkpointer=build_checkpointer(persist=opts.persist), - auto_approve=opts.auto, - ) - - -def _confirm(name: str, args: dict[str, object]) -> bool: - """Headless approval: print the pending tool call and read a y/N from stdin.""" - rendered = ", ".join(f"{key}={value!r}" for key, value in args.items()) - # escape() the tool name/args: they're echoed for approval but may contain "[" that - # Rich would parse as markup (or raise on). The user still sees the full action. - output.error_console.print(output.warn(f"Run {escape(name)}({escape(rendered)})? [y/N] ")) - try: - answer = input().strip().lower() - except EOFError: - return False - return answer in {"y", "yes"} - - -def _ask_repl(question: str) -> str: - """Headless ask-user: print the agent's question and read the answer from stdin.""" - output.console.print(output.heading(f"Agent asks: {escape(question)}")) - try: - return input("» ") - except EOFError: - return "" - - -def _read_line() -> str | None: - """Read one prompt line; ``None`` on EOF (Ctrl-D) to end the loop.""" - try: - return input("» ") - except EOFError: - return None - - -def _web_note(opts: CodeOptions) -> str | None: - """The "web search disabled" notice when --web is on but no Firecrawl key is set.""" - if opts.web and not env.get(FIRECRAWL_API_KEY_ENV): - return ( - "FIRECRAWL_API_KEY is not set, so web search is disabled. " - "Get a key at https://firecrawl.dev" - ) - return None - - -def _run_tui( - agent: CompiledAgent, - opts: CodeOptions, - bridge: AskBridge, - *, - voice: VoiceSession | None = None, -) -> None: - from aai_cli.code_agent.tui import CodeAgentApp - - # mouse=False leaves terminal mouse reporting off, so native text selection (and - # copy/paste) works in the transcript and prompt; the UI is fully keyboard-driven. - # ``voice`` (when set) routes spoken turns into the prompt and reads summaries back. - CodeAgentApp( - agent=agent, - ask_bridge=bridge, - auto_approve=opts.auto, - initial=opts.prompt, - thread_id=opts.session, - cwd=opts.root_dir.resolve(), - web_note=_web_note(opts), - voice=voice, - ).run(mouse=False) - - -def _print_repl_banner(opts: CodeOptions) -> None: - from aai_cli.code_agent import banner - - for row in banner.wordmark(): - output.console.print(f"[{banner.BRAND_HEX}]{row}[/]", highlight=False) - output.console.print(output.muted(banner.version())) - output.console.print(output.muted(f"Thread: {opts.session}")) - output.console.print(banner.READY_LINE, style=banner.BRAND_HEX, highlight=False) - output.console.print(output.muted(banner.TIP_LINE)) - - -def _run_repl(agent: CompiledAgent, opts: CodeOptions, bridge: AskBridge) -> None: - _print_repl_banner(opts) - bridge.handler = _ask_repl - session = CodeSession( - agent=agent, - sink=RichRenderer(), - approver=_confirm, - thread_id=opts.session, - auto_approve=opts.auto, - ) - run_repl(session, read_line=_read_line, initial=opts.prompt) - - -def _announce_voice(renderer: RichRenderer, voice: VoiceSession) -> None: - """One-time voice-mode notice, naming whether replies are read back (sandbox) or not.""" - if voice.readback: - renderer.notice( - "Voice mode on: speak your request; replies are read back aloud. Ctrl-C to quit." - ) - else: - renderer.notice( - "Voice mode on: speak your request. Readback needs the sandbox (streaming TTS), " - "so replies show as text. Ctrl-C to quit." - ) - - -def _voice_sink(renderer: RichRenderer, voice: VoiceSession) -> EventSink: - """Render every event, and read a spoken *summary* of each reply back aloud (no code).""" - - def sink(event: Event) -> None: - renderer(event) - if isinstance(event, AssistantText): - voice.speak(spoken_summary(event.text)) - - return sink - - -def _voice_read_line(voice: VoiceSession, renderer: RichRenderer) -> Callable[[], str | None]: - """A read-line that captures a spoken turn, degrading to typed input if no mic exists. - - The first time the microphone can't be opened (no device, sounddevice missing) it - prints a one-line notice and switches to ``input()`` for the rest of the session, so a - voice-default run on a mic-less box still works instead of erroring out. - """ - state = {"typed": False} - - def read_line() -> str | None: - if state["typed"]: - return _read_line() - renderer.notice("Listening… (speak now)") - try: - line = voice.listen() - except errors.CLIError as exc: - if exc.error_type not in AUDIO_ERROR_TYPES: - raise - renderer.notice(f"No microphone available ({exc.message}); switching to typed input.") - state["typed"] = True - return _read_line() - if line: - renderer.notice(f"Heard: {line}") - return line - - return read_line - - -def _run_voice(agent: CompiledAgent, opts: CodeOptions, bridge: AskBridge, api_key: str) -> None: - _print_repl_banner(opts) - voice = build_voice_session(api_key) - renderer = RichRenderer() - _announce_voice(renderer, voice) - bridge.handler = _ask_repl # spoken clarifications still fall back to the keyboard - session = CodeSession( - agent=agent, - sink=_voice_sink(renderer, voice), - approver=_confirm, - thread_id=opts.session, - auto_approve=opts.auto, - ) - run_repl(session, read_line=_voice_read_line(voice, renderer), initial=opts.prompt) - - -def run_code(opts: CodeOptions, state: AppState, *, json_mode: bool) -> None: - """Start an `assembly code` coding session from already-parsed flags.""" - del json_mode # the coding agent has no JSON output mode; it is a live session - api_key = state.resolve_api_key() - bridge = AskBridge() - agent = _build_agent(api_key, opts, bridge) - interactive = stdio.stdout_is_tty() and stdio.stdin_is_tty() - try: - if opts.voice and opts.tui and interactive: - # The default: spoken turns are entered into the TUI prompt; summaries read back. - _run_tui(agent, opts, bridge, voice=build_voice_session(api_key)) - elif opts.voice and interactive: - _run_voice(agent, opts, bridge, api_key) # --no-tui: the plain voice REPL - elif opts.tui and interactive: - _run_tui(agent, opts, bridge) - else: - _run_repl(agent, opts, bridge) - except KeyboardInterrupt: - raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None diff --git a/aai_cli/commands/stream/__init__.py b/aai_cli/commands/stream/__init__.py index 64b4b38b..09b09add 100644 --- a/aai_cli/commands/stream/__init__.py +++ b/aai_cli/commands/stream/__init__.py @@ -21,7 +21,7 @@ commands=("stream",), ) -DEFAULT_SPEECH_MODEL = SpeechModel.u3_rt_pro +DEFAULT_SPEECH_MODEL = SpeechModel.universal_3_5_pro @app.command( diff --git a/aai_cli/help_panels.py b/aai_cli/help_panels.py index cbfd2c89..92ddd20b 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -14,7 +14,6 @@ from __future__ import annotations QUICK_START = "Quick Start" # zero-to-running onboarding: onboard -CODE = "Coding Agent" # the terminal coding agent: code BUILD = "Build an App" # scaffold a new project: init TRANSCRIPTION = "Run AssemblyAI" # use AssemblyAI directly: transcribe, stream, agent, llm HISTORY = "History" # browse past work: transcripts, sessions @@ -25,7 +24,7 @@ # panel it belongs to (`SPEC` in aai_cli/commands/*.py — see aai_cli.command_registry), # and ordering within a panel comes from that module's sparse `order` rank, so adding # a command never edits a shared ordering list; only a brand-new panel touches this. -PANEL_ORDER = (QUICK_START, CODE, BUILD, TRANSCRIPTION, SETUP, HISTORY, ACCOUNT) +PANEL_ORDER = (QUICK_START, BUILD, TRANSCRIPTION, SETUP, HISTORY, ACCOUNT) # Option panels group a single command's flags within its own ``--help``. The # `transcribe` command exposes 40+ options; without panels they render as one diff --git a/aai_cli/skills/aai-cli/references/transcription.md b/aai_cli/skills/aai-cli/references/transcription.md index 087fd0b2..eb176596 100644 --- a/aai_cli/skills/aai-cli/references/transcription.md +++ b/aai_cli/skills/aai-cli/references/transcription.md @@ -56,7 +56,7 @@ channel (`-you.wav`, `-system.wav`) beside the shared transcript. High-value flags (run `assembly stream --help` for the full set): - Capture: `--device N`, `--sample-rate HZ`, `--encoding pcm_s16le|pcm_mulaw`. -- Model/turns: `--speech-model` (default `u3-rt-pro`), `--format-turns`, +- Model/turns: `--speech-model` (default `universal-3-5-pro`), `--format-turns`, `--include-partial-turns`, `--end-of-turn-confidence`, `--min-turn-silence`, `--max-turn-silence`, `--vad-threshold`. - Features: `--speaker-labels`, `--max-speakers`, `--keyterms-prompt`, diff --git a/docs/superpowers/plans/2026-06-22-live-datetime-tool.md b/docs/superpowers/plans/2026-06-22-live-datetime-tool.md new file mode 100644 index 00000000..8d09e113 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-datetime-tool.md @@ -0,0 +1,292 @@ +# Date/time tool for `assembly live` — 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:** Give the `assembly live` voice agent a keyless, always-present `get_current_datetime` tool that reports the current local date and time. + +**Architecture:** A new `aai_cli/agent_cascade/datetime_tool.py` wraps the system clock (via an injected `Clock` seam) as a zero-argument LangChain `BaseTool`, formatting a short speakable string, and is wired into the live deepagents graph through the three tool hooks in `brain.py`. Mirrors `weather_tool.py` minus the network/error-handling (the clock can't fail). + +**Tech Stack:** Python 3.12+, stdlib `datetime`, LangChain `@tool`, deepagents, pytest. Tests are hermetic via the injected `Clock` (no real clock). + +## Global Constraints + +- `from __future__ import annotations` at module top; modern typing (`X | None`). +- Cross-platform `strftime` only: NO `%-d` / `%-I` (they break on Windows, where the suite also runs). Use `%d` / `%I` (zero-padded). +- The single non-determinism is an injected `Clock` callable (default `_now`); tests pass a fixed `datetime` — no real clock. +- NO try/except / no blind `except Exception` (the clock has no failure mode) → therefore NO `pyproject.toml` `BLE001` change. +- Internal helper docstrings keep trailing periods; the tool's own docstring is its model-facing description. +- Gate rules: iterate with targeted `uv run pytest`. The full `./scripts/check.sh` is deferred to the human (the branch carries unrelated WIP and can't be cleanly gated mid-flight). Commit with `AAI_ALLOW_COMMIT=1` and stage ONLY this feature's files (never `git add -A`). `uv` is safe-chain-wrapped and may emit a `EPERM listen` error in some sandboxes — retry once. + +--- + +### Task 1: The `datetime_tool.py` module (get_current_datetime, standalone) + +**Files:** +- Create: `aai_cli/agent_cascade/datetime_tool.py` +- Test: `tests/test_agent_cascade_datetime.py` + +**Interfaces:** +- Produces (Task 2 relies on these exact names): `DATETIME_TOOL_NAME = "get_current_datetime"` (str); `Clock = Callable[[], datetime]`; `build_datetime_tool(now: Clock = _now) -> BaseTool` (tool named `get_current_datetime`, zero args, returns `str`); `_format(now: datetime) -> str`, `_now() -> datetime` (module-private). + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_agent_cascade_datetime.py`: + +```python +"""Tests for the keyless local date/time tool behind `assembly live`. + +The tool's only non-determinism is the injected ``Clock`` callable, so the whole +flow is deterministic with no real clock (and pytest-socket stays armed — no I/O). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from aai_cli.agent_cascade import datetime_tool + +# A fixed, timezone-aware instant: Monday, 2026-06-22 14:30 at a fixed -07:00 offset. +# A fixed offset (not a named zone) keeps %Z deterministic cross-platform without tzdata. +_FIXED = datetime(2026, 6, 22, 14, 30, tzinfo=timezone(timedelta(hours=-7))) +_EXPECTED = "It's Monday, June 22, 2026 at 02:30 PM UTC-07:00." + + +# --- _format ----------------------------------------------------------------- + + +def test_format_renders_exact_speakable_string(): + assert datetime_tool._format(_FIXED) == _EXPECTED + + +# --- _now (default seam) ----------------------------------------------------- + + +def test_now_returns_timezone_aware_datetime(): + n = datetime_tool._now() + assert isinstance(n, datetime) + # astimezone() makes it aware; a naive datetime (mutation dropping it) fails here. + assert n.tzinfo is not None + + +# --- build_datetime_tool ----------------------------------------------------- + + +def test_tool_is_named_get_current_datetime(): + tool = datetime_tool.build_datetime_tool(now=lambda: _FIXED) + assert tool.name == datetime_tool.DATETIME_TOOL_NAME + + +def test_tool_returns_formatted_current_datetime(): + tool = datetime_tool.build_datetime_tool(now=lambda: _FIXED) + assert tool.invoke({}) == _EXPECTED +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /tmp/claude-501/aai-datetime-wt && uv run pytest tests/test_agent_cascade_datetime.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'aai_cli.agent_cascade.datetime_tool'`. + +- [ ] **Step 3: Write the module** + +Create `aai_cli/agent_cascade/datetime_tool.py`: + +```python +"""A keyless local date/time tool for the `assembly live` voice agent. + +Reports the current local date and time so the live agent can answer "what time is +it?", "what's today's date?", or "what day is it?". It needs no network and no API +key — just the system clock — making it, like the weather tool, always present. + +The only non-determinism is the :data:`Clock` seam (a ``() -> datetime`` callable), +injected in tests so the flow is deterministic with no real clock. Everything else +(the spoken formatting) is pure and tested directly. There is no failure mode to +handle: reading the local clock cannot fail, so the tool returns unconditionally. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +# The registered tool name. ``brain.py`` keys its UI label and capability phrase off +# this, so a test pins it. +DATETIME_TOOL_NAME = "get_current_datetime" + +# A clock returns the current instant. Injected in tests (the only non-determinism). +Clock = Callable[[], datetime] + + +def _now() -> datetime: + """Return the current local time as a timezone-aware datetime (the default clock).""" + return datetime.now().astimezone() + + +def _format(now: datetime) -> str: + """Render ``now`` as one short, speakable date+time string. + + Uses only cross-platform ``strftime`` codes (no ``%-d``/``%-I``, which break on + Windows). Zero-padded day/hour is fine — the model reads the string aloud. + """ + return now.strftime("It's %A, %B %d, %Y at %I:%M %p %Z.") + + +def build_datetime_tool(now: Clock = _now) -> BaseTool: + """Wrap the local clock as the ``get_current_datetime`` tool (``now`` injectable).""" + from langchain_core.tools import tool + + @tool(DATETIME_TOOL_NAME) + def get_current_datetime() -> str: + """Get the current local date and time. Use when asked the date, the day of the + week, or the time.""" + return _format(now()) + + return get_current_datetime +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /tmp/claude-501/aai-datetime-wt && uv run pytest tests/test_agent_cascade_datetime.py -q` +Expected: PASS (4 passed). If `test_format_renders_exact_speakable_string` fails on the weekday or `%p`/`%Z` text, confirm the actual `strftime` output and reconcile the EXPECTED literal (2026-06-22 is a Monday; a fixed `-07:00` offset renders `%Z` as `UTC-07:00`; the C locale renders `%p` as `PM`). + +- [ ] **Step 5: Verify lint/types and commit** + +```bash +cd /tmp/claude-501/aai-datetime-wt +uv run ruff check aai_cli/agent_cascade/datetime_tool.py tests/test_agent_cascade_datetime.py +uv run pyright aai_cli/agent_cascade/datetime_tool.py +git add aai_cli/agent_cascade/datetime_tool.py tests/test_agent_cascade_datetime.py +AAI_ALLOW_COMMIT=1 git commit -m "feat: local date/time tool module for assembly live" +``` +(Ignore editor "unknown import symbol" diagnostics on the brand-new module — those are stale-index false positives; `uv run pyright` is authoritative.) + +--- + +### Task 2: Wire `get_current_datetime` into the live agent (`brain.py`) + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` (import; `_TOOL_LABELS`; `_tool_capabilities`; `build_live_tools`) +- Test: `tests/test_agent_cascade_brain.py` (update the two EXACT-assertion `build_live_tools` tests; add label + capability tests) + +**Interfaces:** +- Consumes from Task 1: `datetime_tool.DATETIME_TOOL_NAME`, `datetime_tool.build_datetime_tool`. +- Produces: `build_live_tools()` includes the datetime tool always; `_tool_label("get_current_datetime") == "Checking the time"`; `build_system_prompt` advertises the date/time capability. + +- [ ] **Step 1: Update the existing exact-assertion tests + add new tests (failing)** + +In `tests/test_agent_cascade_brain.py`: + +Change the existing import `from aai_cli.agent_cascade import brain, weather_tool` to also import `datetime_tool`: +```python +from aai_cli.agent_cascade import brain, datetime_tool, weather_tool +``` + +Update `test_build_live_tools_has_weather_and_web_search_when_keyed` — its exact-set assertion must include the datetime tool: +```python + assert sorted(names) == sorted( + [brain.WEB_SEARCH_TOOL_NAME, weather_tool.WEATHER_TOOL_NAME, datetime_tool.DATETIME_TOOL_NAME] + ) +``` + +Replace `test_build_live_tools_is_just_weather_without_firecrawl_key` (rename — it is no longer "just weather"): +```python +def test_build_live_tools_has_weather_and_datetime_without_firecrawl_key(monkeypatch): + monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: None) + # No FIRECRAWL_API_KEY -> no web search, but the keyless weather + datetime tools load. + names = [tool.name for tool in brain.build_live_tools()] + assert names == [weather_tool.WEATHER_TOOL_NAME, datetime_tool.DATETIME_TOOL_NAME] +``` + +Add two new tests at the end of the file: +```python +def test_datetime_tool_advertised_in_system_prompt(): + prompt = brain.build_system_prompt( + "persona", tools=[_NamedTool(datetime_tool.DATETIME_TOOL_NAME)] + ) + assert "current date and time" in prompt + + +def test_tool_label_maps_datetime(): + assert brain._tool_label(datetime_tool.DATETIME_TOOL_NAME) == "Checking the time" +``` + +> NOTE: This plan quotes `brain.py`/test code as of base `5a6a88c`. If the branch has advanced, do NOT blindly apply literal replacements — read the CURRENT files and make the minimal edits achieving the four wiring changes; in particular re-check the exact `build_live_tools` assertions and any `_tool_capabilities` ordering test, and update every assertion that pins the exact toolset. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /tmp/claude-501/aai-datetime-wt && uv run pytest tests/test_agent_cascade_brain.py -q` +Expected: FAIL on the two updated `build_live_tools` tests (datetime tool not built yet), `test_datetime_tool_advertised_in_system_prompt` (phrase absent), and `test_tool_label_maps_datetime` (falls back to `"Using get_current_datetime"`). + +- [ ] **Step 3: Wire `brain.py` — import** + +Add `datetime_tool` to the existing `from aai_cli.agent_cascade import weather_tool` line: +```python +from aai_cli.agent_cascade import datetime_tool, weather_tool +``` + +- [ ] **Step 4: Wire `brain.py` — `_TOOL_LABELS`** + +Add the datetime label to `_TOOL_LABELS`: +```python +_TOOL_LABELS = { + WEB_SEARCH_TOOL_NAME: "Searching the web", + weather_tool.WEATHER_TOOL_NAME: "Checking the weather", + datetime_tool.DATETIME_TOOL_NAME: "Checking the time", +} +``` + +- [ ] **Step 5: Wire `brain.py` — `_tool_capabilities`** + +After the weather `if` block, add the datetime capability: +```python + if weather_tool.WEATHER_TOOL_NAME in names: + capabilities.append("tell someone the current weather and short forecast for a place") + if datetime_tool.DATETIME_TOOL_NAME in names: + capabilities.append("tell you the current date and time") + return capabilities +``` + +- [ ] **Step 6: Wire `brain.py` — `build_live_tools`** + +Add the lazy import and include the datetime tool in the always-present list: +```python + from aai_cli.agent_cascade.datetime_tool import build_datetime_tool + from aai_cli.agent_cascade.weather_tool import build_weather_tool + from aai_cli.code_agent.firecrawl_search import build_web_search_tool + + tools: list[BaseTool] = [build_weather_tool(), build_datetime_tool()] + search = build_web_search_tool() + if search is not None: + tools.append(search) + return tools +``` +Also update the `build_live_tools` docstring's first sentence to name the datetime tool (e.g. "the keyless weather and date/time tools, plus Firecrawl web search when ``FIRECRAWL_API_KEY`` is set."). + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cd /tmp/claude-501/aai-datetime-wt && uv run pytest tests/test_agent_cascade_brain.py tests/test_agent_cascade_datetime.py -q` +Expected: PASS (all green). + +- [ ] **Step 8: Verify lint/types and commit** + +```bash +cd /tmp/claude-501/aai-datetime-wt +uv run ruff check aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py +uv run pyright aai_cli/agent_cascade/brain.py +git add aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py +AAI_ALLOW_COMMIT=1 git commit -m "feat: wire get_current_datetime tool into assembly live" +``` +(The committed `test_agent_cascade_brain.py` has pre-existing pyright errors from a `_NamedTool` test double and `build_completer`/`build_streamer` protocol mismatches; confirm your change adds only the same `_NamedTool`-convention kind, nothing new.) + +--- + +## Self-Review + +**Spec coverage:** new module + `DATETIME_TOOL_NAME`/`Clock`/`_now`/`_format`/`build_datetime_tool` → Task 1; always-present keyless wiring → Task 2 Step 6; `_tool_capabilities` phrase → Task 2 Step 5; `_TOOL_LABELS` "Checking the time" → Task 2 Step 4; no error handling / no BLE001 → honored (no try/except in the module); cross-platform strftime → Task 1 Step 3 + Global Constraints; hermetic clock-seam tests incl. exact-string + tz-aware assertions → Tasks 1 & 2. + +**Placeholder scan:** none — every step carries full code/commands. + +**Type consistency:** `DATETIME_TOOL_NAME`, `Clock`, `build_datetime_tool(now=…)`, `_format`, `_now` names match between Task 1 (definition) and Task 2 (consumption); `build_live_tools` order `[weather, datetime, search?]` matches the updated keyless test assertion. diff --git a/docs/superpowers/plans/2026-06-22-live-file-readwrite.md b/docs/superpowers/plans/2026-06-22-live-file-readwrite.md new file mode 100644 index 00000000..cc656015 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-file-readwrite.md @@ -0,0 +1,1084 @@ +# `assembly live` File Read/Write Implementation Plan + +> **✅ REBASED onto the streaming reply architecture (2026-06-22).** The streaming +> pipeline has landed: `build_completer`/`_run_graph`/`_complete_within` are gone; +> the reply leg is now `brain.build_streamer` → `stream_reply` yielding +> `SpeechDelta`/`ToolNotice`, consumed by the engine on a producer thread + `queue.Queue` +> with a monotonic deadline (`CascadeSession._consume`/`_next_event`/`_pump`). Tasks 3 +> and 4 below have been rewritten for it; Tasks 1, 2, 5, 6, 7 carry over. +> +> **Verified mechanism (the basis for Task 3):** with a gated graph +> (`interrupt_on={"write_file": True, "edit_file": True}` + `InMemorySaver`), streaming +> `stream_mode="messages"` yields the tool-call AIMessage and then the stream **ends** — +> the write does not run. `graph.get_state(config).interrupts[0].value["action_requests"]` +> then holds `[{"name", "args", …}]`. Resuming via +> `graph.stream(Command(resume={"decisions": [{"type": "approve"}]}), config, stream_mode="messages")` +> executes the write and continues the turn; `get_state(config).next` empties when done. +> +> **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 `assembly live` read, write, and search files in its launch directory, opt-in behind `--files`, with writes confirmed by a `y/n` keypress in the voice TUI. + +**Architecture:** `assembly live`'s deepagents brain already binds the filesystem toolset (`read_file`/`write_file`/`edit_file`/`ls`/`glob`/`grep`) — but against an in-memory backend, so today it touches ephemeral graph state, not disk, and is unadvertised. The flag flips three switches in `aai_cli/agent_cascade/brain.py::build_graph`: (1) point the backend at the real cwd via `FilesystemBackend(root_dir=cwd, virtual_mode=True)`; (2) gate `write_file`/`edit_file` with `interrupt_on` + an `InMemorySaver` checkpointer; (3) advertise the capability in the system prompt. The brain's completer resolves write interrupts through an injected `Approver` (the exact pattern `aai_cli/code_agent/session.py` uses); the voice TUI supplies it by reusing `code_agent.modals.ApprovalScreen`, and headless runs auto-deny. + +**Tech Stack:** Python 3.12+, deepagents / langgraph / langchain, Typer, Textual, pytest + syrupy, `uv`. + +## Global Constraints + +- **Opt-in.** New boolean flag `--files`, default off. With it off, behavior is byte-for-byte unchanged (default in-memory backend, no gating, nothing advertised). +- **Reads ungated, incl. `grep`.** `read_file`/`ls`/`glob`/`grep` auto-approve. Only `write_file`/`edit_file` are gated. +- **Writes confirmed via TUI keypress** (`y`/`a`/`n`). Headless / non-TTY runs **auto-deny** writes. +- **Rooted at cwd**, `FilesystemBackend(root_dir=str(Path.cwd()), virtual_mode=True)` — traversal escapes blocked. +- **No shell.** `execute` stays bound but inert (no sandbox backend); it is **not** advertised and **not** gated. +- **Reply timeout excludes human-approval wait** — with `--files` on, the reply leg runs without the 60s wall-clock backstop (a keypress may take arbitrarily long). +- **Repo gates:** TDD; `from __future__ import annotations` atop every module; modern typing (`X | None`); errors→stderr/data→stdout; help copy terse, imperative, **no trailing period**; `--help`/TUI snapshots regenerated with `--snapshot-update`, never hand-edited. The CI gate (`./scripts/check.sh`) enforces 100% patch coverage **and** a diff-scoped mutation gate — assert behavior that would *fail* if a changed line broke, not just execute it. Run the full gate to green before the final commit. +- **Commit discipline:** the pre-commit hook blocks `git commit` unless `./scripts/check.sh` last passed for the current tree. Per-task commits during iteration may use `AAI_ALLOW_COMMIT=1 git commit …`; the **final** task runs the full gate and commits without the override. + +--- + +### Task 1: Real-cwd backend + write-gating in `build_graph` + +Add the `files` config knob and make `build_graph` swap to a real-cwd `FilesystemBackend` with write-gating + checkpointer when it's set. Isolate the gating decision in a pure, directly-testable helper (`_graph_kwargs`) so we never introspect langgraph internals. + +**Files:** +- Modify: `aai_cli/agent_cascade/config.py` (add `files` field) +- Modify: `aai_cli/agent_cascade/brain.py` (`_WRITE_TOOLS`, `_build_fs_backend`, `_graph_kwargs`, wire into `build_graph`) +- Test: `tests/test_agent_cascade_config.py`, `tests/test_agent_cascade_brain.py` + +**Interfaces:** +- Produces: `CascadeConfig.files: bool` (default `False`); `brain._WRITE_TOOLS: tuple[str, ...] = ("write_file", "edit_file")`; `brain._build_fs_backend() -> BackendProtocol`; `brain._graph_kwargs(config: CascadeConfig, *, backend_factory: Callable[[], object] = _build_fs_backend) -> dict[str, object]` returning `{}` when `not config.files` and `{"backend", "interrupt_on", "checkpointer"}` when set. + +- [ ] **Step 1: Write the failing config test** + +In `tests/test_agent_cascade_config.py`: + +```python +def test_files_defaults_off(): + from aai_cli.agent_cascade.config import CascadeConfig + + assert CascadeConfig().files is False +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `uv run pytest tests/test_agent_cascade_config.py::test_files_defaults_off -q` +Expected: FAIL — `AttributeError: 'CascadeConfig' object has no attribute 'files'`. + +- [ ] **Step 3: Add the config field** + +In `aai_cli/agent_cascade/config.py`, inside `CascadeConfig` (after `format_turns`): + +```python + # Opt-in: let the agent read/write files in the launch directory (writes are gated). + files: bool = False +``` + +- [ ] **Step 4: Write the failing `_graph_kwargs` tests** + +In `tests/test_agent_cascade_brain.py`: + +```python +def test_graph_kwargs_empty_when_files_off(): + from aai_cli.agent_cascade import brain + from aai_cli.agent_cascade.config import CascadeConfig + + assert brain._graph_kwargs(CascadeConfig(files=False)) == {} + + +def test_graph_kwargs_gates_writes_and_roots_backend_at_cwd(monkeypatch, tmp_path): + from deepagents.backends import FilesystemBackend + + from aai_cli.agent_cascade import brain + from aai_cli.agent_cascade.config import CascadeConfig + + monkeypatch.chdir(tmp_path) + kwargs = brain._graph_kwargs(CascadeConfig(files=True)) + + backend = kwargs["backend"] + assert isinstance(backend, FilesystemBackend) + # FilesystemBackend resolves the root to `cwd`; virtual_mode blocks traversal escapes. + from pathlib import Path + + assert Path(backend.cwd) == tmp_path.resolve() + assert backend.virtual_mode is True + # Only the mutating file tools are gated; reads (incl. grep) stay ungated. + assert kwargs["interrupt_on"] == {"write_file": True, "edit_file": True} + assert "execute" not in kwargs["interrupt_on"] + assert kwargs["checkpointer"] is not None +``` + +- [ ] **Step 5: Run them, verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -k graph_kwargs -q` +Expected: FAIL — `AttributeError: module 'aai_cli.agent_cascade.brain' has no attribute '_graph_kwargs'`. + +- [ ] **Step 6: Implement the helpers and wire `build_graph`** + +In `aai_cli/agent_cascade/brain.py`, add the import near the top: + +```python +from pathlib import Path +``` + +Add module-level constant (near `_TOOL_LABELS`): + +```python +# The mutating file tools gated behind human approval when --files is on (reads — incl. grep — +# stay ungated). Matches the code agent's write-tool names so the same approval flow applies. +_WRITE_TOOLS = ("write_file", "edit_file") +``` + +Add the backend factory + kwargs helper (above `build_graph`): + +```python +def _build_fs_backend() -> object: + """A deepagents filesystem backend rooted at the launch directory. + + ``virtual_mode=True`` maps the model's ``/``-rooted paths under cwd and blocks traversal + escapes — the same containment ``assembly code`` gets from its ``LocalShellBackend``. This + is a filesystem (not sandbox) backend, so the always-bound ``execute`` tool stays inert. + """ + from deepagents.backends import FilesystemBackend + + return FilesystemBackend(root_dir=str(Path.cwd()), virtual_mode=True) + + +def _graph_kwargs( + config: CascadeConfig, *, backend_factory: Callable[[], object] = _build_fs_backend +) -> dict[str, object]: + """The extra ``create_deep_agent`` kwargs that turn on real-cwd files + write-gating. + + Empty when ``--files`` is off, so the graph is built exactly as before. When on: a real-cwd + backend, ``interrupt_on`` pausing only the mutating tools for human approval, and an + in-memory checkpointer (interrupt/resume needs one). ``backend_factory`` is the test seam. + """ + if not config.files: + return {} + from langgraph.checkpoint.memory import InMemorySaver + + return { + "backend": backend_factory(), + "interrupt_on": dict.fromkeys(_WRITE_TOOLS, True), + "checkpointer": InMemorySaver(), + } +``` + +Then in `build_graph`, replace the `return create_deep_agent(...)` call with: + +```python + return create_deep_agent( + model=model, + tools=builtin + extra, + system_prompt=build_system_prompt(config.system_prompt, tools=builtin, extra_tools=extra), + **_graph_kwargs(config), + ) +``` + +(`Callable` is already imported in `brain.py`.) + +- [ ] **Step 7: Run the tests, verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_config.py::test_files_defaults_off tests/test_agent_cascade_brain.py -k graph_kwargs -q` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add aai_cli/agent_cascade/config.py aai_cli/agent_cascade/brain.py tests/test_agent_cascade_config.py tests/test_agent_cascade_brain.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): real-cwd filesystem backend + write-gating behind --files" +``` + +--- + +### Task 2: Advertise the file capability + speakable tool labels + +Tell the model it can read/write/search files (only when `--files` is on), and give the file tools speakable affordance labels so the live UI shows "Writing a file…" instead of sitting silent. + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` (`build_system_prompt` gains `files`; `_TOOL_LABELS` additions) +- Test: `tests/test_agent_cascade_brain.py` + +**Interfaces:** +- Consumes: `CascadeConfig.files` (Task 1). +- Produces: `brain.build_system_prompt(persona: str, *, tools, extra_tools=(), files: bool = False) -> str`; expanded `brain._TOOL_LABELS`. + +- [ ] **Step 1: Write the failing tests** + +In `tests/test_agent_cascade_brain.py`: + +```python +def test_system_prompt_advertises_files_when_enabled(): + prompt = brain.build_system_prompt("You are a helper.", tools=[], files=True) + assert "read" in prompt and "write" in prompt and "files" in prompt + assert "working directory" in prompt + + +def test_system_prompt_omits_files_when_disabled(): + prompt = brain.build_system_prompt("You are a helper.", tools=[], files=False) + # No tools and no files -> the no-tools guidance, which must not claim file access. + assert "working directory" not in prompt + + +def test_tool_label_for_write_is_speakable(): + assert brain._tool_label("write_file") == "Writing a file" + assert brain._tool_label("grep") == "Searching files" +``` + +- [ ] **Step 2: Run them, verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -k "system_prompt_advertises_files or system_prompt_omits_files or tool_label_for_write" -q` +Expected: FAIL — `build_system_prompt() got an unexpected keyword argument 'files'`. + +- [ ] **Step 3: Implement** + +In `aai_cli/agent_cascade/brain.py`, extend `_TOOL_LABELS`: + +```python +_TOOL_LABELS = { + WEB_SEARCH_TOOL_NAME: "Searching the web", + "read_file": "Reading a file", + "write_file": "Writing a file", + "edit_file": "Editing a file", + "ls": "Listing files", + "glob": "Finding files", + "grep": "Searching files", +} +``` + +Add the capability phrase constant (near `_NO_TOOLS_GUIDANCE`): + +```python +# Advertised when --files is on, so the model knows it can touch the launch directory (and the +# spoken tail still keeps replies short). Writes pause for the user's y/n; reads are immediate. +_FILE_CAPABILITY = "read, write, and search files in your working directory" +``` + +Change `build_system_prompt` to accept `files` and fold the phrase into the capability clause: + +```python +def build_system_prompt( + persona: str, + *, + tools: Sequence[BaseTool], + extra_tools: Sequence[BaseTool] = (), + files: bool = False, +) -> str: + capabilities = _tool_capabilities(tools) + extra = _extra_capability(extra_tools) + if extra is not None: + capabilities.append(extra) + if files: + capabilities.append(_FILE_CAPABILITY) + if not capabilities: + return f"{persona}\n\n{_NO_TOOLS_GUIDANCE}" + guidance = ( + f"You can use tools to help answer: {_join_clause(capabilities)}. Reach for a " + "tool when a question needs fresh or external information; answer directly and " + "instantly when you already know. Only offer to do what these tools allow — don't " + f"say you'll search the web or look something up unless it's listed here. {_SPOKEN_TAIL}" + ) + return f"{persona}\n\n{guidance}" +``` + +Update the `build_graph` call from Task 1 to pass `files=config.files`: + +```python + system_prompt=build_system_prompt( + config.system_prompt, tools=builtin, extra_tools=extra, files=config.files + ), +``` + +- [ ] **Step 4: Run the tests, verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -k "system_prompt or tool_label" -q` +Expected: PASS (existing prompt tests still pass — `files` defaults to `False`). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): advertise file capability + speakable file tool labels" +``` + +--- + +### Task 3: Resolve write-approval interrupts in the completer + +When the gated graph pauses on a write, ask an injected `Approver` and resume with approve/reject — looping until the turn finishes. Reuse `code_agent.events.interrupt_request`. Use a fresh per-turn `thread_id` so the checkpointer never accumulates state across the cascade's full-history-per-turn calls. + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` (`Approver`, `build_completer` gains `approver`; `_run_graph`/`_drive_graph` gain `config`; new `_resolve_writes`/`_decide`) +- Test: `tests/test_agent_cascade_brain.py` + +**Interfaces:** +- Consumes: `brain._WRITE_TOOLS`, `CascadeConfig.files` (Task 1); `aai_cli.code_agent.events.interrupt_request`. +- Produces: `brain.Approver = Callable[[str, dict[str, object]], bool]`; `brain.build_completer(api_key, config, *, graph=None, approver: Approver | None = None) -> Callable[..., str]`. + +- [ ] **Step 1: Write the failing approval tests** + +In `tests/test_agent_cascade_brain.py` (the `FakeChatModel` + `create_deep_agent` helpers already exist in this file). Add a real gated graph builder and two tests: + +```python +def _gated_graph(model: BaseChatModel): + """A real deepagents graph that gates write_file (mirrors --files), for approval tests.""" + from deepagents import create_deep_agent + from deepagents.backends import FilesystemBackend + from langgraph.checkpoint.memory import InMemorySaver + + return create_deep_agent( + model=model, + backend=FilesystemBackend(root_dir="/", virtual_mode=True), + interrupt_on={"write_file": True, "edit_file": True}, + checkpointer=InMemorySaver(), + system_prompt="be a friendly live agent", + ) + + +def _write_then_done(): + """A model that first calls write_file, then (after resume) answers in plain text.""" + call = AIMessage( + content="", + tool_calls=[{"name": "write_file", "args": {"file_path": "/notes.txt", "content": "hi"}, "id": "w1"}], + ) + return FakeChatModel(responses=[call, AIMessage(content="Saved your note.")]) + + +def test_write_is_approved_then_resumes(monkeypatch): + asked: list[tuple[str, dict]] = [] + + def approve(name, args): + asked.append((name, args)) + return True + + graph = _gated_graph(_write_then_done()) + complete = brain.build_completer("k", CascadeConfig(files=True), graph=graph, approver=approve) + reply = complete([{"role": "user", "content": "save a note"}]) + assert reply == "Saved your note." + assert asked and asked[0][0] == "write_file" + + +def test_write_is_rejected_without_approval(): + graph = _gated_graph( + FakeChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[{"name": "write_file", "args": {"file_path": "/n.txt", "content": "x"}, "id": "w1"}], + ), + AIMessage(content="Okay, I won't save it."), + ] + ) + ) + complete = brain.build_completer( + "k", CascadeConfig(files=True), graph=graph, approver=lambda name, args: False + ) + reply = complete([{"role": "user", "content": "save a note"}]) + assert reply == "Okay, I won't save it." +``` + +- [ ] **Step 2: Run them, verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -k "write_is_approved or write_is_rejected" -q` +Expected: FAIL — `build_completer() got an unexpected keyword argument 'approver'`. + +- [ ] **Step 3: Implement the approver wiring** + +In `aai_cli/agent_cascade/brain.py`, add the import + `itertools` and type alias near the top: + +```python +import itertools +``` + +Below `_FLOW_LOG`/imports, add: + +```python +# Decide whether a gated write may run (front-end supplied). Mirrors the code agent's Approver. +Approver = Callable[[str, dict[str, object]], bool] + +# Message handed back to the model when the user declines a write (matches the code agent's copy). +_DECLINED = "User declined to run this tool." +``` + +Rewrite `build_completer` to thread the approver and a fresh per-turn config: + +```python +def build_completer( + api_key: str, + config: CascadeConfig, + *, + graph: CompiledAgent | None = None, + approver: Approver | None = None, +) -> Callable[..., str]: + """A ``complete_reply`` for the cascade engine backed by the deepagents graph. + + When ``--files`` gates writes, the graph pauses on a write; ``approver`` decides and the + turn resumes (see :func:`_resolve_writes`). Each turn uses a fresh ``thread_id`` so the + checkpointer never accumulates the cascade's full-history-per-turn input across turns. + ``graph``/``approver`` are injected in tests. + """ + resolved = build_graph(api_key, config) if graph is None else graph + turn_ids = itertools.count() + + def complete_reply( + messages: list[ChatCompletionMessageParam], + on_tool: Callable[[str], None] | None = None, + ) -> str: + conversation = [message for message in messages if message.get("role") != "system"] + run_config = ( + {"configurable": {"thread_id": f"live-{next(turn_ids)}"}} if config.files else None + ) + return _reply_text(_run_graph(resolved, conversation, on_tool, approver, run_config)) + + return complete_reply +``` + +Thread `approver`/`config` through `_run_graph` and `_drive_graph`, and add the resolution loop: + +```python +def _run_graph( + graph: CompiledAgent, + conversation: list[ChatCompletionMessageParam], + on_tool: Callable[[str], None] | None = None, + approver: Approver | None = None, + config: dict[str, object] | None = None, +) -> dict[str, object]: + try: + result = _drive_graph(graph, {"messages": conversation}, on_tool, config) + return _resolve_writes(graph, result, approver, on_tool, config) + except CLIError: + raise + except Exception as exc: + raise CLIError( + f"the agent couldn't complete the turn: {exc}", error_type="agent_brain_error" + ) from exc + + +def _resolve_writes( + graph: CompiledAgent, + result: dict[str, object], + approver: Approver | None, + on_tool: Callable[[str], None] | None, + config: dict[str, object] | None, +) -> dict[str, object]: + """Loop approving/rejecting gated writes until the turn no longer pauses. + + A no-op when nothing is gated (``--files`` off): ``interrupt_request`` returns ``None`` and + the initial result is returned unchanged. + """ + from langgraph.types import Command + + from aai_cli.code_agent.events import interrupt_request + + while True: + request = interrupt_request(result) + if request is None: + return result + actions = request.get("action_requests") + actions = actions if isinstance(actions, list) else [] + decisions = [_decide(action, approver) for action in actions] + result = _drive_graph(graph, Command(resume={"decisions": decisions}), on_tool, config) + + +def _decide(action: dict[str, object], approver: Approver | None) -> dict[str, object]: + """Ask the approver about one pending write and shape the resume decision (reject if none).""" + name = str(action.get("name", "")) + args = action.get("args") or {} + if not isinstance(args, dict): + args = {} + if approver is not None and approver(name, args): + return {"type": "approve"} + return {"type": "reject", "message": _DECLINED} +``` + +Update `_drive_graph` to accept and forward `config` (replace its body's `None`): + +```python +def _drive_graph( + graph: CompiledAgent, + graph_input: object, + on_tool: Callable[[str], None] | None = None, + config: dict[str, object] | None = None, +) -> dict[str, object]: + if (on_tool is not None or debuglog.active()) and hasattr(graph, "stream"): + last: dict[str, object] = {} + seen = 0 + for chunk in graph.stream(graph_input, config, stream_mode="values"): + seen = _log_flow(chunk, seen, on_tool) + last = chunk + return last + return graph.invoke(graph_input, config) +``` + +(Note: `_drive_graph`'s `graph_input` is now `object` — it accepts a `Command` on resume.) + +- [ ] **Step 4: Run the tests, verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -q` +Expected: PASS (all, including the pre-existing completer tests — `approver`/`config` default to `None`, so the non-`files` path is unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): resolve write-approval interrupts in the reply completer" +``` + +--- + +### Task 4: Thread the approver through the engine + drop the timeout when files are on + +`CascadeDeps.real` passes the approver to `build_completer`; the reply leg runs without the 60s backstop when `--files` is on (a keypress can pause arbitrarily long). + +**Files:** +- Modify: `aai_cli/agent_cascade/engine.py` (`CascadeDeps.real` gains `approver`; `_complete_within` accepts `timeout: float | None`; `_generate_reply` chooses the timeout) +- Test: `tests/test_agent_cascade_engine.py`, `tests/_cascade_fakes.py` (only if a helper needs the new kwarg) + +**Interfaces:** +- Consumes: `brain.Approver`, `brain.build_completer(..., approver=...)` (Task 3); `CascadeConfig.files` (Task 1). +- Produces: `CascadeDeps.real(api_key, config, *, audio, stt_params, approver: brain.Approver | None = None)`; `CascadeSession._complete_within(messages, timeout: float | None) -> str` (runs inline when `timeout is None`). + +- [ ] **Step 1: Write the failing tests** + +In `tests/test_agent_cascade_engine.py`: + +```python +def test_complete_within_runs_inline_when_no_timeout(): + # With --files on the reply leg runs with no wall-clock deadline (human approval can pause), + # so complete_reply runs on the *calling* thread rather than a timeout child thread. + seen: list[int] = [] + + def reply(messages, on_tool=None): + seen.append(threading.get_ident()) + return "ok" + + session, _r, _p = make_session(complete_reply=reply, config=CascadeConfig(files=True)) + out = session._complete_within([{"role": "user", "content": "hi"}], None) + assert out == "ok" + assert seen == [threading.get_ident()] # ran inline, not on a child thread + + +def test_complete_within_uses_child_thread_when_timed(): + seen: list[int] = [] + + def reply(messages, on_tool=None): + seen.append(threading.get_ident()) + return "ok" + + session, _r, _p = make_session(complete_reply=reply) + session._complete_within([{"role": "user", "content": "hi"}], 60.0) + assert seen and seen[0] != threading.get_ident() # ran on the timeout child thread + + +def test_real_passes_approver_to_completer(monkeypatch): + captured: dict[str, object] = {} + + def fake_build_completer(api_key, config, *, approver=None): + captured["approver"] = approver + return lambda messages, on_tool=None: "" + + monkeypatch.setattr(engine.brain, "build_completer", fake_build_completer) + sentinel = lambda name, args: True + engine.CascadeDeps.real( + "k", CascadeConfig(files=True), audio=iter([]), stt_params=object(), approver=sentinel + ) + assert captured["approver"] is sentinel +``` + +- [ ] **Step 2: Run them, verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_engine.py -k "complete_within or real_passes_approver" -q` +Expected: FAIL — `_complete_within()` signature mismatch / `real()` has no `approver` kwarg. + +- [ ] **Step 3: Implement** + +In `aai_cli/agent_cascade/engine.py`, make `_complete_within` accept `float | None` and run inline when `None`: + +```python + def _complete_within( + self, messages: list[ChatCompletionMessageParam], timeout: float | None + ) -> str: + """Run the blocking reply leg, optionally under a wall-clock backstop. + + ``timeout=None`` (used when ``--files`` gates writes) runs ``complete_reply`` inline: + a write pauses for a human ``y/n`` keypress that may take arbitrarily long, so the + 60s backstop must not fire. Otherwise the leg runs on a throwaway daemon thread and is + cut off after ``timeout`` so a stuck network leg can't hang the turn forever. + """ + if timeout is None: + return self.deps.complete_reply(messages, on_tool=self.renderer.tool_call) + replies: list[str] = [] + failures: list[CLIError] = [] + + def run() -> None: + try: + replies.append(self.deps.complete_reply(messages, on_tool=self.renderer.tool_call)) + except CLIError as exc: + failures.append(exc) + + worker = threading.Thread(target=run, daemon=True) # pragma: no mutate + worker.start() + worker.join(timeout) + if worker.is_alive(): + raise CLIError( + f"the agent took longer than {timeout:.0f}s to respond and was cut off", + error_type="agent_timeout", + ) + if failures: + raise failures[0] + return replies[0] +``` + +In `_generate_reply`, choose the timeout by `files`: + +```python + timeout = None if self.config.files else _REPLY_TIMEOUT_SECONDS + try: + reply = self._complete_within(messages, timeout) +``` + +In `CascadeDeps.real`, add the `approver` parameter and pass it through: + +```python + @classmethod + def real( + cls, + api_key: str, + config: CascadeConfig, + *, + audio: Iterable[bytes], + stt_params: StreamingParameters, + approver: brain.Approver | None = None, + ) -> CascadeDeps: + def run_stt(on_turn: Callable[[object], None]) -> None: + client.stream_audio(api_key, audio, params=stt_params, on_turn=on_turn) + + complete_reply = brain.build_completer(api_key, config, approver=approver) + + def synthesize(text: str) -> bytes: + spec = SpeakConfig( + text=text, + voice=config.voice, + language=config.language, + sample_rate=TTS_SAMPLE_RATE, + extra=config.tts_extra, + ) + return tts_session.synthesize(api_key, spec).pcm + + return cls(run_stt=run_stt, complete_reply=complete_reply, synthesize=synthesize) +``` + +- [ ] **Step 4: Run the tests, verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_engine.py -q` +Expected: PASS (existing timeout test still green — the timed branch is unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/engine.py tests/test_agent_cascade_engine.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): pass write approver through engine; skip reply timeout when files on" +``` + +--- + +### Task 5: TUI write-approval modal (reuse `ApprovalScreen`) + +Give `LiveAgentApp` an `approve_write` that blocks the cascade worker on the code agent's `ApprovalScreen` (keyboard `y`/`a`/`n`) and returns the decision. `a` (auto) approves all later writes this session. + +**Files:** +- Modify: `aai_cli/agent_cascade/tui.py` (`_auto_approve_writes`, `_modal_result`, `approve_write`, transparent-modal CSS) +- Test: `tests/test_live_tui.py` + +**Interfaces:** +- Consumes: `aai_cli.code_agent.modals.ApprovalScreen` (a `ModalScreen[str]` returning `"approve"`/`"auto"`/`"reject"`). +- Produces: `LiveAgentApp.approve_write(name: str, args: dict[str, object]) -> bool`. + +- [ ] **Step 1: Write the failing tests** + +In `tests/test_live_tui.py`: + +```python +def test_approve_write_returns_true_on_approve(monkeypatch): + app = _app() + monkeypatch.setattr(app, "_modal_result", lambda screen, default: "approve") + assert app.approve_write("write_file", {"file_path": "/n.txt"}) is True + + +def test_approve_write_returns_false_on_reject(monkeypatch): + app = _app() + monkeypatch.setattr(app, "_modal_result", lambda screen, default: "reject") + assert app.approve_write("write_file", {"file_path": "/n.txt"}) is False + + +def test_approve_write_auto_skips_later_prompts(monkeypatch): + app = _app() + calls: list[int] = [] + monkeypatch.setattr( + app, "_modal_result", lambda screen, default: calls.append(1) or "auto" + ) + assert app.approve_write("write_file", {"file_path": "/a.txt"}) is True + assert app.approve_write("edit_file", {"file_path": "/b.txt"}) is True + assert calls == [1] # the second write was auto-approved without a modal +``` + +- [ ] **Step 2: Run them, verify they fail** + +Run: `uv run pytest tests/test_live_tui.py -k approve_write -q` +Expected: FAIL — `LiveAgentApp` has no attribute `approve_write`. + +- [ ] **Step 3: Implement** + +In `aai_cli/agent_cascade/tui.py`, add to the imports: + +```python +import threading +``` + +and (with the other `code_agent` imports): + +```python +from aai_cli.code_agent.modals import ApprovalScreen +``` + +Add the transparent-modal rule to the `CSS` block (so the bottom-docked modal shows the transcript above it, matching the code TUI): + +```css + /* The approval modal docks at the bottom and must stay see-through (the transcript shows + above it), overriding ModalScreen's default opaque DEFAULT_CSS. */ + ModalScreen { background: transparent; } +``` + +In `__init__`, add the auto-approve latch (near `self._interrupt`): + +```python + self._auto_approve_writes = False # set once the user picks "auto" on a write prompt +``` + +Add the approval methods (in the interrupt/quit section): + +```python + def _modal_result[T](self, screen: ModalScreen[T], default: T) -> T: + """Push a modal from the cascade worker thread and block until it's dismissed.""" + done = threading.Event() + box: dict[str, T] = {"value": default} + + def _store(result: T | None) -> None: + if result is not None: + box["value"] = result + done.set() + + self.call_from_thread(self.push_screen, screen, _store) + done.wait() + return box["value"] + + def approve_write(self, name: str, args: dict[str, object]) -> bool: + """Decide a gated write by a y/n keypress; True to allow. + + Called on the cascade worker thread (via the brain's approver). Blocks on a bottom-docked + approval modal so the user confirms a file write by keyboard — the one place the + hands-free session pauses for input. "Auto" approves every later write this session. + """ + if self._auto_approve_writes: + return True + decision = self._modal_result(ApprovalScreen(name, args), default="reject") + if decision == "auto": + self._auto_approve_writes = True + return True + return decision == "approve" +``` + +Add the `ModalScreen` import to the typing block: + +```python +from textual.screen import ModalScreen +``` + +- [ ] **Step 4: Run the tests, verify they pass** + +Run: `uv run pytest tests/test_live_tui.py -k approve_write -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/tui.py tests/test_live_tui.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): TUI write-approval modal reusing code agent's ApprovalScreen" +``` + +--- + +### Task 6: `--files` flag + command wiring (TUI approver, headless deny) + +Expose the flag, carry it into `CascadeConfig`, wire the TUI's `approve_write` into the cascade deps, and auto-deny writes on the headless path. + +**Files:** +- Modify: `aai_cli/commands/agent_cascade/__init__.py` (the `--files` option + epilog example) +- Modify: `aai_cli/commands/agent_cascade/_exec.py` (`AgentCascadeOptions.files`, config wiring, deps approver on both paths) +- Test: `tests/test_agent_cascade_command.py`, `tests/test_live_tui.py` +- Snapshot: `tests/__snapshots__/test_snapshots_help_run.ambr` (regenerated) + +**Interfaces:** +- Consumes: `CascadeConfig.files` (Task 1), `CascadeDeps.real(..., approver=...)` (Task 4), `LiveAgentApp.approve_write` (Task 5). +- Produces: `AgentCascadeOptions.files: bool`; `_exec._deny_writes(name, args) -> bool` (always `False`). + +- [ ] **Step 1: Write the failing tests** + +In `tests/test_agent_cascade_command.py` (the `_opts` helper builds an `AgentCascadeOptions`; add `files=False` to its defaults dict so existing callers stay valid — see Step 3): + +```python +def test_files_flag_flows_into_config(monkeypatch): + captured: dict[str, object] = {} + monkeypatch.setattr(_exec.tts_session, "require_available", lambda _c: None) + monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k") + monkeypatch.setattr(_exec, "FileSource", lambda src: types.SimpleNamespace(sample_rate=16000)) + monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") + monkeypatch.setattr(_exec, "_should_use_tui", lambda **_: False) + monkeypatch.setattr(_exec, "_warn_without_web_search", lambda **_: None) + monkeypatch.setattr( + _exec, "_open_audio", lambda *a, **k: (iter([]), _exec.NullPlayer(), 16000) + ) + + def fake_real(api_key, cfg, *, audio, stt_params, approver=None): + captured["files"] = cfg.files + captured["approver"] = approver + return _exec.engine.CascadeDeps( + run_stt=lambda on_turn: None, complete_reply=lambda m, on_tool=None: "", synthesize=lambda t: b"" + ) + + monkeypatch.setattr(_exec.engine.CascadeDeps, "real", fake_real) + monkeypatch.setattr(_exec.engine, "run_cascade", lambda **kwargs: None) + run_agent_cascade(_opts(source="clip.wav", files=True), _state(), json_mode=False) + assert captured["files"] is True + # Headless path: writes can't be confirmed, so the approver denies them. + assert captured["approver"]("write_file", {}) is False + + +def test_deny_writes_always_false(): + assert _exec._deny_writes("write_file", {"file_path": "/x"}) is False +``` + +(Use the file's existing `_state()`/`AppState` helper; if absent, mirror the `AppState` construction in `test_run_wires_deps_and_invokes_cascade`.) + +In `tests/test_live_tui.py`, assert the TUI path wires the app's approver: + +```python +def test_tui_wires_app_approver(monkeypatch): + captured: dict[str, object] = {} + monkeypatch.setattr(_exec, "DuplexAudio", lambda **k: types.SimpleNamespace( + mic=iter([]), player=_exec.NullPlayer(), close=lambda: None, toggle_listening=lambda: True + )) + monkeypatch.setattr(_exec, "_build_stt_params", lambda opts, rate: object()) + + def fake_real(api_key, cfg, *, audio, stt_params, approver=None): + captured["approver"] = approver + return _exec.engine.CascadeDeps( + run_stt=lambda on_turn: None, complete_reply=lambda m, on_tool=None: "", synthesize=lambda t: b"" + ) + + monkeypatch.setattr(_exec.engine.CascadeDeps, "real", fake_real) + monkeypatch.setattr(_exec.engine, "run_cascade", lambda **kwargs: None) + + class _DummyApp: + def __init__(self, **kwargs): + self.approve_write = lambda name, args: True + def run(self, **kwargs): + pass + error = None + + monkeypatch.setattr("aai_cli.agent_cascade.tui.LiveAgentApp", _DummyApp) + _exec._run_live_tui("k", _opts(files=True), CascadeConfig(files=True)) + assert captured["approver"] is not None and captured["approver"]("write_file", {}) is True +``` + +- [ ] **Step 2: Run them, verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_command.py -k "files_flag or deny_writes" tests/test_live_tui.py -k tui_wires_app_approver -q` +Expected: FAIL — `AgentCascadeOptions` has no `files` / `_exec` has no `_deny_writes`. + +- [ ] **Step 3: Implement the flag + wiring** + +In `aai_cli/commands/agent_cascade/__init__.py`, add the option to `live(...)` (in the Tools panel, after `mcp_config`): + +```python + files: bool = typer.Option( + False, + "--files", + help="Let the agent read and write files in the current directory (writes need y/n confirmation)", + rich_help_panel=_PANEL_TOOLS, + ), +``` + +Add an epilog example (in the `examples_epilog([...])` list): + +```python + ( + "Let the agent read and write files here", + "assembly --sandbox live --files", + ), +``` + +Pass it into the options constructor: + +```python + mcp_config=tuple(mcp_config or ()), + files=files, + show_code=show_code, +``` + +In `aai_cli/commands/agent_cascade/_exec.py`, add the dataclass field (after `mcp_config`): + +```python + # Let the agent read/write files in the launch directory (writes confirmed; none by default). + files: bool +``` + +Add the headless deny approver (module level, near `_web_search_note`): + +```python +def _deny_writes(name: str, args: dict[str, object]) -> bool: + """Approver for non-interactive runs: deny every gated write (no channel to confirm one). + + Reads stay ungated (they never reach an approver), so a piped/file/--json `--files` session + can still read and search — it just can't write without a TUI to press y/n in. + """ + del name, args + return False +``` + +Set `files=opts.files` on **both** `CascadeConfig(...)` constructions (the live config in `run_agent_cascade` and the `_print_show_code` config). For `run_agent_cascade`'s config add: + +```python + mcp_servers=mcp_servers, + files=opts.files, +``` + +For `_print_show_code`'s config add `files=opts.files,` (so `--show-code` reflects the flag in the constructed config even though the generated script is unaffected — see Step 5). + +Pass the deny approver on the headless path — in `run_agent_cascade`, change the `CascadeDeps.real(...)` call: + +```python + deps = engine.CascadeDeps.real( + api_key, config, audio=audio, stt_params=stt_params, approver=_deny_writes + ) +``` + +Wire the TUI's approver — in `_run_live_tui`, build the app first, then the deps referencing `app.approve_write` (the closure resolves `app` at call time, after it's assigned): + +```python +def _run_live_tui(api_key: str, opts: AgentCascadeOptions, config: CascadeConfig) -> None: + from aai_cli.agent_cascade.tui import LiveAgentApp + + duplex = DuplexAudio(target_rate=SAMPLE_RATE, device=opts.device) + stt_params = _build_stt_params(opts, SAMPLE_RATE) + + def approve_write(name: str, args: dict[str, object]) -> bool: + return app.approve_write(name, args) + + deps = engine.CascadeDeps.real( + api_key, config, audio=duplex.mic, stt_params=stt_params, approver=approve_write + ) + + def run_conversation(renderer: engine.Renderer) -> None: + engine.run_cascade( + renderer=renderer, + player=duplex.player, + config=config, + deps=deps, + on_session=lambda session: app.set_interrupt(session.interrupt_reply), + ) + + app = LiveAgentApp( + run_conversation=run_conversation, + on_stop=duplex.close, + on_toggle_listen=duplex.toggle_listening, + web_note=_web_search_note(), + ) + app.run(mouse=False) + if app.error is not None: + raise app.error +``` + +Update the `_opts` helper in `tests/test_agent_cascade_command.py` to include `files=False` in its defaults, and update any existing test that constructs `CascadeDeps.real(...)` or a `fake_real`/`fake_build_completer` to accept the new keyword-only `approver` parameter (e.g. the `fake_real` at ~line 230 and the `build_completer` lambda at ~line 248 — give them `*, approver=None`). + +- [ ] **Step 4: Run the targeted tests, verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_command.py tests/test_live_tui.py -q` +Expected: PASS. + +- [ ] **Step 5: Confirm `--show-code` is unaffected, then regenerate the help snapshot** + +The generated script (`code_gen.agent_cascade`) models the STT→LLM→TTS SDK cascade, not the deepagents toolset, so `--files` does not change its output — no code-gen change is needed (the flag is carried on the config only for completeness). Confirm: + +Run: `uv run pytest tests/test_code_gen_agent_cascade.py tests/test_agent_cascade_show_code.py -q` +Expected: PASS. + +Regenerate the `live --help` golden (the new `--files` row + epilog example land here): + +Run: `uv run pytest tests/test_snapshots_help_run.py --snapshot-update -q` +Then verify clean: `uv run pytest tests/test_snapshots_help_run.py -q` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/commands/agent_cascade/__init__.py aai_cli/commands/agent_cascade/_exec.py tests/test_agent_cascade_command.py tests/test_live_tui.py tests/__snapshots__/test_snapshots_help_run.ambr +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): --files flag wiring (TUI approver + headless deny)" +``` + +--- + +### Task 7: Docs consistency + full gate + +Document the flag and run the authoritative gate to green. + +**Files:** +- Modify: `REFERENCE.md`, `README.md` (if either enumerates `assembly live` flags — the docs consistency gate checks `assembly …` command refs) +- Modify: `aai_cli/AGENTS.md` (note the new capability under the `agent_cascade/` subsystem bullet) + +- [ ] **Step 1: Check what the docs gate expects** + +Run: `uv run python scripts/docs_consistency_gate.py` +Expected: PASS, or a specific instruction about a missing `assembly live --files` reference / env var. Fix exactly what it reports (add a `--files` mention to the `live` section of `REFERENCE.md`/`README.md` if flagged). + +- [ ] **Step 2: Add a one-line note to `aai_cli/AGENTS.md`** + +In the `agent_cascade/` subsystem bullet, append a sentence: + +``` +`--files` swaps the brain's in-memory backend for a real-cwd `FilesystemBackend` +(deepagents) and gates `write_file`/`edit_file` behind a TUI `y/n` approval (the +code agent's `ApprovalScreen`); reads (incl. `grep`) stay ungated and headless runs +auto-deny writes. +``` + +- [ ] **Step 3: Run the full gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` Fix any mutation-gate survivors on changed lines by tightening assertions (the diff-scoped mutation + 100% patch-coverage stages are the ones most likely to flag gaps). Re-run until green. + +- [ ] **Step 4: Final commit (no override — the gate just passed)** + +```bash +git add REFERENCE.md README.md aai_cli/AGENTS.md +git commit -m "docs(live): document --files file read/write capability" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Opt-in flag default off → Task 1 (config) + Task 6 (flag). ✓ +- Reads ungated incl. grep; writes gated → Task 1 (`interrupt_on` only write tools) + Task 2 (grep label). ✓ +- TUI keypress confirm → Task 5; headless auto-deny → Task 6 (`_deny_writes`). ✓ +- `FilesystemBackend` rooted at cwd, virtual_mode → Task 1. ✓ +- `execute` inert/unadvertised/ungated → Task 1 (only `_WRITE_TOOLS` gated) + Task 2 (not in capability phrase). ✓ +- Reply timeout excludes approval wait → Task 4. ✓ +- Capability advertised → Task 2. ✓ +- `--show-code` unaffected (verified) → Task 6 Step 5. ✓ +- Tests for brain/engine/TUI → Tasks 1–6. ✓ +- Docs consistency → Task 7. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows the code; commands have expected output. + +**Type consistency:** `Approver = Callable[[str, dict[str, object]], bool]` used identically in `brain.build_completer`, `engine.CascadeDeps.real`, `_exec._deny_writes`, and `LiveAgentApp.approve_write`. `_complete_within(messages, timeout: float | None)` matches its two call sites (`None` when `config.files`, else `_REPLY_TIMEOUT_SECONDS`). `_graph_kwargs`/`_build_fs_backend` names match between definition (Task 1) and use (`build_graph`). `CascadeConfig.files` consistent across config/brain/engine/exec. diff --git a/docs/superpowers/plans/2026-06-22-live-keyless-tools.md b/docs/superpowers/plans/2026-06-22-live-keyless-tools.md new file mode 100644 index 00000000..70349aa2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-keyless-tools.md @@ -0,0 +1,1731 @@ +# Eight Keyless Tools for `assembly live` — 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:** Add eight always-on, keyless tools to the `assembly live` voice agent (`agent-cascade`): `look_up_topic`, `calculate`, `convert_units`, `define_word`, `get_time_in`, `date_math`, `check_holiday`, and `sun_times`. + +**Architecture:** Each tool is a self-contained module in `aai_cli/agent_cascade/`, mirroring the existing `weather_tool.py`/`datetime_tool.py` pattern: a `*_TOOL_NAME` constant, a `build_*_tool(seam=default)` factory returning a LangChain `BaseTool`, at most one injected seam (`Fetcher`/`Clock`) for hermetic tests, and a body that **never raises** — failures return a short spoken apology. A new shared `geocode.py` factors out the Open-Meteo geocoding used by `get_weather`, `get_time_in`, and `sun_times`. `brain.py` binds all eight (no key gate) and advertises each. + +**Tech Stack:** Python 3.12–3.13, LangChain `BaseTool`, deepagents graph, `httpx`/`httpx2`, and five new libraries — `pint`, `simpleeval`, `python-dateutil`, `holidays`, `astral`. + +**Spec:** `docs/superpowers/specs/2026-06-22-live-keyless-tools-design.md` + +## Global Constraints + +- **`from __future__ import annotations`** at the top of every module; modern typing (`X | None`). +- **Never raise out of a tool body.** Catch failures and return a short, speakable apology string (the `weather_tool` rule). Use a broad `except Exception:` for network/library calls, exactly as `weather_tool.get_weather` does. +- **One seam per tool, injected for tests.** `Fetcher = Callable[[str], object]` (parsed-JSON GET) and/or `Clock = Callable[[], datetime]`. The default fetcher uses `httpx.get(url, timeout=…).raise_for_status()` then `.json()`. No test touches the real network/clock. +- **Lazy imports for the new libraries** (`pint`, `simpleeval`, `dateutil`, `holidays`, `astral`) and for `langchain_core.tools` — import inside the factory/body, never at module top, to keep CLI startup fast (`webpage_tool` is the precedent). +- **Tuning knobs get `# pragma: no mutate`** (e.g. text-truncation caps) — the mutation gate can't kill an order-equivalent ±1 change. +- **Tests must assert behavior, not just execute it** (diff-scoped mutation gate + 100% patch coverage vs `origin/main`). Assert the exact returned string/branch. +- **Help/docstring copy:** tool docstrings are the model-facing usage contract — write them as full sentences (they are NOT the period-less CLI help copy, and are not snapshot-pinned). +- **Dependency floors:** pin each new dependency's floor to the **second-newest** release (safe-chain's minimum-package-age check rejects the newest at resolution). +- **Commit discipline:** a PreToolUse hook blocks `git commit` unless `./scripts/check.sh` last passed for the current tree. Per-task commits in this plan are WIP commits — prefix them `AAI_ALLOW_COMMIT=1`. Each PR ends with a **gate task** that runs `./scripts/check.sh` to completion (`All checks passed.`) before the real commit / PR. +- **Two PRs:** Task 1 is **PR-A** (dependencies only). Tasks 2–12 are **PR-B** (the feature), which lands after PR-A. + +--- + +## PR-A — Dependencies + +### Task 1: Add the five libraries + +**Files:** +- Modify: `pyproject.toml` (the `dependencies = [` array, around line 27) +- Modify: `uv.lock` (regenerated, not hand-edited) +- Test: `tests/test_live_tool_deps.py` (Create) + +**Interfaces:** +- Produces: importable `pint`, `simpleeval`, `dateutil`, `holidays`, `astral` for Tasks 4, 5, 8, 9, 10. + +- [ ] **Step 1: Find the second-newest released version of each library** + +Run (network permitting; otherwise consult PyPI): +```bash +for p in pint simpleeval python-dateutil holidays astral; do + echo "== $p =="; uv pip index versions "$p" 2>/dev/null | head -3 +done +``` +For each, choose the **second-newest** version as the floor (safe-chain rejects the newest). Record the chosen floors. + +- [ ] **Step 2: Write the failing test** + +```python +# tests/test_live_tool_deps.py +"""The five libraries the new live tools need must be importable.""" + +from __future__ import annotations + +import importlib + +import pytest + + +@pytest.mark.parametrize( + "module", + ["pint", "simpleeval", "dateutil", "holidays", "astral"], +) +def test_live_tool_dependency_importable(module: str) -> None: + assert importlib.import_module(module) is not None +``` + +- [ ] **Step 3: Run it to confirm it fails** + +Run: `uv run pytest tests/test_live_tool_deps.py -q` +Expected: FAIL — `ModuleNotFoundError` for at least `pint`/`simpleeval`/`holidays`/`astral`. + +- [ ] **Step 4: Add the dependencies** + +In `pyproject.toml`, inside the `dependencies = [` array, add (substituting the floors chosen in Step 1): +```toml + "pint>=X.Y", # physical-unit conversion for the live convert_units tool + "simpleeval>=X.Y", # safe arithmetic-expression eval for the live calculate tool + "python-dateutil>=X.Y", # date arithmetic for the live date_math tool (declared directly; deptry forbids using it only transitively) + "holidays>=X.Y", # offline public-holiday data for the live check_holiday tool + "astral>=X.Y", # sunrise/sunset + moon phase for the live sun_times tool +``` + +- [ ] **Step 5: Regenerate the lock** + +Run: `uv lock` +Expected: `uv.lock` updates; `uv lock --check` then passes. + +- [ ] **Step 6: Run the test to confirm it passes** + +Run: `uv run pytest tests/test_live_tool_deps.py -q` +Expected: PASS (5 parametrized cases). + +- [ ] **Step 7: Run the full gate, then commit (this is its own PR)** + +Run: `./scripts/check.sh` +Expected: `All checks passed.` +```bash +git add pyproject.toml uv.lock tests/test_live_tool_deps.py +git commit -m "build: add pint, simpleeval, python-dateutil, holidays, astral for live tools" +``` +Open PR-A. Tasks 2–12 land in PR-B on top of it. + +--- + +## PR-B — The eight tools + +### Task 2: Shared `geocode.py` + refactor `weather_tool` + +**Files:** +- Create: `aai_cli/agent_cascade/geocode.py` +- Modify: `aai_cli/agent_cascade/weather_tool.py` (replace its private `_geocode` + `_GEOCODE_URL` with delegation) +- Test: `tests/test_agent_cascade_geocode.py` (Create) + +**Interfaces:** +- Produces: `geocode.Fetcher`, `geocode.GeoResult(name, latitude, longitude, timezone)`, `geocode.geocode(name, *, fetch) -> GeoResult | None`. Consumed by Tasks 7 (`get_time_in`) and 10 (`sun_times`), and now by `weather_tool`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_geocode.py +from __future__ import annotations + +from aai_cli.agent_cascade import geocode + + +def _fake_fetch(payload: object): + def fetch(url: str) -> object: + assert "geocoding-api.open-meteo.com" in url + assert "name=Tokyo" in url and "count=1" in url + return payload + return fetch + + +def test_geocode_returns_top_match_with_timezone() -> None: + result = geocode.geocode( + "Tokyo", + fetch=_fake_fetch( + {"results": [{"name": "Tokyo", "latitude": 35.6895, "longitude": 139.69, "timezone": "Asia/Tokyo"}]} + ), + ) + assert result == geocode.GeoResult("Tokyo", 35.6895, 139.69, "Asia/Tokyo") + + +def test_geocode_no_match_returns_none() -> None: + assert geocode.geocode("Nowheresville", fetch=_fake_fetch({"results": []})) is None + assert geocode.geocode("Nowheresville", fetch=_fake_fetch({})) is None +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_geocode.py -q` +Expected: FAIL — `ModuleNotFoundError: aai_cli.agent_cascade.geocode`. + +- [ ] **Step 3: Create `geocode.py`** + +```python +# aai_cli/agent_cascade/geocode.py +"""Shared Open-Meteo geocoding for the `assembly live` voice agent's place-aware tools. + +The weather, world-clock, and sun-times tools all turn a spoken place name into +coordinates via Open-Meteo's keyless geocoding endpoint; the time/sun tools additionally +need the IANA timezone the same response already carries. This module is the single +geocoding implementation so that logic isn't duplicated across tools. + +The only network seam is :data:`Fetcher` (a ``url -> parsed JSON`` callable), injected in +tests so the flow runs with no sockets — the same shape ``weather_tool`` uses. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from urllib.parse import urlencode + +from aai_cli.core import jsonshape + +# A fetcher GETs a URL and returns parsed JSON. Injected in tests (the only net seam). +Fetcher = Callable[[str], object] + +_GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" + + +@dataclass(frozen=True) +class GeoResult: + """A resolved place: display name, coordinates, and IANA timezone.""" + + name: str + latitude: float + longitude: float + timezone: str + + +def geocode(name: str, *, fetch: Fetcher) -> GeoResult | None: + """Resolve a place name to a :class:`GeoResult`, or None when there is no match. + + Asks Open-Meteo's geocoding endpoint for the single best match. No match (an empty or + absent ``results`` list) returns None so a caller can speak a clear "couldn't find that + place" instead of guessing. + """ + query = urlencode({"name": name, "count": 1, "language": "en", "format": "json"}) + payload = jsonshape.as_mapping(fetch(f"{_GEOCODE_URL}?{query}")) + results = jsonshape.mapping_list(payload.get("results")) if payload is not None else [] + if not results: + return None + top = results[0] + return GeoResult( + name=str(top.get("name", name)), + latitude=jsonshape.as_float(top.get("latitude")), + longitude=jsonshape.as_float(top.get("longitude")), + timezone=str(top.get("timezone", "")), + ) +``` + +- [ ] **Step 4: Refactor `weather_tool._geocode` to delegate** + +In `aai_cli/agent_cascade/weather_tool.py`, delete the `_GEOCODE_URL` constant and replace the body of `_geocode` so it calls the shared module (keep its 3-tuple return shape so `_forecast`/`get_weather` are unchanged): +```python +from aai_cli.agent_cascade import geocode as _geocode_mod + + +def _geocode(name: str, *, fetch: Fetcher) -> tuple[str, float, float] | None: + """Resolve a place name to ``(display name, latitude, longitude)`` via the shared geocoder.""" + result = _geocode_mod.geocode(name, fetch=fetch) + if result is None: + return None + return (result.name, result.latitude, result.longitude) +``` +Leave `_FORECAST_URL` and the rest of `weather_tool` intact. + +- [ ] **Step 5: Run geocode + weather tests** + +Run: `uv run pytest tests/test_agent_cascade_geocode.py tests/test_agent_cascade_weather.py -q` +Expected: PASS. If a pre-existing weather test referenced `weather_tool._GEOCODE_URL`, repoint that assertion at `geocode._GEOCODE_URL` (the URL/params are unchanged, so the fake-fetch happy paths still match). + +- [ ] **Step 6: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/geocode.py aai_cli/agent_cascade/weather_tool.py tests/test_agent_cascade_geocode.py +AAI_ALLOW_COMMIT=1 git commit -m "refactor(live): extract shared geocode.py from weather_tool" +``` + +--- + +### Task 3: `look_up_topic` (Wikipedia) + +**Files:** +- Create: `aai_cli/agent_cascade/topic_tool.py` +- Test: `tests/test_agent_cascade_topic.py` (Create) + +**Interfaces:** +- Produces: `topic_tool.LOOKUP_TOOL_NAME = "look_up_topic"`, `topic_tool.build_lookup_tool(fetch=…) -> BaseTool`. Consumed by Task 12 (`brain.py`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_topic.py +from __future__ import annotations + +from aai_cli.agent_cascade.topic_tool import LOOKUP_TOOL_NAME, build_lookup_tool + + +def _tool(payload: object): + def fetch(url: str) -> object: + assert "en.wikipedia.org/api/rest_v1/page/summary/" in url + return payload + return build_lookup_tool(fetch=fetch) + + +def test_lookup_returns_extract() -> None: + tool = _tool({"type": "standard", "title": "Ada Lovelace", "extract": "Ada Lovelace was a mathematician."}) + assert tool.invoke({"topic": "Ada Lovelace"}) == "Ada Lovelace was a mathematician." + + +def test_lookup_disambiguation_apology() -> None: + tool = _tool({"type": "disambiguation", "title": "Mercury", "extract": "Mercury may refer to..."}) + assert "couldn't find a clear summary" in tool.invoke({"topic": "Mercury"}) + + +def test_lookup_empty_extract_apology() -> None: + tool = _tool({"type": "standard", "extract": ""}) + assert "couldn't find a clear summary" in tool.invoke({"topic": "Zzz"}) + + +def test_lookup_fetch_error_apology() -> None: + def boom(url: str) -> object: + raise RuntimeError("502") + tool = build_lookup_tool(fetch=boom) + assert tool.invoke({"topic": "Anything"}) == "I couldn't look that up right now." + + +def test_tool_name() -> None: + assert build_lookup_tool().name == LOOKUP_TOOL_NAME == "look_up_topic" +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_topic.py -q` +Expected: FAIL — `ModuleNotFoundError`. + +- [ ] **Step 3: Create `topic_tool.py`** + +```python +# aai_cli/agent_cascade/topic_tool.py +"""A keyless encyclopedia-lookup tool for the `assembly live` voice agent. + +Backed by Wikipedia's REST summary endpoint, which needs no API key and returns a clean, +~1-paragraph ``extract`` purpose-built to be read aloud — so "who is…", "what is…", and +"tell me about…" work without the keyed Firecrawl search. The only network seam is +:data:`Fetcher`, injected in tests. Failures never raise out to the graph. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING +from urllib.parse import quote + +from aai_cli.core import jsonshape + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +LOOKUP_TOOL_NAME = "look_up_topic" + +# A fetcher GETs a URL and returns parsed JSON. Injected in tests (the only net seam). +Fetcher = Callable[[str], object] + +_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary/" +# Cap the spoken extract; the endpoint is already ~1 paragraph, this just guards an outlier. +_MAX_CHARS = 1200 # pragma: no mutate — a tuning knob; ±a few chars is equivalent + + +def _get_json(url: str) -> object: + """GET ``url`` and return its parsed JSON body (the default network seam).""" + import httpx + + response = httpx.get(url, timeout=15.0) + response.raise_for_status() + return response.json() + + +def build_lookup_tool(fetch: Fetcher = _get_json) -> BaseTool: + """Wrap the Wikipedia summary lookup as the ``look_up_topic`` tool (``fetch`` injectable).""" + from langchain_core.tools import tool + + @tool(LOOKUP_TOOL_NAME) + def look_up_topic(topic: str) -> str: + """Look up an encyclopedic summary of a person, place, organization, or topic. Use + for "who is…", "what is…", or "tell me about…" questions about well-known subjects.""" + try: + payload = jsonshape.as_mapping(fetch(f"{_SUMMARY_URL}{quote(topic)}")) or {} + extract = str(payload.get("extract", "")).strip() + if not extract or payload.get("type") == "disambiguation": + return f"I couldn't find a clear summary for '{topic}'." + return extract[:_MAX_CHARS] + except Exception: + return "I couldn't look that up right now." + + return look_up_topic +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_agent_cascade_topic.py -q` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/topic_tool.py tests/test_agent_cascade_topic.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add look_up_topic Wikipedia tool" +``` + +--- + +### Task 4: `calculate` (simpleeval) + +**Files:** +- Create: `aai_cli/agent_cascade/calc_tool.py` +- Test: `tests/test_agent_cascade_calc.py` (Create) + +**Interfaces:** +- Produces: `calc_tool.CALC_TOOL_NAME = "calculate"`, `calc_tool.build_calc_tool() -> BaseTool`. Consumed by Task 12. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_calc.py +from __future__ import annotations + +import pytest + +from aai_cli.agent_cascade.calc_tool import CALC_TOOL_NAME, build_calc_tool + + +@pytest.fixture +def calc(): + return build_calc_tool() + + +def test_percentage(calc) -> None: + assert calc.invoke({"expression": "0.15 * 240"}) == "36" + + +def test_integer_division_rounds_cleanly(calc) -> None: + # 87 / 3 is 28.999999999999996 in float; must read as 29, not the artifact. + assert calc.invoke({"expression": "87 / 3"}) == "29" + + +def test_precedence_and_unary(calc) -> None: + assert calc.invoke({"expression": "3 + 4 * 5"}) == "23" + assert calc.invoke({"expression": "-2 ** 2"}) in {"-4", "4"} # operator precedence, no crash + + +def test_non_integer_keeps_decimals(calc) -> None: + assert calc.invoke({"expression": "10 / 3"}) == "3.3333" + + +@pytest.mark.parametrize( + "expression,expected", + [ + ("abs(-7)", "7"), + ("round(3.14159, 2)", "3.14"), + ("min(3, 9, 2)", "2"), + ("max(3, 9, 2)", "9"), + ("sum([1, 2, 3])", "6"), + ("sqrt(16)", "4"), + ("floor(2.9)", "2"), + ("ceil(2.1)", "3"), + ("log(1)", "0"), + ("log10(1000)", "3"), + ("hypot(3, 4)", "5"), + ("gcd(12, 18)", "6"), + ("mean([2, 4, 9])", "5"), + ("median([1, 5, 2])", "2"), + ("stdev([1, 1, 1])", "0"), + ("round(pi, 2)", "3.14"), + ("round(e, 2)", "2.72"), + ], +) +def test_whitelisted_functions_and_constants(calc, expression, expected) -> None: + assert calc.invoke({"expression": expression}) == expected + + +def test_rejects_names(calc) -> None: + assert calc.invoke({"expression": "foo + 1"}) == "I couldn't compute that." + + +def test_rejects_unwhitelisted_function(calc) -> None: + # factorial is deliberately NOT exposed (it explodes from small inputs). + assert calc.invoke({"expression": "factorial(5)"}) == "I couldn't compute that." + + +def test_rejects_syntax_error(calc) -> None: + assert calc.invoke({"expression": "3 +"}) == "I couldn't compute that." + + +def test_division_by_zero(calc) -> None: + assert calc.invoke({"expression": "1 / 0"}) == "I couldn't compute that." + + +def test_power_bomb_is_capped(calc) -> None: + # simpleeval's MAX_POWER guard turns this into an error, not a hang. + assert calc.invoke({"expression": "9 ** 9 ** 9"}) == "I couldn't compute that." + + +def test_tool_name() -> None: + assert build_calc_tool().name == CALC_TOOL_NAME == "calculate" +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_calc.py -q` +Expected: FAIL — `ModuleNotFoundError`. + +- [ ] **Step 3: Create `calc_tool.py`** + +```python +# aai_cli/agent_cascade/calc_tool.py +"""A pure, offline math tool for the `assembly live` voice agent. + +The LLM does mental math unreliably; this tool evaluates an expression exactly. It uses +``simpleeval`` seeded with a curated whitelist of pure ``math``/``statistics`` functions and +the constants ``pi``/``e`` — so the agent can "run code to get an answer" (square roots, +logs, averages, standard deviation) with no shell and no network. ``simpleeval`` bounds the +``**`` operator via ``MAX_POWER`` but does NOT bound function arguments, so the whitelist +deliberately excludes anything that explodes from small inputs (factorial, pow, perm, comb). +There is no network seam. Failures never raise out to the graph. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +CALC_TOOL_NAME = "calculate" + +# Round non-integer results so float artifacts (87/3 -> 28.999999999999996) never leak into +# speech. Asserted by a test (10/3 -> "3.3333"), so this is NOT a free no-mutate knob. +_PRECISION = 4 + + +def _format(value: float) -> str: + """Render a numeric result for speech: integers bare (``36``), else rounded (``3.3333``). + + ``value`` is whatever ``simpleeval.eval`` returned (typed ``Any`` by the untyped library, + so no cast is needed); a non-numeric result makes ``round`` raise, caught by the caller. + """ + number = round(value, _PRECISION) + if number == int(number): + return str(int(number)) + return str(number) + + +def _functions() -> dict[str, object]: + """The curated, pure functions exposed to the evaluator. + + Replaces simpleeval's defaults (dropping ``rand``/``randint``). Deliberately excludes + ``factorial``/``pow``/``perm``/``comb`` — simpleeval bounds ``**`` via ``MAX_POWER`` but + NOT function arguments, so an unguarded one would reopen the resource-exhaustion hole. + Everything here is O(n) on its input and can't grow a giant number from a small one. + """ + import math + import statistics + + return { + "abs": abs, + "round": round, + "min": min, + "max": max, + "sum": sum, + "sqrt": math.sqrt, + "floor": math.floor, + "ceil": math.ceil, + "log": math.log, + "log10": math.log10, + "hypot": math.hypot, + "gcd": math.gcd, + "mean": statistics.mean, + "median": statistics.median, + "stdev": statistics.stdev, + } + + +def _names() -> dict[str, float]: + """Math constants exposed to the evaluator (replaces simpleeval's True/False/None names).""" + import math + + return {"pi": math.pi, "e": math.e} + + +def build_calc_tool() -> BaseTool: + """Wrap a whitelisted ``simpleeval`` evaluator as the ``calculate`` tool.""" + from langchain_core.tools import tool + + @tool(CALC_TOOL_NAME) + def calculate(expression: str) -> str: + """Evaluate a math expression and return the result. Use numbers, the operators + + - * / // % ** and parentheses, the constants pi and e, and these functions: abs, + round, min, max, sum, sqrt, floor, ceil, log, log10, hypot, gcd, mean, median, stdev. + Pass a list to the aggregate functions, e.g. mean([2, 4, 9]). Translate the spoken + question into such an expression yourself. Examples: "15% of 240" -> "0.15 * 240"; + "square root of 150" -> "sqrt(150)"; "standard deviation of 2, 4, 4, 4, 5" -> + "stdev([2, 4, 4, 4, 5])"; "split 87 three ways" -> "87 / 3".""" + from simpleeval import SimpleEval + + try: + return _format(SimpleEval(functions=_functions(), names=_names()).eval(expression)) + except Exception: + return "I couldn't compute that." + + return calculate +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_agent_cascade_calc.py -q` +Expected: PASS (the fixed tests plus 17 parametrized function/constant cases). If `-2 ** 2` surprises you, the assertion accepts both — it only checks no crash. If `statistics.mean`/`median` return a type that formats unexpectedly, `_format` normalizes both int and float, so the expected strings hold. + +- [ ] **Step 5: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/calc_tool.py tests/test_agent_cascade_calc.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add calculate tool via simpleeval" +``` + +--- + +### Task 5: `convert_units` (pint + frankfurter.app) + +**Files:** +- Create: `aai_cli/agent_cascade/units_tool.py` +- Test: `tests/test_agent_cascade_units.py` (Create) + +**Interfaces:** +- Produces: `units_tool.CONVERT_TOOL_NAME = "convert_units"`, `units_tool.build_convert_tool(fetch=…) -> BaseTool`. Consumed by Task 12. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_units.py +from __future__ import annotations + +from aai_cli.agent_cascade.units_tool import CONVERT_TOOL_NAME, build_convert_tool + + +def test_length_conversion_via_pint() -> None: + out = build_convert_tool().invoke({"value": 5, "from_unit": "mile", "to_unit": "kilometer"}) + assert out.startswith("5") and "8.05" in out and "kilometer" in out + + +def test_temperature_conversion_via_pint() -> None: + out = build_convert_tool().invoke({"value": 350, "from_unit": "degF", "to_unit": "degC"}) + assert "176.67" in out + + +def test_incompatible_units_apology() -> None: + out = build_convert_tool().invoke({"value": 5, "from_unit": "mile", "to_unit": "kilogram"}) + assert out == "I couldn't convert those units." + + +def test_currency_conversion_via_fetch() -> None: + def fetch(url: str) -> object: + assert "api.frankfurter.app/latest" in url + assert "from=USD" in url and "to=EUR" in url and "amount=100" in url + return {"rates": {"EUR": 92.4}} + out = build_convert_tool(fetch=fetch).invoke({"value": 100, "from_unit": "usd", "to_unit": "eur"}) + assert "100 USD is 92.4 EUR." == out + + +def test_currency_fetch_error_apology() -> None: + def boom(url: str) -> object: + raise RuntimeError("down") + out = build_convert_tool(fetch=boom).invoke({"value": 100, "from_unit": "USD", "to_unit": "EUR"}) + assert out == "I couldn't get that exchange rate right now." + + +def test_tool_name() -> None: + assert build_convert_tool().name == CONVERT_TOOL_NAME == "convert_units" +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_units.py -q` +Expected: FAIL — `ModuleNotFoundError`. + +- [ ] **Step 3: Create `units_tool.py`** + +```python +# aai_cli/agent_cascade/units_tool.py +"""A units-and-currency conversion tool for the `assembly live` voice agent. + +Physical units convert offline via ``pint``; currencies convert via keyless +frankfurter.app (the only network path, behind the injected :data:`Fetcher`). Path +selection is by ISO-4217 currency code. Failures never raise out to the graph. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +from aai_cli.core import jsonshape + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +CONVERT_TOOL_NAME = "convert_units" + +Fetcher = Callable[[str], object] + +_FRANKFURTER_URL = "https://api.frankfurter.app/latest" +# ISO-4217 codes we treat as currency (vs. a physical unit). A small, common set is enough; +# anything else falls to the pint path, which apologizes if it isn't a real unit. +_CURRENCIES = frozenset( + {"USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "INR", "MXN", "BRL", "SEK", "NOK", "NZD", "ZAR"} +) + + +def _get_json(url: str) -> object: + """GET ``url`` and return parsed JSON (the default currency-fetch seam).""" + import httpx + + response = httpx.get(url, timeout=15.0) + response.raise_for_status() + return response.json() + + +def _round(value: float) -> str: + """Render a converted amount: integers bare, else two decimals (speakable money/units).""" + rounded = round(value, 2) + return str(int(rounded)) if rounded == int(rounded) else str(rounded) + + +def _convert_currency(value: float, src: str, dst: str, *, fetch: Fetcher) -> str: + """Convert via frankfurter.app; raises on a network error or an absent rate.""" + query = urlencode({"amount": value, "from": src, "to": dst}) + payload = jsonshape.as_mapping(fetch(f"{_FRANKFURTER_URL}?{query}")) or {} + rates = jsonshape.as_mapping(payload.get("rates")) or {} + amount = jsonshape.as_float(rates[dst]) # KeyError if missing -> caught by caller + return f"{_round(value)} {src} is {_round(amount)} {dst}." + + +def _convert_units(value: float, src: str, dst: str) -> str: + """Convert physical units via pint; raises pint errors on bad/incompatible units.""" + import pint + + quantity = pint.UnitRegistry().Quantity(value, src).to(dst) + return f"{_round(value)} {src} is {_round(float(quantity.magnitude))} {dst}." + + +def build_convert_tool(fetch: Fetcher = _get_json) -> BaseTool: + """Wrap unit + currency conversion as the ``convert_units`` tool (``fetch`` injectable).""" + from langchain_core.tools import tool + + @tool(CONVERT_TOOL_NAME) + def convert_units(value: float, from_unit: str, to_unit: str) -> str: + """Convert a value between units or currencies. For physical units pass names pint + understands (e.g. "mile", "kilometer", "kg", "lb", "degF", "degC"); for money pass + ISO currency codes (e.g. "USD", "EUR"). Examples: 5 mile -> kilometer; 350 degF -> + degC; 100 USD -> EUR.""" + src, dst = from_unit.upper(), to_unit.upper() + if src in _CURRENCIES and dst in _CURRENCIES: + try: + return _convert_currency(value, src, dst, fetch=fetch) + except Exception: + return "I couldn't get that exchange rate right now." + try: + return _convert_units(value, from_unit, to_unit) + except Exception: + return "I couldn't convert those units." + + return convert_units +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_agent_cascade_units.py -q` +Expected: PASS (6 tests). If pint formats `8.046...`, the rounding yields `8.05`. + +- [ ] **Step 5: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/units_tool.py tests/test_agent_cascade_units.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add convert_units tool (pint + frankfurter)" +``` + +--- + +### Task 6: `define_word` (dictionaryapi.dev) + +**Files:** +- Create: `aai_cli/agent_cascade/define_tool.py` +- Test: `tests/test_agent_cascade_define.py` (Create) + +**Interfaces:** +- Produces: `define_tool.DEFINE_TOOL_NAME = "define_word"`, `define_tool.build_define_tool(fetch=…) -> BaseTool`. Consumed by Task 12. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_define.py +from __future__ import annotations + +from aai_cli.agent_cascade.define_tool import DEFINE_TOOL_NAME, build_define_tool + + +def _tool(payload: object): + def fetch(url: str) -> object: + assert "api.dictionaryapi.dev/api/v2/entries/en/" in url + return payload + return build_define_tool(fetch=fetch) + + +def test_define_with_synonyms() -> None: + tool = _tool( + [{"meanings": [{"partOfSpeech": "adjective", + "definitions": [{"definition": "lasting a very short time."}], + "synonyms": ["transient", "fleeting", "momentary"]}]}] + ) + out = tool.invoke({"word": "ephemeral"}) + assert "ephemeral (adjective): lasting a very short time." in out + assert "Synonyms: transient, fleeting." in out # capped at two + + +def test_define_without_synonyms() -> None: + tool = _tool([{"meanings": [{"partOfSpeech": "noun", "definitions": [{"definition": "a thing."}]}]}]) + out = tool.invoke({"word": "widget"}) + assert out == "widget (noun): a thing." + + +def test_define_not_found_apology() -> None: + # The API returns an OBJECT (not a list) when the word is unknown. + tool = _tool({"title": "No Definitions Found"}) + assert tool.invoke({"word": "asdfghjkl"}) == "I couldn't find a definition for 'asdfghjkl'." + + +def test_define_fetch_error_apology() -> None: + def boom(url: str) -> object: + raise RuntimeError("500") + assert build_define_tool(fetch=boom).invoke({"word": "x"}) == "I couldn't look up that word right now." + + +def test_tool_name() -> None: + assert build_define_tool().name == DEFINE_TOOL_NAME == "define_word" +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_define.py -q` +Expected: FAIL — `ModuleNotFoundError`. + +- [ ] **Step 3: Create `define_tool.py`** + +```python +# aai_cli/agent_cascade/define_tool.py +"""A keyless dictionary tool for the `assembly live` voice agent. + +Backed by dictionaryapi.dev (no key). On success the endpoint returns a JSON array of +entries; on a miss it returns a JSON object, which we treat as "not found". The only +network seam is :data:`Fetcher`. Failures never raise out to the graph. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING +from urllib.parse import quote + +from aai_cli.core import jsonshape + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +DEFINE_TOOL_NAME = "define_word" + +Fetcher = Callable[[str], object] + +_ENTRIES_URL = "https://api.dictionaryapi.dev/api/v2/entries/en/" +_MAX_SYNONYMS = 2 # keep the spoken reply short + + +def _get_json(url: str) -> object: + """GET ``url`` and return parsed JSON (the default network seam).""" + import httpx + + response = httpx.get(url, timeout=15.0) + response.raise_for_status() + return response.json() + + +def _format(word: str, entries: list[object]) -> str: + """Render the first meaning as ``word (pos): definition.`` plus up to two synonyms.""" + first = jsonshape.as_mapping(entries[0]) or {} + meanings = jsonshape.mapping_list(first.get("meanings")) + meaning = meanings[0] + pos = str(meaning.get("partOfSpeech", "")).strip() + definitions = jsonshape.mapping_list(meaning.get("definitions")) + definition = str(definitions[0].get("definition", "")).strip() + line = f"{word} ({pos}): {definition}" + synonyms = [str(s) for s in jsonshape.object_list(meaning.get("synonyms"))][:_MAX_SYNONYMS] + if synonyms: + return f"{line} Synonyms: {', '.join(synonyms)}." + return line + + +def build_define_tool(fetch: Fetcher = _get_json) -> BaseTool: + """Wrap the dictionary lookup as the ``define_word`` tool (``fetch`` injectable).""" + from langchain_core.tools import tool + + @tool(DEFINE_TOOL_NAME) + def define_word(word: str) -> str: + """Define an English word and give a couple of synonyms. Use for "define X", "what + does X mean", or "another word for X".""" + try: + payload = fetch(f"{_ENTRIES_URL}{quote(word)}") + if not isinstance(payload, list) or not payload: + return f"I couldn't find a definition for '{word}'." + return _format(word, payload) + except Exception: + return "I couldn't look up that word right now." + + return define_word +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_agent_cascade_define.py -q` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/define_tool.py tests/test_agent_cascade_define.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add define_word dictionary tool" +``` + +--- + +### Task 7: `get_time_in` (geocode + zoneinfo) + +**Files:** +- Create: `aai_cli/agent_cascade/worldclock_tool.py` +- Test: `tests/test_agent_cascade_worldclock.py` (Create) + +**Interfaces:** +- Consumes: `geocode.geocode`, `geocode.GeoResult` (Task 2). +- Produces: `worldclock_tool.WORLDCLOCK_TOOL_NAME = "get_time_in"`, `worldclock_tool.build_worldclock_tool(fetch=…, now=…) -> BaseTool`. Consumed by Task 12. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_worldclock.py +from __future__ import annotations + +from datetime import datetime, timezone + +from aai_cli.agent_cascade import geocode +from aai_cli.agent_cascade.worldclock_tool import WORLDCLOCK_TOOL_NAME, build_worldclock_tool + + +def test_time_in_place(monkeypatch) -> None: + monkeypatch.setattr( + geocode, "geocode", lambda name, *, fetch: geocode.GeoResult("Tokyo", 35.6, 139.6, "Asia/Tokyo") + ) + # 2026-06-22 12:00 UTC -> 21:00 JST. + now = lambda: datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc) + out = build_worldclock_tool(fetch=lambda url: {}, now=now).invoke({"place": "Tokyo"}) + assert out.startswith("In Tokyo it's Monday, June 22, 2026 at 09:00 PM") + assert "JST" in out + + +def test_time_in_no_match_apology(monkeypatch) -> None: + monkeypatch.setattr(geocode, "geocode", lambda name, *, fetch: None) + out = build_worldclock_tool(fetch=lambda url: {}).invoke({"place": "Nowhere"}) + assert out == "I couldn't find a place called 'Nowhere'." + + +def test_time_in_bad_timezone_apology(monkeypatch) -> None: + monkeypatch.setattr( + geocode, "geocode", lambda name, *, fetch: geocode.GeoResult("X", 0.0, 0.0, "Not/AZone") + ) + out = build_worldclock_tool(fetch=lambda url: {}).invoke({"place": "X"}) + assert out == "I couldn't get the time there right now." + + +def test_tool_name() -> None: + assert build_worldclock_tool().name == WORLDCLOCK_TOOL_NAME == "get_time_in" +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_worldclock.py -q` +Expected: FAIL — `ModuleNotFoundError`. + +- [ ] **Step 3: Create `worldclock_tool.py`** + +```python +# aai_cli/agent_cascade/worldclock_tool.py +"""A world-clock tool for the `assembly live` voice agent. + +Resolves a spoken place to its IANA timezone via the shared :mod:`geocode` helper, then +renders the current local time there with ``zoneinfo``. Two seams — the geocoder's +:data:`Fetcher` and a :data:`Clock` — are injected in tests. Failures never raise out to +the graph. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +from aai_cli.agent_cascade import geocode + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +WORLDCLOCK_TOOL_NAME = "get_time_in" + +Clock = Callable[[], datetime] + + +def _get_json(url: str) -> object: + """GET ``url`` and return parsed JSON (the default geocode-fetch seam).""" + import httpx + + response = httpx.get(url, timeout=15.0) + response.raise_for_status() + return response.json() + + +def _now() -> datetime: + """Return the current instant as a timezone-aware datetime (the default clock).""" + return datetime.now().astimezone() + + +def build_worldclock_tool(fetch: geocode.Fetcher = _get_json, now: Clock = _now) -> BaseTool: + """Wrap the geocode→timezone lookup as the ``get_time_in`` tool (seams injectable).""" + from langchain_core.tools import tool + + @tool(WORLDCLOCK_TOOL_NAME) + def get_time_in(place: str) -> str: + """Get the current local time in a named place (a city or country). Use for "what + time is it in X" or "is it morning in X".""" + result = geocode.geocode(place, fetch=fetch) + if result is None: + return f"I couldn't find a place called '{place}'." + try: + local = now().astimezone(ZoneInfo(result.timezone)) + return f"In {result.name} it's {local.strftime('%A, %B %d, %Y at %I:%M %p %Z')}." + except Exception: + return "I couldn't get the time there right now." + + return get_time_in +``` + +Note: each place-aware tool owns its own `_get_json` default (matching `weather_tool`/`topic_tool`); `geocode.geocode` itself takes `fetch` as a required keyword and has no default fetcher. This keeps `geocode.py` free of a network default and avoids any cross-module private access. + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_agent_cascade_worldclock.py -q` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/worldclock_tool.py aai_cli/agent_cascade/geocode.py tests/test_agent_cascade_worldclock.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add get_time_in world-clock tool" +``` + +--- + +### Task 8: `date_math` (python-dateutil) + +**Files:** +- Create: `aai_cli/agent_cascade/datemath_tool.py` +- Test: `tests/test_agent_cascade_datemath.py` (Create) + +**Interfaces:** +- Produces: `datemath_tool.DATEMATH_TOOL_NAME = "date_math"`, `datemath_tool.build_datemath_tool(now=…) -> BaseTool`. Consumed by Task 12. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_datemath.py +from __future__ import annotations + +from datetime import datetime, timezone + +from aai_cli.agent_cascade.datemath_tool import DATEMATH_TOOL_NAME, build_datemath_tool + +# Pin "today" to 2026-06-22 (a Monday). +_NOW = lambda: datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc) + + +def test_single_future_date() -> None: + out = build_datemath_tool(now=_NOW).invoke({"date": "2026-07-04"}) + assert out == "July 04, 2026 is a Saturday — 12 days from now." + + +def test_single_past_date() -> None: + out = build_datemath_tool(now=_NOW).invoke({"date": "2026-06-20"}) + assert out == "June 20, 2026 is a Saturday — 2 days ago." + + +def test_single_today() -> None: + out = build_datemath_tool(now=_NOW).invoke({"date": "2026-06-22"}) + assert out == "June 22, 2026 is a Monday — that's today." + + +def test_one_day_singular() -> None: + out = build_datemath_tool(now=_NOW).invoke({"date": "2026-06-23"}) + assert "1 day from now." in out + + +def test_two_dates_span() -> None: + out = build_datemath_tool(now=_NOW).invoke({"date": "2026-03-01", "other_date": "2026-08-25"}) + assert out.startswith("There are 177 days between March 01 and August 25, 2026") + assert "5 months" in out and "3 weeks" in out + + +def test_bad_date_apology() -> None: + assert build_datemath_tool(now=_NOW).invoke({"date": "not-a-date"}) == "I couldn't work out those dates." + + +def test_tool_name() -> None: + assert build_datemath_tool().name == DATEMATH_TOOL_NAME == "date_math" +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_datemath.py -q` +Expected: FAIL — `ModuleNotFoundError`. + +- [ ] **Step 3: Create `datemath_tool.py`** + +```python +# aai_cli/agent_cascade/datemath_tool.py +"""A date-arithmetic tool for the `assembly live` voice agent. + +The LLM knows calendar facts but miscounts across them; this tool does the exact day +counting and weekday. The model passes ISO dates it worked out; the tool reports the +weekday + signed distance from today (one date) or the span between two dates (two). The +only seam is a :data:`Clock`. ``python-dateutil`` is imported lazily. Failures never raise. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import date, datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +DATEMATH_TOOL_NAME = "date_math" + +Clock = Callable[[], datetime] + + +def _now() -> datetime: + """Return the current instant (the default clock).""" + return datetime.now().astimezone() + + +def _days(n: int) -> str: + """Pluralize a day count: ``1 day`` / ``2 days``.""" + return f"{n} day" if n == 1 else f"{n} days" + + +def _signed_distance(target: date, today: date) -> str: + """A spoken distance from today: ``that's today`` / ``N days from now`` / ``N days ago``.""" + delta = (target - today).days + if delta == 0: + return "that's today" + if delta > 0: + return f"{_days(delta)} from now" + return f"{_days(-delta)} ago" + + +def _breakdown(start: date, end: date) -> str: + """A human span via ``relativedelta``, e.g. ``about 5 months and 3 weeks``.""" + from dateutil.relativedelta import relativedelta + + rel = relativedelta(end, start) + parts: list[str] = [] + if rel.years: + parts.append(f"{rel.years} year" + ("" if rel.years == 1 else "s")) + if rel.months: + parts.append(f"{rel.months} month" + ("" if rel.months == 1 else "s")) + weeks, days = divmod(rel.days, 7) + if weeks: + parts.append(f"{weeks} week" + ("" if weeks == 1 else "s")) + if days: + parts.append(_days(days)) + return "about " + " and ".join(parts) if parts else "the same day" + + +def _single(target: date, today: date) -> str: + """One-date report: weekday + signed distance from today.""" + return f"{target.strftime('%B %d, %Y')} is a {target.strftime('%A')} — {_signed_distance(target, today)}." + + +def _span(d1: date, d2: date) -> str: + """Two-date report: total days between + a human breakdown.""" + start, end = sorted((d1, d2)) + total = (end - start).days + return ( + f"There are {total} days between {start.strftime('%B %d')} and " + f"{end.strftime('%B %d, %Y')} — {_breakdown(start, end)}." + ) + + +def build_datemath_tool(now: Clock = _now) -> BaseTool: + """Wrap date arithmetic as the ``date_math`` tool (``now`` injectable).""" + from langchain_core.tools import tool + + @tool(DATEMATH_TOOL_NAME) + def date_math(date: str, other_date: str | None = None) -> str: + """Count days and weekdays between dates. Work out the relevant date(s) yourself and + pass them as YYYY-MM-DD strings; this tool does the exact counting. Pass one date for + "what weekday is X" / "how many days until X" (e.g. "2026-12-25"); pass two for "days + between X and Y".""" + from dateutil import parser as date_parser + + try: + today = now().date() + first = date_parser.isoparse(date).date() + if other_date is None: + return _single(first, today) + return _span(first, date_parser.isoparse(other_date).date()) + except Exception: + return "I couldn't work out those dates." + + return date_math +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_agent_cascade_datemath.py -q` +Expected: PASS (7 tests). If the span breakdown wording differs, adjust the assertion to the real `relativedelta` output (March 1 → August 25 is 5 months, 24 days → "5 months and 3 weeks and 3 days"); update the test to match exactly. + +- [ ] **Step 5: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/datemath_tool.py tests/test_agent_cascade_datemath.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add date_math tool via python-dateutil" +``` + +--- + +### Task 9: `check_holiday` (holidays) + +**Files:** +- Create: `aai_cli/agent_cascade/holiday_tool.py` +- Test: `tests/test_agent_cascade_holiday.py` (Create) + +**Interfaces:** +- Produces: `holiday_tool.HOLIDAY_TOOL_NAME = "check_holiday"`, `holiday_tool.build_holiday_tool(now=…) -> BaseTool`. Consumed by Task 12. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_holiday.py +from __future__ import annotations + +from datetime import datetime, timezone + +from aai_cli.agent_cascade.holiday_tool import HOLIDAY_TOOL_NAME, build_holiday_tool + +_NOW = lambda: datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc) + + +def test_date_is_a_holiday() -> None: + out = build_holiday_tool(now=_NOW).invoke({"country": "US", "date": "2026-12-25"}) + assert out == "December 25, 2026 is Christmas Day in US." + + +def test_date_is_not_a_holiday() -> None: + out = build_holiday_tool(now=_NOW).invoke({"country": "US", "date": "2026-03-03"}) + assert out == "March 03, 2026 is not a public holiday in US." + + +def test_next_holiday() -> None: + out = build_holiday_tool(now=_NOW).invoke({"country": "US"}) + assert out == "The next US public holiday is Independence Day on July 04, 2026 — 12 days from now." + + +def test_unknown_country_apology() -> None: + out = build_holiday_tool(now=_NOW).invoke({"country": "ZZ"}) + assert out == "I don't have holiday data for that country." + + +def test_bad_date_apology() -> None: + out = build_holiday_tool(now=_NOW).invoke({"country": "US", "date": "nope"}) + assert out == "I couldn't work out that date." + + +def test_tool_name() -> None: + assert build_holiday_tool().name == HOLIDAY_TOOL_NAME == "check_holiday" +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_holiday.py -q` +Expected: FAIL — `ModuleNotFoundError`. + +- [ ] **Step 3: Create `holiday_tool.py`** + +```python +# aai_cli/agent_cascade/holiday_tool.py +"""A public-holiday tool for the `assembly live` voice agent. + +Backed by the offline ``holidays`` library (no network). With a date it names the holiday +(or says there isn't one); without a date it reports the next upcoming holiday from today. +The only seam is a :data:`Clock`. ``holidays``/``dateutil`` are imported lazily. Failures +never raise out to the graph. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +HOLIDAY_TOOL_NAME = "check_holiday" + +Clock = Callable[[], datetime] + + +def _now() -> datetime: + """Return the current instant (the default clock).""" + return datetime.now().astimezone() + + +def _days(n: int) -> str: + """A spoken distance: ``today`` / ``1 day from now`` / ``N days from now``.""" + if n == 0: + return "today" + return f"{n} day from now" if n == 1 else f"{n} days from now" + + +def build_holiday_tool(now: Clock = _now) -> BaseTool: + """Wrap the holiday lookup as the ``check_holiday`` tool (``now`` injectable).""" + from langchain_core.tools import tool + + @tool(HOLIDAY_TOOL_NAME) + def check_holiday(country: str = "US", date: str | None = None) -> str: + """Look up public holidays. ``country`` is a two-letter code (US, GB, DE, …; defaults + to US when the user names no country). With a YYYY-MM-DD ``date`` it says whether that + day is a holiday; without one it reports the next upcoming public holiday.""" + import holidays as holidays_lib + from dateutil import parser as date_parser + + try: + calendar_class = holidays_lib.country_holidays # raises NotImplementedError on unknown country + today = now().date() + if date is not None: + day = date_parser.isoparse(date).date() + name = calendar_class(country, years=day.year).get(day) + nice = day.strftime("%B %d, %Y") + if name: + return f"{nice} is {name} in {country}." + return f"{nice} is not a public holiday in {country}." + calendar = calendar_class(country, years=[today.year, today.year + 1]) + upcoming = sorted((d, n) for d, n in calendar.items() if d >= today) + day, name = upcoming[0] + return ( + f"The next {country} public holiday is {name} on " + f"{day.strftime('%B %d, %Y')} — {_days((day - today).days)}." + ) + except NotImplementedError: + return "I don't have holiday data for that country." + except (ValueError, OverflowError): + return "I couldn't work out that date." + + return check_holiday +``` + +Note: confirm `holidays.country_holidays("ZZ")` raises `NotImplementedError` at call time (not lazily). If the installed version raises a different type for an unknown country, widen the `except` accordingly and update the test to match. + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_agent_cascade_holiday.py -q` +Expected: PASS (6 tests). If the library names Independence Day differently (e.g. "Independence Day (observed)" when July 4 falls on a weekend — 2026-07-04 is a Saturday), adjust the assertion to the real name the library returns for 2026. + +- [ ] **Step 5: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/holiday_tool.py tests/test_agent_cascade_holiday.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add check_holiday tool via holidays" +``` + +--- + +### Task 10: `sun_times` (astral + geocode) + +**Files:** +- Create: `aai_cli/agent_cascade/suntimes_tool.py` +- Test: `tests/test_agent_cascade_suntimes.py` (Create) + +**Interfaces:** +- Consumes: `geocode.geocode`, `geocode.GeoResult` (Task 2). +- Produces: `suntimes_tool.SUNTIMES_TOOL_NAME = "sun_times"`, `suntimes_tool.build_suntimes_tool(fetch=…, now=…) -> BaseTool`. Consumed by Task 12. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_agent_cascade_suntimes.py +from __future__ import annotations + +from datetime import datetime, timezone + +from aai_cli.agent_cascade import geocode +from aai_cli.agent_cascade.suntimes_tool import SUNTIMES_TOOL_NAME, build_suntimes_tool, _moon_name + +_NOW = lambda: datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc) + + +def test_sun_times_happy_path(monkeypatch) -> None: + monkeypatch.setattr( + geocode, "geocode", lambda name, *, fetch: geocode.GeoResult("Paris", 48.85, 2.35, "Europe/Paris") + ) + out = build_suntimes_tool(fetch=lambda url: {}, now=_NOW).invoke({"place": "Paris"}) + assert out.startswith("In Paris today the sun rises at ") + assert "and sets at " in out and "the moon is " in out + + +def test_sun_times_no_match_apology(monkeypatch) -> None: + monkeypatch.setattr(geocode, "geocode", lambda name, *, fetch: None) + out = build_suntimes_tool(fetch=lambda url: {}).invoke({"place": "Nowhere"}) + assert out == "I couldn't find a place called 'Nowhere'." + + +def test_sun_times_bad_timezone_apology(monkeypatch) -> None: + monkeypatch.setattr( + geocode, "geocode", lambda name, *, fetch: geocode.GeoResult("X", 0.0, 0.0, "Not/AZone") + ) + out = build_suntimes_tool(fetch=lambda url: {}, now=_NOW).invoke({"place": "X"}) + assert out == "I couldn't get the sun times there right now." + + +def test_moon_name_bins() -> None: + assert _moon_name(0.0) == "a new moon" + assert _moon_name(7.0) == "a first-quarter moon" + assert _moon_name(14.0) == "a full moon" + assert _moon_name(21.0) == "a last-quarter moon" + + +def test_tool_name() -> None: + assert build_suntimes_tool().name == SUNTIMES_TOOL_NAME == "sun_times" +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_suntimes.py -q` +Expected: FAIL — `ModuleNotFoundError`. + +- [ ] **Step 3: Create `suntimes_tool.py`** + +```python +# aai_cli/agent_cascade/suntimes_tool.py +"""A sunrise/sunset + moon-phase tool for the `assembly live` voice agent. + +Resolves a place to coordinates + timezone via the shared :mod:`geocode` helper, then +computes today's sun times and the moon phase offline with ``astral``. Two seams — the +geocoder's :data:`Fetcher` and a :data:`Clock` — are injected in tests. Failures never +raise out to the graph. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +from aai_cli.agent_cascade import geocode + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +SUNTIMES_TOOL_NAME = "sun_times" + +Clock = Callable[[], datetime] + +# astral.moon.phase() returns 0..27.99; the lunar cycle's four quarters sit at 0/7/14/21. +# Each name owns a ~3.5-wide bin centered on its landmark (new wraps around 28→0). +_MOON_NAMES = ( + (1.75, "a new moon"), + (5.25, "a waxing crescent"), + (8.75, "a first-quarter moon"), + (12.25, "a waxing gibbous"), + (15.75, "a full moon"), + (19.25, "a waning gibbous"), + (22.75, "a last-quarter moon"), + (26.25, "a waning crescent"), +) + + +def _get_json(url: str) -> object: + """GET ``url`` and return parsed JSON (the default geocode-fetch seam).""" + import httpx + + response = httpx.get(url, timeout=15.0) + response.raise_for_status() + return response.json() + + +def _now() -> datetime: + """Return the current instant (the default clock).""" + return datetime.now().astimezone() + + +def _moon_name(phase: float) -> str: + """Map an astral moon phase (0..28) to a spoken phase name.""" + for upper, name in _MOON_NAMES: + if phase < upper: + return name + return "a new moon" # 26.25..28 wraps back to new + + +def build_suntimes_tool(fetch: geocode.Fetcher = _get_json, now: Clock = _now) -> BaseTool: + """Wrap the sun/moon lookup as the ``sun_times`` tool (seams injectable).""" + from langchain_core.tools import tool + + @tool(SUNTIMES_TOOL_NAME) + def sun_times(place: str) -> str: + """Get today's sunrise and sunset and the current moon phase for a place. Use for + "when does the sun set in X", "what time is sunrise in X", or "what's the moon + phase".""" + result = geocode.geocode(place, fetch=fetch) + if result is None: + return f"I couldn't find a place called '{place}'." + try: + from astral import LocationInfo + from astral.moon import phase as moon_phase + from astral.sun import sun + + tz = ZoneInfo(result.timezone) + today = now().astimezone(tz).date() + location = LocationInfo(latitude=result.latitude, longitude=result.longitude) + times = sun(location.observer, date=today, tzinfo=tz) + sunrise = times["sunrise"].strftime("%I:%M %p") + sunset = times["sunset"].strftime("%I:%M %p") + return ( + f"In {result.name} today the sun rises at {sunrise} and sets at {sunset}, " + f"and the moon is {_moon_name(moon_phase(today))}." + ) + except Exception: + return "I couldn't get the sun times there right now." + + return sun_times +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_agent_cascade_suntimes.py -q` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/suntimes_tool.py tests/test_agent_cascade_suntimes.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add sun_times tool via astral" +``` + +--- + +### Task 11: Wire all eight into `brain.py` + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` (the imports, `_TOOL_LABELS`, `_tool_capabilities`, `build_live_tools`) +- Test: `tests/test_agent_cascade_brain.py` (Modify — add wiring assertions) + +**Interfaces:** +- Consumes: every `build_*_tool` and `*_TOOL_NAME` from Tasks 3–10. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_agent_cascade_brain.py`: +```python +def test_build_live_tools_includes_all_keyless_tools(monkeypatch) -> None: + # Force the keyed web-search tool absent so we assert only the always-on set. + from aai_cli.agent_cascade import brain, firecrawl_search + + monkeypatch.setattr(firecrawl_search, "build_web_search_tool", lambda: None) + names = {t.name for t in brain.build_live_tools()} + assert { + "get_weather", "read_url", "get_current_datetime", + "look_up_topic", "calculate", "convert_units", "define_word", + "get_time_in", "date_math", "check_holiday", "sun_times", + } <= names + + +def test_tool_labels_present() -> None: + from aai_cli.agent_cascade import brain + + for name, label in [ + ("look_up_topic", "Looking that up"), + ("calculate", "Calculating"), + ("convert_units", "Converting units"), + ("define_word", "Looking up a definition"), + ("get_time_in", "Checking the time there"), + ("date_math", "Working out dates"), + ("check_holiday", "Checking holidays"), + ("sun_times", "Checking sun times"), + ]: + assert brain._tool_label(name) == label + + +def test_capabilities_advertise_new_tools() -> None: + from aai_cli.agent_cascade import brain + + tools = brain.build_live_tools() + caps = " ".join(brain._tool_capabilities(tools)) + for phrase in [ + "look up facts about people", "do arithmetic", "convert units and currencies", + "define words", "tell the current time in a place", + "do date math", "public holidays", "sunrise, sunset", + ]: + assert phrase in caps +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -k "live_tools or labels or capabilities" -q` +Expected: FAIL — missing tool names/labels/phrases. + +- [ ] **Step 3: Add the imports** + +At the top of `brain.py`, extend the existing tool-module import (currently `from aai_cli.agent_cascade import datetime_tool, weather_tool, webpage_tool`) to: +```python +from aai_cli.agent_cascade import ( + calc_tool, + datemath_tool, + datetime_tool, + define_tool, + holiday_tool, + suntimes_tool, + topic_tool, + units_tool, + weather_tool, + webpage_tool, + worldclock_tool, +) +``` + +- [ ] **Step 4: Extend `_TOOL_LABELS`** + +In the `_TOOL_LABELS` dict, after the `datetime_tool.DATETIME_TOOL_NAME` entry, add: +```python + topic_tool.LOOKUP_TOOL_NAME: "Looking that up", + calc_tool.CALC_TOOL_NAME: "Calculating", + units_tool.CONVERT_TOOL_NAME: "Converting units", + define_tool.DEFINE_TOOL_NAME: "Looking up a definition", + worldclock_tool.WORLDCLOCK_TOOL_NAME: "Checking the time there", + datemath_tool.DATEMATH_TOOL_NAME: "Working out dates", + holiday_tool.HOLIDAY_TOOL_NAME: "Checking holidays", + suntimes_tool.SUNTIMES_TOOL_NAME: "Checking sun times", +``` + +- [ ] **Step 5: Extend `_tool_capabilities`** + +In `_tool_capabilities`, after the existing `datetime_tool` check, add one gated phrase per tool (mirroring the existing `if … in names:` blocks): +```python + if topic_tool.LOOKUP_TOOL_NAME in names: + capabilities.append("look up facts about people, places, and topics") + if calc_tool.CALC_TOOL_NAME in names: + capabilities.append("do arithmetic, percentages, and math like square roots and averages") + if units_tool.CONVERT_TOOL_NAME in names: + capabilities.append("convert units and currencies") + if define_tool.DEFINE_TOOL_NAME in names: + capabilities.append("define words and give synonyms") + if worldclock_tool.WORLDCLOCK_TOOL_NAME in names: + capabilities.append("tell the current time in a place") + if datemath_tool.DATEMATH_TOOL_NAME in names: + capabilities.append("do date math, like days until a date or the weekday of a date") + if holiday_tool.HOLIDAY_TOOL_NAME in names: + capabilities.append("tell you about public holidays") + if suntimes_tool.SUNTIMES_TOOL_NAME in names: + capabilities.append("tell you sunrise, sunset, and the moon phase for a place") +``` + +- [ ] **Step 6: Extend `build_live_tools`** + +In `build_live_tools`, add the eight factory imports and append their tools to the `tools` list before the web-search block: +```python + from aai_cli.agent_cascade.calc_tool import build_calc_tool + from aai_cli.agent_cascade.datemath_tool import build_datemath_tool + from aai_cli.agent_cascade.define_tool import build_define_tool + from aai_cli.agent_cascade.holiday_tool import build_holiday_tool + from aai_cli.agent_cascade.suntimes_tool import build_suntimes_tool + from aai_cli.agent_cascade.topic_tool import build_lookup_tool + from aai_cli.agent_cascade.units_tool import build_convert_tool + from aai_cli.agent_cascade.worldclock_tool import build_worldclock_tool +``` +and extend the list literal: +```python + tools: list[BaseTool] = [ + build_weather_tool(), + build_read_url_tool(), + build_datetime_tool(), + build_lookup_tool(), + build_calc_tool(), + build_convert_tool(), + build_define_tool(), + build_worldclock_tool(), + build_datemath_tool(), + build_holiday_tool(), + build_suntimes_tool(), + ] +``` +Also update the `build_live_tools` docstring to reflect the broader keyless toolset (it currently names only weather/read-url/datetime). + +- [ ] **Step 7: Run the brain tests** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -q` +Expected: PASS. Also run the prompt snapshot tests if capability copy is snapshot-pinned: `uv run pytest tests/test_agent_cascade_prompt.py -q` and regenerate with `--snapshot-update` if the system-prompt golden changed. + +- [ ] **Step 8: Commit (WIP)** + +```bash +AAI_ALLOW_COMMIT=1 git add aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py tests/test_agent_cascade_prompt.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): bind and advertise the eight new tools" +``` + +--- + +### Task 12: Gate green + open PR-B + +**Files:** none (verification + the real commit) + +- [ ] **Step 1: Run the full gate** + +Run: `./scripts/check.sh` +Expected: `All checks passed.` Fix anything it flags — most likely: a surviving mutant on a changed line (add an assertion that fails when that line breaks), a patch-coverage gap (cover the missed branch), `vulture` flagging an unused helper, or the docstring-coverage ratchet. Re-run until green. + +- [ ] **Step 2: Push the branch and open PR-B** + +The per-task WIP commits stay as-is — the merge queue squash-merges them into one commit, so no local rebase/reset is needed (interactive rebase isn't available in this environment anyway). Push the branch and open PR-B against `main`, noting in the body that it depends on PR-A (the dependency PR) and should land after it. Use a squash-merge title like: + +``` +feat(live): add eight keyless tools to assembly live + +look_up_topic, calculate, convert_units, define_word, get_time_in, +date_math, check_holiday, sun_times — all always-bound (no API key), +plus a shared geocode.py extracted from weather_tool. +``` + +Let it land through the merge queue (which re-runs the diff-scoped gates against the combined state). + +--- + +## Self-Review Notes + +- **Spec coverage:** all eight tools (Tasks 3–10), `geocode.py` refactor (Task 2), the five deps (Task 1), and the three `brain.py` edits (Task 11) map to tasks. The spec's per-tool failure-apology strings are asserted verbatim in each task's tests. +- **Live-API caveat:** several tests exercise the *real* libraries (`pint`, `simpleeval`, `dateutil`, `holidays`, `astral`) — these are offline, so the tests stay hermetic. Only the HTTP tools (`look_up_topic`, `define_word`, currency path of `convert_units`, and the geocoders) use the injected `Fetcher`; no test hits the network. +- **Brittle-assertion watch:** the `date_math` span breakdown, the `holidays` Independence-Day naming, and the exact `pint` rounding are computed by third-party libraries — each task's Step 4 says to reconcile the assertion with the library's actual output rather than assume the wording above. diff --git a/docs/superpowers/plans/2026-06-22-live-read-url-tool.md b/docs/superpowers/plans/2026-06-22-live-read-url-tool.md new file mode 100644 index 00000000..f6fe4ac9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-read-url-tool.md @@ -0,0 +1,391 @@ +# Read-a-URL tool (web pages + PDFs) for `assembly live` — 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:** Give the `assembly live` voice agent a keyless, always-present `read_url` tool that fetches a web page or PDF by URL and returns its readable text. + +**Architecture:** A new `aai_cli/agent_cascade/webpage_tool.py` wraps the existing `core/webpage.py:fetch_article` (HTML via trafilatura, PDF via pypdf — the same reader behind `assembly speak --url`) as a LangChain `BaseTool`, mirroring the sibling `weather_tool.py` (pure helpers + one injected network seam + best-effort error handling). It is wired into the live deepagents graph through the three existing tool hooks in `brain.py`. + +**Tech Stack:** Python 3.12+, LangChain `@tool`, deepagents, pytest. Tests are hermetic via the injected `read` seam (pytest-socket stays armed). + +## Global Constraints + +- `from __future__ import annotations` at the top of every module; modern typing (`X | None`). +- Internal helper docstrings keep trailing periods (mutation gate flags a changed docstring line); the `read_url` tool's own docstring is its model-facing description. +- **Lazy-import the heavy reader.** `core/webpage.py` imports `httpx2` at module top, so `webpage_tool.py` must NOT import `fetch_article` at module scope — defer it inside `_read` (mirrors `weather_tool._get_json` deferring `httpx`). Keep `Article` under `TYPE_CHECKING` only. +- The single network seam is an injected `Reader` callable; tests pass a fake — no sockets, no real `fetch_article`. +- The tool is **best-effort and never raises** into the graph (a spoken turn can't surface a traceback). +- Gate rules: iterate with targeted `uv run pytest`, then run `./scripts/check.sh` to completion before the final commit (it must print `All checks passed.`). The branch `live-tool-call-ux` carries **unrelated in-flight working-tree changes** — `git add` only this feature's files for each commit, and use `AAI_ALLOW_COMMIT=1 git commit …` for the intermediate (Task 1) commit since the full tree can't be cleanly gated mid-flight. + +--- + +### Task 1: The `webpage_tool.py` module (read_url tool, standalone) + +**Files:** +- Create: `aai_cli/agent_cascade/webpage_tool.py` +- Test: `tests/test_agent_cascade_webpage.py` + +**Interfaces:** +- Consumes: `aai_cli.core.webpage.fetch_article` (`(url: str) -> Article`, `Article` has `.text: str` and `.title: str | None`); `aai_cli.core.errors.UsageError` (raised for a non-http(s) URL or no readable text), `APIError` (raised for fetch failure) — both `CLIError` subclasses. +- Produces (later tasks rely on these exact names): + - `READ_URL_TOOL_NAME = "read_url"` (str constant) + - `Reader = Callable[[str], Article]` (type alias) + - `build_read_url_tool(read: Reader = _read) -> BaseTool` — returns a tool named `read_url` taking `url: str` returning `str` + - `_format(article: Article) -> str`, `_read(url: str) -> Article` (module-private) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_agent_cascade_webpage.py`: + +```python +"""Tests for the keyless read-a-URL tool behind `assembly live`. + +The tool's only network seam is the injected ``read`` callable, so the whole +fetch -> format flow runs with no sockets (pytest-socket stays armed). +""" + +from __future__ import annotations + +import pytest + +from aai_cli.agent_cascade import webpage_tool +from aai_cli.core.errors import APIError, UsageError +from aai_cli.core.webpage import Article + + +def _article(text: str = "Body text.", title: str | None = "Title") -> Article: + return Article(text=text, title=title, url="https://example.com/post") + + +# --- _format ----------------------------------------------------------------- + + +def test_format_leads_with_title_then_body(): + out = webpage_tool._format(_article(text="Hello world.", title="My Post")) + assert out == "My Post\n\nHello world." + + +def test_format_without_title_is_body_only(): + out = webpage_tool._format(_article(text="Just the body.", title=None)) + assert out == "Just the body." + + +def test_format_truncates_long_body_with_marker(): + long = "x" * (webpage_tool._MAX_CHARS + 50) + out = webpage_tool._format(_article(text=long, title=None)) + assert out == "x" * webpage_tool._MAX_CHARS + "\n…[truncated]" + + +def test_format_keeps_short_body_untruncated(): + out = webpage_tool._format(_article(text="short", title=None)) + assert "[truncated]" not in out + assert out == "short" + + +# --- _read (default seam delegates to core.webpage.fetch_article) ------------ + + +def test_read_delegates_to_fetch_article(monkeypatch): + captured = {} + + def fake_fetch_article(url: str) -> Article: + captured["url"] = url + return _article() + + monkeypatch.setattr("aai_cli.core.webpage.fetch_article", fake_fetch_article) + result = webpage_tool._read("https://example.com/post") + assert captured["url"] == "https://example.com/post" + assert result.title == "Title" + + +# --- build_read_url_tool ----------------------------------------------------- + + +def test_tool_is_named_read_url(): + tool = webpage_tool.build_read_url_tool(read=lambda url: _article()) + assert tool.name == webpage_tool.READ_URL_TOOL_NAME + + +def test_read_url_happy_path_returns_formatted_text(): + tool = webpage_tool.build_read_url_tool(read=lambda url: _article(text="Article.", title="T")) + assert tool.invoke({"url": "https://example.com"}) == "T\n\nArticle." + + +def test_read_url_usage_error_returns_no_readable_text_message(): + def read(url: str) -> Article: + raise UsageError("Couldn't find readable text.") + + tool = webpage_tool.build_read_url_tool(read=read) + assert tool.invoke({"url": "https://example.com"}) == ( + "I couldn't find readable text on that page." + ) + + +def test_read_url_fetch_failure_returns_could_not_read_message(): + def read(url: str) -> Article: + raise APIError("DNS boom") + + tool = webpage_tool.build_read_url_tool(read=read) + assert tool.invoke({"url": "https://example.com"}) == ( + "I couldn't read that page right now." + ) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_webpage.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'aai_cli.agent_cascade.webpage_tool'` (collection error). + +- [ ] **Step 3: Write the module** + +Create `aai_cli/agent_cascade/webpage_tool.py`: + +```python +"""A keyless read-a-URL tool for the `assembly live` voice agent. + +Reads a web page or PDF the agent has a URL for and returns its readable text, so +the live agent can read an article the user names or a link surfaced by web search. +It reuses :func:`aai_cli.core.webpage.fetch_article` — the same trafilatura HTML +extraction and pypdf PDF text extraction that backs ``assembly speak --url`` — so no +API key is needed and every live session has this capability. + +The only network seam is :data:`Reader` (a ``url -> Article`` callable), injected in +tests so the whole flow runs with no sockets — the same shape ``weather_tool`` uses. +Failures never raise out to the graph: ``read_url`` catches them and returns a short +spoken apology so a fetch outage can't sink a live turn. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from aai_cli.core.errors import UsageError + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + + from aai_cli.core.webpage import Article + +# The registered tool name. ``brain.py`` detects availability and labels the live-UI +# affordance by this name, so a test pins it. +READ_URL_TOOL_NAME = "read_url" + +# A reader GETs a URL and returns the extracted Article. Injected in tests (the only net seam). +Reader = Callable[[str], "Article"] + +# Cap the returned text so a long article or multi-page PDF can't blow the model's context +# budget. The body is source for the model to summarize aloud, so the exact cap is a tuning +# knob — a +-1 shift is behaviorally equivalent, so no test can kill that mutant. +_MAX_CHARS = 16000 # pragma: no mutate + + +def _read(url: str) -> Article: + """Fetch and extract ``url`` via core.webpage (imported lazily to stay off startup).""" + from aai_cli.core.webpage import fetch_article + + return fetch_article(url) + + +def _format(article: Article) -> str: + """Render the article as ``title + readable text``, truncated to ``_MAX_CHARS``.""" + body = article.text + if len(body) > _MAX_CHARS: + body = body[:_MAX_CHARS] + "\n…[truncated]" + if article.title: + return f"{article.title}\n\n{body}" + return body + + +def build_read_url_tool(read: Reader = _read) -> BaseTool: + """Wrap the URL reader as the ``read_url`` tool (``read`` injectable for tests).""" + from langchain_core.tools import tool + + @tool(READ_URL_TOOL_NAME) + def read_url(url: str) -> str: + """Read a web page or PDF by URL and return its text. Use to read an article, + document, or page you have the URL for (e.g. from a web-search result).""" + try: + return _format(read(url)) + except UsageError: + # Bad URL or no readable text (scanned/image-only PDF, paywalled/JS page). + return "I couldn't find readable text on that page." + except Exception: + # Any fetch failure (APIError: DNS/timeout/non-2xx, or anything else) must not + # bubble into brain's "couldn't complete the turn" path. Mirrors weather_tool. + return "I couldn't read that page right now." + + return read_url +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_webpage.py -q` +Expected: PASS (10 passed). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/webpage_tool.py tests/test_agent_cascade_webpage.py +AAI_ALLOW_COMMIT=1 git commit -m "feat: read-a-URL (web + PDF) tool module for assembly live" +``` + +--- + +### Task 2: Wire `read_url` into the live agent (`brain.py`) + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` (import line ~24, `_TOOL_LABELS`, `_tool_capabilities`, `build_live_tools`) +- Test: `tests/test_agent_cascade_brain.py` (update two existing `build_live_tools` tests; add three wiring tests) + +**Interfaces:** +- Consumes from Task 1: `webpage_tool.READ_URL_TOOL_NAME`, `webpage_tool.build_read_url_tool`. +- Produces: `build_live_tools()` returns `[weather, read_url, (web_search if keyed)]` in that order; `_tool_label("read_url")` returns `"Reading the page"`; `build_system_prompt` advertises reading a page/PDF. + +- [ ] **Step 1: Update the two existing `build_live_tools` tests + add wiring tests (failing)** + +In `tests/test_agent_cascade_brain.py`, change the import (line ~18) from: + +```python +from aai_cli.agent_cascade import brain, weather_tool +``` + +to: + +```python +from aai_cli.agent_cascade import brain, weather_tool, webpage_tool +``` + +Replace the existing `test_build_live_tools_has_weather_and_web_search_when_keyed` body (lines ~378-384) so it also asserts read_url: + +```python +def test_build_live_tools_has_weather_and_web_search_when_keyed(monkeypatch): + search = _NamedTool(brain.WEB_SEARCH_TOOL_NAME) + monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: search) + names = [tool.name for tool in brain.build_live_tools()] + # Web search is the optional keyed leg; the keyless weather + read-url tools are always present. + assert brain.WEB_SEARCH_TOOL_NAME in names + assert weather_tool.WEATHER_TOOL_NAME in names + assert webpage_tool.READ_URL_TOOL_NAME in names +``` + +Replace `test_build_live_tools_is_just_weather_without_firecrawl_key` (lines ~387-391) with the keyless-pair assertion (renamed): + +```python +def test_build_live_tools_has_weather_and_read_url_without_firecrawl_key(monkeypatch): + monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: None) + # No FIRECRAWL_API_KEY -> no web search, but the keyless weather + read-url tools still load. + names = [tool.name for tool in brain.build_live_tools()] + assert names == [weather_tool.WEATHER_TOOL_NAME, webpage_tool.READ_URL_TOOL_NAME] +``` + +Add three new tests at the end of the file (after `test_tool_label_maps_weather`): + +```python +def test_read_url_tool_advertised_in_system_prompt(): + prompt = brain.build_system_prompt( + "persona", tools=[_NamedTool(webpage_tool.READ_URL_TOOL_NAME)] + ) + assert "read a web page or PDF" in prompt + + +def test_tool_label_maps_read_url(): + assert brain._tool_label(webpage_tool.READ_URL_TOOL_NAME) == "Reading the page" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -q` +Expected: FAIL — `test_build_live_tools_has_weather_and_read_url_without_firecrawl_key` (read_url not yet built), `test_read_url_tool_advertised_in_system_prompt` (phrase absent), `test_tool_label_maps_read_url` (falls back to `"Using read_url"`), and the keyed test's new `in` assertion. + +- [ ] **Step 3: Wire `brain.py` — import** + +Change the import line (~24) from: + +```python +from aai_cli.agent_cascade import weather_tool +``` + +to: + +```python +from aai_cli.agent_cascade import weather_tool, webpage_tool +``` + +- [ ] **Step 4: Wire `brain.py` — `_TOOL_LABELS`** + +Add the read_url label to the `_TOOL_LABELS` dict (after the weather entry): + +```python +_TOOL_LABELS = { + WEB_SEARCH_TOOL_NAME: "Searching the web", + weather_tool.WEATHER_TOOL_NAME: "Checking the weather", + webpage_tool.READ_URL_TOOL_NAME: "Reading the page", +} +``` + +- [ ] **Step 5: Wire `brain.py` — `_tool_capabilities`** + +In `_tool_capabilities`, after the weather `if` block, add the read-url capability so it is advertised when present: + +```python + if weather_tool.WEATHER_TOOL_NAME in names: + capabilities.append("tell someone the current weather and short forecast for a place") + if webpage_tool.READ_URL_TOOL_NAME in names: + capabilities.append("read a web page or PDF you have the URL for") + return capabilities +``` + +- [ ] **Step 6: Wire `brain.py` — `build_live_tools`** + +In `build_live_tools`, add the lazy import and include the read-url tool in the always-present list. The body becomes: + +```python + from aai_cli.agent_cascade.weather_tool import build_weather_tool + from aai_cli.agent_cascade.webpage_tool import build_read_url_tool + from aai_cli.code_agent.firecrawl_search import build_web_search_tool + + tools: list[BaseTool] = [build_weather_tool(), build_read_url_tool()] + search = build_web_search_tool() + if search is not None: + tools.append(search) + return tools +``` + +Also update its docstring's first sentence to name the new tool, e.g. change *"the keyless weather tool, plus Firecrawl web search…"* to *"the keyless weather and read-a-URL tools, plus Firecrawl web search when ``FIRECRAWL_API_KEY`` is set."* + +- [ ] **Step 7: Run the brain tests to verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_brain.py tests/test_agent_cascade_webpage.py -q` +Expected: PASS (all green). + +- [ ] **Step 8: Run the full gate** + +Run: `./scripts/check.sh` +Expected: finishes with `All checks passed.` (covers ruff, mypy/pyright, vulture, import-linter, 100% patch coverage vs origin/main, and the diff-scoped mutation gate on the changed lines). Fix any finding before committing. + +- [ ] **Step 9: Commit** + +```bash +git add aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py +git commit -m "feat: wire read_url tool into assembly live" +``` + +--- + +## Self-Review + +**Spec coverage:** +- New module `webpage_tool.py` reusing `fetch_article` (HTML + PDF) → Task 1. +- `READ_URL_TOOL_NAME`, injected `Reader` seam, `_MAX_CHARS` truncation, `_format`, `build_read_url_tool` → Task 1 Step 3. +- Best-effort error handling (UsageError vs other → two apology strings) → Task 1 Steps 1 & 3. +- Always-present (keyless) wiring in `build_live_tools` → Task 2 Step 6. +- `_tool_capabilities` advertises read-url → Task 2 Step 5. +- `_TOOL_LABELS` "Reading the page" → Task 2 Step 4. +- Weather tool left untouched → confirmed (no weather edits in any task). +- Testing matrix (format title/no-title/truncation/short, happy, UsageError, APIError, brain wiring) → Tasks 1 & 2 test steps. +- Security note (no approval gate) → design-doc only; no code, correctly nothing to implement. + +**Placeholder scan:** none — every step carries full code/commands. + +**Type consistency:** `READ_URL_TOOL_NAME`, `Reader`, `build_read_url_tool(read=…)`, `_format`, `_read` names match between Task 1 (definition) and Task 2 (consumption); `build_live_tools` order `[weather, read_url, search?]` matches the updated keyless test assertion. diff --git a/docs/superpowers/plans/2026-06-22-live-sandboxed-execute.md b/docs/superpowers/plans/2026-06-22-live-sandboxed-execute.md new file mode 100644 index 00000000..23563df6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-sandboxed-execute.md @@ -0,0 +1,978 @@ +# Sandboxed `execute` + durable memory for `assembly live` (M1) 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:** Make `assembly live --files` able to run shell commands in the real cwd, OS-sandbox-confined (can't reach the network or escape cwd) and human-approved per run, and remember its work across sessions via a per-project memory file. + +**Architecture:** One new focused module `aai_cli/agent_cascade/sandbox.py` holds the entire sandbox concern: a `SandboxedShellBackend(LocalShellBackend)` whose `execute()` never delegates to the unconfined host shell but instead wraps the command in an OS sandbox (`sandbox-exec` SBPL on macOS, `bwrap` on Linux, refuse on every other platform / missing binary), driven by pure policy renderers and injectable `Runner`/capability seams so the suite is hermetic. `brain.py` swaps its filesystem backend for this one (which makes deepagents auto-add a *functional* `execute` tool), adds `execute` to the existing approval gate, and turns on `MemoryMiddleware` via `memory=["./.deepagents/AGENTS.md"]`. + +**Tech Stack:** Python 3.12+, deepagents + langgraph, Typer/Textual, pytest + syrupy, `uv`. Pure-subprocess OS sandbox — **no new dependency**. + +**Spec:** `docs/superpowers/specs/2026-06-22-live-sandboxed-execute-design.md` (this plan implements **Milestone M1** only — "Sandboxed `execute` + memory". M2 subagents and M3 spoken approval are separate plans/PRs that build on this one.) + +## Global Constraints + +- `from __future__ import annotations` at the top of every module (verbatim from `CLAUDE.md`). +- **No new dependency.** Pure subprocess over OS binaries (`sandbox-exec`, `bwrap`); `MemoryMiddleware` and `InMemorySaver` are already available. Do **not** touch `uv.lock`. +- **Live-only.** All new code lives in `aai_cli/agent_cascade/`; everything is gated behind `--files`. The `--files`-off path must be byte-for-byte unchanged. +- **Inert (safe refusal), never a fallback to unconfined execution.** On any platform other than macOS/Linux, or when the sandbox binary is missing, capability is `"none"` and `execute` returns a refusal string and **runs nothing**. The override must **never** call `super().execute()` (the unconfined host shell). This refuse-don't-fall-back branch is the single most safety-critical line. +- **`execute` never raises into the graph** — on any failure it returns a short string for the model to speak (the never-raise contract every live tool follows). +- **Errors → stderr, data → stdout** (repo invariant; not directly relevant here but preserve it). +- **Help copy is terse, imperative, sentence-case, no trailing period** (Codex-CLI style). Help strings are pinned by syrupy `--help` goldens — regenerate with `--snapshot-update`, never hand-edit `.ambr`. +- **`subprocess` is fenced by ruff `TID251`.** `sandbox.py` needs a deliberate per-file allowlist entry in `pyproject.toml`. Build the child env via `aai_cli/core/env.child_env`. +- **Max file length is 500 lines** (`scripts/max_file_length.py`) — keep `sandbox.py` under it. +- **Gate reality (from memory + `CLAUDE.md`):** `./scripts/check.sh` enforces **100% patch coverage vs `origin/main`** *and* a **diff-scoped mutation gate** — a changed boolean/string/branch survives unless a test asserts the behavioral *difference* between its two values, not merely that the line ran. There is also a **no-new-escape-hatches** gate (no net-new `# type: ignore`/`# noqa`/`pragma: no cover`/`Any`/`cast(`/test skip/xfail/sleep vs merge-base). `# pragma: no mutate` is the sanctioned way to exempt a genuinely unassertable tuning literal (use it for the `ulimit` caps only). +- **Commit hook:** a PreToolUse hook blocks `git commit` unless `./scripts/check.sh` last passed for the current working-tree signature. Use `AAI_ALLOW_COMMIT=1 git commit …` for the per-task WIP commits below, then run the **full** `./scripts/check.sh` once at the end (Task 7) and let that gate the final state. +- **Workspace:** execute on the current `live-tool-call-ux` branch (it already carries this feature's design docs). Commit ONLY this feature's files; never `git add -A`. + +--- + +### Task 1: Denylist constants + Seatbelt profile renderer (`sandbox.py`) + +The security core, part 1. Pure function, no I/O — fully unit-testable. + +**Files:** +- Create: `aai_cli/agent_cascade/sandbox.py` +- Test: `tests/test_agent_cascade_sandbox.py` + +**Interfaces:** +- Produces: + - `HOME_SECRETS: tuple[str, ...]` — credential dirs/files relative to `$HOME` (`.ssh`, `.aws`, `.gnupg`, `.netrc`, `.npmrc`). + - `CWD_READ_DENY: tuple[str, ...]` — project-local secrets denied for reads even though cwd is otherwise readable (`.env`, `.claude`). `.env` also covers `.env.*`. + - `CWD_WRITE_DENY: tuple[str, ...]` — persistence paths denied for writes even inside cwd (`.git/hooks`). + - `SHELL_RC: tuple[str, ...]` — shell rc files denied for writes (matters only when `cwd == $HOME`): `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`. + - `render_seatbelt_profile(cwd: str, tmp: str, home: str, *, home_secrets: Sequence[str] = HOME_SECRETS, cwd_read_deny: Sequence[str] = CWD_READ_DENY, cwd_write_deny: Sequence[str] = CWD_WRITE_DENY, shell_rc: Sequence[str] = SHELL_RC) -> str` — an SBPL profile string. + +- [ ] **Step 1: Write the failing test** + +Add to a new `tests/test_agent_cascade_sandbox.py`: + +```python +from __future__ import annotations + +from aai_cli.agent_cascade import sandbox + + +def test_seatbelt_profile_is_default_allow_reads_deny_default(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + assert "(version 1)" in profile + assert "(deny default)" in profile + assert "(allow process-exec*)" in profile + assert "(allow file-read*)" in profile # default-allow reads + # No network allow anywhere — network stays denied by (deny default). + assert "network" not in profile + + +def test_seatbelt_profile_denies_each_home_secret_for_reads(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + for name in sandbox.HOME_SECRETS: + assert f'(deny file-read* (subpath "/home/u/{name}"))' in profile + + +def test_seatbelt_profile_denies_project_secrets_for_reads(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + # .env (and .env.*) under cwd are read-denied via a regex; .claude/ via subpath. + assert "file-read*" in profile and "/work/proj" in profile + assert any(".env" in line and "deny file-read*" in line for line in profile.splitlines()) + assert '(deny file-read* (subpath "/work/proj/.claude"))' in profile + + +def test_seatbelt_profile_writes_confined_to_cwd_and_tmp(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + assert '(allow file-write* (subpath "/work/proj") (subpath "/tmp"))' in profile + + +def test_seatbelt_profile_denies_persistence_writes_inside_cwd(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + assert '(deny file-write* (subpath "/work/proj/.git/hooks"))' in profile + # Shell rc files denied for writes (covers the cwd == $HOME case). + for name in sandbox.SHELL_RC: + assert f'(deny file-write* (subpath "/home/u/{name}"))' in profile +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `uv run pytest tests/test_agent_cascade_sandbox.py -q` +Expected: FAIL — `ModuleNotFoundError: aai_cli.agent_cascade.sandbox` (or `AttributeError`). + +- [ ] **Step 3: Write the minimal implementation** + +Create `aai_cli/agent_cascade/sandbox.py`: + +```python +"""OS-sandboxed shell execution for ``assembly live --files``. + +deepagents binds a functional ``execute`` tool only when the backend implements +``SandboxBackendProtocol``. :class:`SandboxedShellBackend` does — but its ``execute`` never +runs an unconfined host shell: it wraps the command in an OS sandbox (``sandbox-exec`` SBPL on +macOS, ``bwrap`` on Linux) that confines writes to cwd, denies the network, and read-denies +credential stores. On any other platform (or with the sandbox binary missing) it refuses and +runs nothing — never a fallback to unconfined execution. The policy renderers are pure and the +subprocess/capability boundaries are injected, so the suite asserts *what we would run* with no +real sandbox. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +# Credential dirs/files under $HOME, read-denied precisely on both platforms. +HOME_SECRETS: tuple[str, ...] = (".ssh", ".aws", ".gnupg", ".netrc", ".npmrc") +# Project-local secrets denied for reads even though cwd is otherwise readable. +CWD_READ_DENY: tuple[str, ...] = (".env", ".claude") +# Persistence paths denied for writes even inside cwd. +CWD_WRITE_DENY: tuple[str, ...] = (".git/hooks",) +# Shell rc files denied for writes (only inside the write region when cwd == $HOME). +SHELL_RC: tuple[str, ...] = (".bashrc", ".zshrc", ".profile", ".bash_profile") + + +def render_seatbelt_profile( + cwd: str, + tmp: str, + home: str, + *, + home_secrets: Sequence[str] = HOME_SECRETS, + cwd_read_deny: Sequence[str] = CWD_READ_DENY, + cwd_write_deny: Sequence[str] = CWD_WRITE_DENY, + shell_rc: Sequence[str] = SHELL_RC, +) -> str: + """Render an Apple Seatbelt (SBPL) profile: default-allow reads, deny secrets, writes only + in cwd + tmp, no network. Last-match-wins, so the denies override the broad allows.""" + lines = [ + "(version 1)", + "(deny default)", + "(allow process-exec*)", + "(allow process-fork)", + "(allow file-read*)", + ] + for name in home_secrets: + lines.append(f'(deny file-read* (subpath "{home}/{name}"))') + # .env and .env.* under cwd, denied via regex; .claude/ via subpath. + lines.append(f'(deny file-read* (regex #"^{cwd}/\\.env($|\\.)"))') + for name in cwd_read_deny: + if name == ".env": + continue + lines.append(f'(deny file-read* (subpath "{cwd}/{name}"))') + lines.append(f'(allow file-write* (subpath "{cwd}") (subpath "{tmp}"))') + for name in cwd_write_deny: + lines.append(f'(deny file-write* (subpath "{cwd}/{name}"))') + for name in shell_rc: + lines.append(f'(deny file-write* (subpath "{home}/{name}"))') + return "\n".join(lines) + "\n" +``` + +> Note: `CWD_READ_DENY` carries `.env` (rendered as the regex line) and `.claude` (rendered as a subpath). The test `test_seatbelt_profile_denies_project_secrets_for_reads` pins both; keep them in the constant so the parity test in Task 2 can assert both renderers cover the same set. + +- [ ] **Step 4: Run to verify it passes** + +Run: `uv run pytest tests/test_agent_cascade_sandbox.py -q` +Expected: PASS (all six tests). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/sandbox.py tests/test_agent_cascade_sandbox.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): seatbelt sandbox profile renderer + denylist constants" +``` + +--- + +### Task 2: bwrap argv builder + parity test (`sandbox.py`) + +The security core, part 2 (Linux), plus the parity test that keeps the two platforms in lockstep. + +**Files:** +- Modify: `aai_cli/agent_cascade/sandbox.py` +- Test: `tests/test_agent_cascade_sandbox.py` + +**Interfaces:** +- Produces: `build_bwrap_argv(cwd: str, tmp: str, command: str, home: str, *, home_secrets: Sequence[str] = HOME_SECRETS, cwd_read_deny: Sequence[str] = CWD_READ_DENY, cwd_write_deny: Sequence[str] = CWD_WRITE_DENY) -> list[str]` — the full `bwrap` argv ending in the shell invocation of `command`. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_agent_cascade_sandbox.py`: + +```python +def test_bwrap_argv_confines_to_cwd_with_network_unshared(): + argv = sandbox.build_bwrap_argv("/work/proj", "/tmp", "echo hi", "/home/u") + assert argv[0] == "bwrap" + assert "--unshare-all" in argv # includes network namespace + assert "--die-with-parent" in argv + # Whole FS read-only = default-allow reads. + assert _has_pair(argv, "--ro-bind", "/", "/") + # cwd + tmp are read-write bound; chdir into cwd. + assert _has_pair(argv, "--bind", "/work/proj", "/work/proj") + assert _has_pair(argv, "--bind", "/tmp", "/tmp") + assert _adjacent(argv, "--chdir", "/work/proj") + # The command lands at the tail via a shell. + assert argv[-1] == "echo hi" or "echo hi" in argv[-1] + + +def test_bwrap_argv_masks_home_secrets_and_git_hooks(): + argv = sandbox.build_bwrap_argv("/work/proj", "/tmp", "echo hi", "/home/u") + joined = " ".join(argv) + for name in sandbox.HOME_SECRETS: + assert f"/home/u/{name}" in joined # masked (tmpfs / ro-bind /dev/null) + assert "/work/proj/.git/hooks" in joined # write blocked via ro-bind + + +def _has_pair(argv, flag, a, b): + for i in range(len(argv) - 2): + if argv[i] == flag and argv[i + 1] == a and argv[i + 2] == b: + return True + return False + + +def _adjacent(argv, flag, value): + for i in range(len(argv) - 1): + if argv[i] == flag and argv[i + 1] == value: + return True + return False + + +def test_renderers_cover_the_same_denylists(): + # Parity: both platform renderers must reference every denylist constant, so a path added + # to one platform can't silently be left unprotected on the other. + seatbelt = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + bwrap = " ".join(sandbox.build_bwrap_argv("/work/proj", "/tmp", "x", "/home/u")) + for name in sandbox.HOME_SECRETS: + assert f"/home/u/{name}" in seatbelt + assert f"/home/u/{name}" in bwrap + assert "/work/proj/.git/hooks" in seatbelt + assert "/work/proj/.git/hooks" in bwrap +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `uv run pytest tests/test_agent_cascade_sandbox.py -q` +Expected: FAIL — `AttributeError: module ... has no attribute 'build_bwrap_argv'`. + +- [ ] **Step 3: Write the minimal implementation** + +Append to `aai_cli/agent_cascade/sandbox.py`: + +```python +def build_bwrap_argv( + cwd: str, + tmp: str, + command: str, + home: str, + *, + home_secrets: Sequence[str] = HOME_SECRETS, + cwd_read_deny: Sequence[str] = CWD_READ_DENY, + cwd_write_deny: Sequence[str] = CWD_WRITE_DENY, +) -> list[str]: + """Build a bubblewrap argv: whole FS read-only (default-allow reads), cwd + tmp read-write, + secret stores masked, ``.git/hooks`` read-only, network unshared. Path-based, so in-cwd + secret-file protection is coarser than Seatbelt's globbing (a documented asymmetry); the + directory-level credential stores are masked precisely on both.""" + argv = [ + "bwrap", + "--unshare-all", + "--die-with-parent", + "--ro-bind", + "/", + "/", + "--bind", + cwd, + cwd, + "--bind", + tmp, + tmp, + ] + # Mask credential stores under $HOME (tmpfs hides their contents). + for name in home_secrets: + argv += ["--tmpfs", f"{home}/{name}"] + # Project-local secrets: mask each path (best-effort; coarser than Seatbelt). + for name in cwd_read_deny: + argv += ["--ro-bind", "/dev/null", f"{cwd}/{name}"] + # Block writes to persistence paths inside cwd by re-binding them read-only. + for name in cwd_write_deny: + argv += ["--ro-bind", f"{cwd}/{name}", f"{cwd}/{name}"] + argv += ["--chdir", cwd, "/bin/sh", "-c", command] + return argv +``` + +> If a `--ro-bind /dev/null ` for a non-existent project secret makes `bwrap` error at launch, that surfaces as a `Runner` failure → apology string (Task 4), never a crash. The coarser-protection asymmetry is acknowledged in the spec. + +- [ ] **Step 4: Run to verify it passes** + +Run: `uv run pytest tests/test_agent_cascade_sandbox.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/sandbox.py tests/test_agent_cascade_sandbox.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): bwrap argv builder + renderer parity test" +``` + +--- + +### Task 3: Capability detection + default Runner (`sandbox.py`) + +The platform probe (`seatbelt | bwrap | none`) and the `subprocess` boundary, both injectable. + +**Files:** +- Modify: `aai_cli/agent_cascade/sandbox.py` (+ `pyproject.toml` for the `TID251` allowlist) +- Test: `tests/test_agent_cascade_sandbox.py` + +**Interfaces:** +- Produces: + - `Capability = Literal["seatbelt", "bwrap", "none"]` + - `detect_capability(*, system: Callable[[], str] = platform.system, which: Callable[[str], str | None] = shutil.which) -> Capability` + - `class CompletedProcessLike(Protocol)` with `output: str` and `returncode: int | None` + - `Runner = Callable[[list[str], str, int], CompletedProcessLike]` + - `default_runner(argv: list[str], cwd: str, timeout: int) -> CompletedProcessLike` — wraps `subprocess.run` (combined stdout+stderr, `cwd`, `timeout`, env via `child_env`), returning partial output + a sentinel `returncode` on timeout instead of raising. + - `DEFAULT_TIMEOUT_SECONDS: int`, `MAX_TIMEOUT_SECONDS: int`, `CPU_LIMIT_SECONDS: int`, `ADDRESS_LIMIT_KB: int`. + +- [ ] **Step 1: Add the `TID251` allowlist entry to `pyproject.toml`** + +In `[tool.ruff.lint.per-file-ignores]` (next to the existing `procs.py`/`coding_agent.py` entries) add: + +```toml +# Sandbox shell-out: launches the OS sandbox binary (sandbox-exec / bwrap) with controlled +# argv; the whole module exists to confine that one subprocess call. +"aai_cli/agent_cascade/sandbox.py" = ["TID251"] +``` + +- [ ] **Step 2: Write the failing test** + +Append to `tests/test_agent_cascade_sandbox.py`: + +```python +def test_detect_capability_seatbelt_on_macos_with_binary(): + cap = sandbox.detect_capability(system=lambda: "Darwin", which=lambda _n: "/usr/bin/sandbox-exec") + assert cap == "seatbelt" + + +def test_detect_capability_bwrap_on_linux_with_binary(): + cap = sandbox.detect_capability(system=lambda: "Linux", which=lambda _n: "/usr/bin/bwrap") + assert cap == "bwrap" + + +def test_detect_capability_none_when_binary_missing(): + cap = sandbox.detect_capability(system=lambda: "Darwin", which=lambda _n: None) + assert cap == "none" + + +def test_detect_capability_none_on_unsupported_platform(): + cap = sandbox.detect_capability(system=lambda: "Windows", which=lambda _n: "anything") + assert cap == "none" +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `uv run pytest tests/test_agent_cascade_sandbox.py -q -k capability` +Expected: FAIL. + +- [ ] **Step 4: Write the minimal implementation** + +Append imports at the top of `sandbox.py` (keep `from __future__ import annotations` first): + +```python +import platform +import shutil +import subprocess +from collections.abc import Callable, Sequence +from typing import Literal, Protocol + +from aai_cli.core.env import child_env +``` + +(Merge the `collections.abc` import with the existing `Sequence` one.) Then append: + +```python +Capability = Literal["seatbelt", "bwrap", "none"] + +DEFAULT_TIMEOUT_SECONDS = 120 # pragma: no mutate +MAX_TIMEOUT_SECONDS = 600 # pragma: no mutate +CPU_LIMIT_SECONDS = 60 # pragma: no mutate +ADDRESS_LIMIT_KB = 4_000_000 # pragma: no mutate +_TIMEOUT_EXIT = 124 # conventional timeout exit code + + +def detect_capability( + *, + system: Callable[[], str] = platform.system, + which: Callable[[str], str | None] = shutil.which, +) -> Capability: + """Resolve the sandbox mechanism for this host: ``seatbelt`` (macOS + ``sandbox-exec``), + ``bwrap`` (Linux + ``bwrap``), else ``none`` — the refuse-don't-fall-back signal.""" + name = system() + if name == "Darwin" and which("sandbox-exec"): + return "seatbelt" + if name == "Linux" and which("bwrap"): + return "bwrap" + return "none" + + +class CompletedProcessLike(Protocol): + """The slice of a finished process the backend reads: combined output + exit code.""" + + output: str + returncode: int | None + + +class _Result: + """Concrete :class:`CompletedProcessLike` the default runner returns.""" + + def __init__(self, output: str, returncode: int | None) -> None: + self.output = output + self.returncode = returncode + + +Runner = Callable[[list[str], str, int], CompletedProcessLike] + + +def default_runner(argv: list[str], cwd: str, timeout: int) -> CompletedProcessLike: + """Run ``argv`` with combined output, in ``cwd``, time-bounded, with a minimal child env. + + A timeout returns the partial output + a sentinel exit code (information, not a crash); a + launch failure is left to raise so the caller turns it into an apology string.""" + try: + proc = subprocess.run( # noqa: S603 — argv is the controlled sandbox invocation + argv, + cwd=cwd, + timeout=timeout, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=child_env(), + check=False, + ) + except subprocess.TimeoutExpired as exc: + out = exc.output or "" + text = out.decode() if isinstance(out, bytes) else out + return _Result(text + f"\n[timed out after {timeout}s]", _TIMEOUT_EXIT) + return _Result(proc.stdout or "", proc.returncode) +``` + +> The `S603` inline `# noqa` is pre-existing project policy (the repo ignores `S603/S607` project-wide for controlled shell-outs, per `CLAUDE.md`). If `ruff` reports it as unused because the rule is already globally ignored, drop the `# noqa` — do not add a net-new escape hatch (the no-escape-hatches gate counts these). Verify with `uv run ruff check aai_cli/agent_cascade/sandbox.py`. + +- [ ] **Step 5: Run to verify it passes** + +Run: `uv run pytest tests/test_agent_cascade_sandbox.py -q` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/agent_cascade/sandbox.py tests/test_agent_cascade_sandbox.py pyproject.toml +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): sandbox capability probe + default subprocess runner" +``` + +--- + +### Task 4: `SandboxedShellBackend.execute()` (`sandbox.py`) + +Wire the renderers + capability + runner into the backend override. This is where the never-call-`super().execute()` invariant lives. + +**Files:** +- Modify: `aai_cli/agent_cascade/sandbox.py` +- Test: `tests/test_agent_cascade_sandbox.py` + +**Interfaces:** +- Consumes: `render_seatbelt_profile`, `build_bwrap_argv`, `detect_capability`, `default_runner`, `Runner`, `Capability`, the timeout/limit constants (all from Task 1–3); `ExecuteResponse` from `deepagents.backends.protocol`; `LocalShellBackend` from `deepagents.backends.local_shell`. +- Produces: `class SandboxedShellBackend(LocalShellBackend)` with `__init__(self, *, root_dir: str, virtual_mode: bool = True, runner: Runner | None = None, capability: Capability | None = None, tmp: str | None = None, home: str | None = None)` and `execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse`. +- The refusal string constant `NO_SANDBOX_MESSAGE = "I can't run code on this system."` and `LAUNCH_FAILURE_MESSAGE = "I couldn't start a sandbox to run that."` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_agent_cascade_sandbox.py`: + +```python +from deepagents.backends.protocol import ExecuteResponse + + +def _backend(tmp_path, cap, runner): + return sandbox.SandboxedShellBackend( + root_dir=str(tmp_path), + capability=cap, + runner=runner, + tmp="/tmp", + home="/home/u", + ) + + +def test_execute_seatbelt_wraps_command_in_sandbox_exec(tmp_path): + calls = [] + + def runner(argv, cwd, timeout): + calls.append((argv, cwd, timeout)) + return sandbox._Result("done", 0) + + backend = _backend(tmp_path, "seatbelt", runner) + resp = backend.execute("pytest -q", timeout=30) + + argv, cwd, timeout = calls[0] + assert argv[0] == "sandbox-exec" and argv[1] == "-p" + assert "(deny default)" in argv[2] # the rendered profile + assert "pytest -q" in argv[-1] # command at the tail (ulimit-wrapped) + assert cwd == str(tmp_path.resolve()) + assert timeout == 30 + assert isinstance(resp, ExecuteResponse) + assert resp.output == "done" and resp.exit_code == 0 + + +def test_execute_bwrap_uses_bwrap_argv(tmp_path): + seen = {} + + def runner(argv, cwd, timeout): + seen["argv"] = argv + return sandbox._Result("ok", 0) + + _backend(tmp_path, "bwrap", runner).execute("ls") + assert seen["argv"][0] == "bwrap" + + +def test_execute_capability_none_refuses_and_never_runs(tmp_path): + # Record-and-assert-not-called (no `# pragma: no cover` — that's a gated escape hatch). + calls = [] + + def runner(argv, cwd, timeout): + calls.append(argv) + return sandbox._Result("", 0) + + resp = _backend(tmp_path, "none", runner).execute("rm -rf /") + assert resp.output == sandbox.NO_SANDBOX_MESSAGE + assert resp.exit_code is None + assert calls == [] # the killer assertion: refusal must run nothing + + +def test_execute_never_calls_super_execute(tmp_path, monkeypatch): + # The unconfined host shell must never run, even on the happy path. A one-line lambda + # records the call so there's no never-executed function body to leave uncovered. + from deepagents.backends.local_shell import LocalShellBackend + + super_calls = [] + monkeypatch.setattr( + LocalShellBackend, + "execute", + lambda self, command, *, timeout=None: super_calls.append(command), + ) + backend = _backend(tmp_path, "seatbelt", lambda a, c, t: sandbox._Result("x", 0)) + assert backend.execute("echo hi").output == "x" + assert super_calls == [] # host shell never invoked + + +def test_execute_runner_failure_returns_apology(tmp_path): + def runner(argv, cwd, timeout): + raise OSError("sandbox-exec missing") + + resp = _backend(tmp_path, "seatbelt", runner).execute("echo hi") + assert resp.output == sandbox.LAUNCH_FAILURE_MESSAGE + assert resp.exit_code is None + + +def test_execute_nonzero_exit_passes_output_and_code_through(tmp_path): + runner = lambda a, c, t: sandbox._Result("boom\n", 1) + resp = _backend(tmp_path, "seatbelt", runner).execute("false") + assert resp.output == "boom\n" and resp.exit_code == 1 + + +def test_execute_clamps_timeout_to_max(tmp_path): + seen = {} + + def runner(argv, cwd, timeout): + seen["timeout"] = timeout + return sandbox._Result("", 0) + + _backend(tmp_path, "seatbelt", runner).execute("x", timeout=10_000) + assert seen["timeout"] == sandbox.MAX_TIMEOUT_SECONDS + + +def test_execute_defaults_timeout_when_unset(tmp_path): + seen = {} + _backend(tmp_path, "seatbelt", lambda a, c, t: (seen.update(t=t) or sandbox._Result("", 0))).execute("x") + assert seen["t"] == sandbox.DEFAULT_TIMEOUT_SECONDS +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `uv run pytest tests/test_agent_cascade_sandbox.py -q -k execute` +Expected: FAIL — `SandboxedShellBackend` undefined. + +- [ ] **Step 3: Write the minimal implementation** + +Append to `aai_cli/agent_cascade/sandbox.py`: + +```python +from deepagents.backends.local_shell import LocalShellBackend +from deepagents.backends.protocol import ExecuteResponse + +NO_SANDBOX_MESSAGE = "I can't run code on this system." +LAUNCH_FAILURE_MESSAGE = "I couldn't start a sandbox to run that." + + +def _ulimit_wrap(command: str) -> str: + """Cap CPU + address space so a runaway can't peg the box inside the timeout.""" + return f"ulimit -t {CPU_LIMIT_SECONDS}; ulimit -v {ADDRESS_LIMIT_KB}; {command}" # pragma: no mutate + + +class SandboxedShellBackend(LocalShellBackend): + """A ``LocalShellBackend`` whose ``execute`` runs through an OS sandbox, never the host shell. + + Inherits the cwd-rooted file operations (``read_file``/``write_file``/``edit_file``/``ls``/ + ``glob``/``grep``) unchanged; implementing ``SandboxBackendProtocol`` (via the base) is what + makes deepagents auto-add the ``execute`` tool. The override confines every run to cwd, denies + the network, and refuses outright when no sandbox is available.""" + + def __init__( + self, + *, + root_dir: str, + virtual_mode: bool = True, + runner: Runner | None = None, + capability: Capability | None = None, + tmp: str | None = None, + home: str | None = None, + ) -> None: + super().__init__(root_dir=root_dir, virtual_mode=virtual_mode) + self._runner: Runner = runner or default_runner + self._capability: Capability = capability if capability is not None else detect_capability() + import os + import tempfile + + self._tmp = tmp if tmp is not None else tempfile.gettempdir() + self._home = home if home is not None else os.path.expanduser("~") + + def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: + """Run ``command`` confined to cwd via the OS sandbox; refuse when none is available.""" + if self._capability == "none": + return ExecuteResponse(output=NO_SANDBOX_MESSAGE, exit_code=None) + cwd = str(self.cwd) + wrapped = _ulimit_wrap(command) + if self._capability == "seatbelt": + profile = render_seatbelt_profile(cwd, self._tmp, self._home) + argv = ["sandbox-exec", "-p", profile, "/bin/sh", "-c", wrapped] + else: + argv = build_bwrap_argv(cwd, self._tmp, wrapped, self._home) + bounded = min(timeout or DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS) + try: + result = self._runner(argv, cwd, bounded) + except Exception: # noqa: BLE001 — any launch failure becomes a speakable apology + return ExecuteResponse(output=LAUNCH_FAILURE_MESSAGE, exit_code=None) + return ExecuteResponse(output=result.output, exit_code=result.returncode) +``` + +> Move the `import os` / `import tempfile` to module top (the post-edit ruff hook will not, since they're used immediately; cleaner to hoist them). The `# noqa: BLE001` is a net-new escape hatch — prefer catching `(OSError, ValueError, subprocess.SubprocessError)` instead of bare `Exception` so no `noqa` is needed and the no-escape-hatches gate stays green. Adjust the `test_execute_runner_failure_returns_apology` runner to raise `OSError` (already does). + +- [ ] **Step 4: Run to verify it passes** + +Run: `uv run pytest tests/test_agent_cascade_sandbox.py -q` +Expected: PASS (all tests). + +- [ ] **Step 5: Sanity-check file length + lint** + +Run: `uv run python scripts/max_file_length.py && uv run ruff check aai_cli/agent_cascade/sandbox.py` +Expected: no output / clean. If `sandbox.py` is near 500 lines, it isn't — it should be ~220. + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/agent_cascade/sandbox.py tests/test_agent_cascade_sandbox.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): SandboxedShellBackend.execute confines to cwd or refuses" +``` + +--- + +### Task 5: Wire the backend, the gate, and memory into `brain.py` + +Swap the backend, add `execute` to the approval set, turn on `MemoryMiddleware`, add the tool label and capability phrasing, and fix the stale "inert" comments. + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` (`_build_fs_backend` ~199–208; `_WRITE_TOOLS` ~196; `_graph_kwargs` ~211–228; `_TOOL_LABELS` ~52–60; the `execute … inert` comment ~193–195) +- Modify: `aai_cli/agent_cascade/prompt.py` (capability phrasing ~27, ~52–71) +- Test: `tests/test_agent_cascade_brain.py`, `tests/test_agent_cascade_prompt.py` + +**Interfaces:** +- Consumes: `sandbox.SandboxedShellBackend` (Task 4). +- Produces: `_build_fs_backend()` now returns a `SandboxedShellBackend`; `_WRITE_TOOLS == ("write_file", "edit_file", "execute")`; `_graph_kwargs(config)` (when `config.files`) additionally carries `memory=["./.deepagents/AGENTS.md"]`; `_TOOL_LABELS["execute"] == "Running code"`; the system prompt advertises code execution when `execute` is bound. + +- [ ] **Step 1: Write the failing tests (brain)** + +In `tests/test_agent_cascade_brain.py`, update the existing `test_graph_kwargs_*` test and add new ones. The existing assertion `kwargs["interrupt_on"] == {"write_file": True, "edit_file": True}` MUST change to include `execute` (this is the mutation-killing edit on the `_WRITE_TOOLS` line): + +```python +def test_graph_kwargs_gates_writes_and_execute_and_sets_memory(monkeypatch, tmp_path): + from aai_cli.agent_cascade import sandbox + + monkeypatch.chdir(tmp_path) + kwargs = brain._graph_kwargs(CascadeConfig(files=True)) + + backend = kwargs["backend"] + assert isinstance(backend, sandbox.SandboxedShellBackend) + assert Path(backend.cwd) == tmp_path.resolve() + assert backend.virtual_mode is True + # execute now joins the write gate. + assert kwargs["interrupt_on"] == {"write_file": True, "edit_file": True, "execute": True} + assert kwargs["checkpointer"] is not None + # Durable per-project memory is turned on. + assert kwargs["memory"] == ["./.deepagents/AGENTS.md"] + + +def test_graph_kwargs_empty_when_files_off(): + assert brain._graph_kwargs(CascadeConfig(files=False)) == {} + + +def test_sandboxed_backend_implements_sandbox_protocol(monkeypatch, tmp_path): + from deepagents.backends.protocol import SandboxBackendProtocol + + monkeypatch.chdir(tmp_path) + backend = brain._build_fs_backend() + assert isinstance(backend, SandboxBackendProtocol) + + +def test_tool_label_execute_is_running_code(): + assert brain._tool_label("execute") == "Running code" +``` + +Also add a gated-decline test mirroring the existing write-decline coverage (find the test that drives `_stream_gated`/`_decide` with a rejecting approver and assert an `execute` action declines to `_DECLINED`). If the existing files-test (`tests/test_agent_cascade_files.py`) parametrizes the gated tool name, add `"execute"` to that parametrization; otherwise add: + +```python +def test_declined_execute_yields_declined_message(): + action = {"name": "execute", "args": {"command": "rm -rf build"}} + assert brain._decide(action, lambda name, args: False) == { + "type": "reject", + "message": brain._DECLINED, + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -q -k "graph_kwargs or sandbox or tool_label or declined_execute"` +Expected: FAIL. + +- [ ] **Step 3: Implement the brain edits** + +In `brain.py`: + +Replace the `_WRITE_TOOLS` block and its comment (lines ~193–196): + +```python +# The mutating tools gated behind human approval when --files is on (reads — incl. grep — stay +# ungated). execute joins the gate because the backend is now sandbox-capable: it runs real +# commands in cwd, OS-confined, but every run is still approved. +_WRITE_TOOLS = ("write_file", "edit_file", "execute") +``` + +Replace `_build_fs_backend` (lines ~199–208): + +```python +def _build_fs_backend() -> object: + """A sandbox-capable deepagents backend rooted at the launch directory. + + ``virtual_mode=True`` maps the model's ``/``-rooted paths under cwd and blocks traversal + escapes (same containment as before for file ops). Being a ``SandboxBackendProtocol`` backend + is what makes deepagents bind a *functional* ``execute`` — and :class:`SandboxedShellBackend` + runs it OS-sandboxed in cwd (no network, no escape) rather than on the host shell.""" + from aai_cli.agent_cascade.sandbox import SandboxedShellBackend + + return SandboxedShellBackend(root_dir=str(Path.cwd()), virtual_mode=True) +``` + +In `_graph_kwargs` (lines ~211–228) add the `memory` key to the returned dict: + +```python + return { + "backend": backend_factory(), + "interrupt_on": dict.fromkeys(_WRITE_TOOLS, True), + "checkpointer": InMemorySaver(), + "memory": ["./.deepagents/AGENTS.md"], + } +``` + +In `_TOOL_LABELS` (lines ~52–60) add the execute label (keep the dict's existing entries): + +```python + "execute": "Running code", +``` + +- [ ] **Step 4: Implement the prompt edit** + +In `prompt.py`, the file capability currently reads (line ~27): + +```python +_FILE_CAPABILITY = "read, write, and search files in your working directory" +``` + +When `--files` is on, `execute` is bound, so the agent can run code. Update the phrasing so it advertises execution. Change `_FILE_CAPABILITY` to: + +```python +_FILE_CAPABILITY = ( + "read, write, and search files in your working directory, and run code to solve problems " + "and operate on this project" +) +``` + +(Single capability phrase; no new branch needed since `--files` is exactly when both the file tools and `execute` are bound. This keeps the change minimal and matches the spec's "advertises *run code…* when `execute` is bound.") + +- [ ] **Step 5: Write/adjust the prompt test** + +In `tests/test_agent_cascade_prompt.py`, find the test asserting the file-capability phrase appears when `files=True` and tighten it to assert the run-code phrasing (kills the mutation on the changed string — help/docstrings are snapshot-pinned, but `_FILE_CAPABILITY` is asserted directly here): + +```python +def test_system_prompt_advertises_code_execution_under_files(): + prompt = build_system_prompt("persona", tools=[], files=True) + assert "run code to solve problems" in prompt + + +def test_system_prompt_omits_code_execution_without_files(): + prompt = build_system_prompt("persona", tools=[], files=False) + assert "run code" not in prompt +``` + +- [ ] **Step 6: Run to verify all pass** + +Run: `uv run pytest tests/test_agent_cascade_brain.py tests/test_agent_cascade_prompt.py tests/test_agent_cascade_files.py -q` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add aai_cli/agent_cascade/brain.py aai_cli/agent_cascade/prompt.py tests/test_agent_cascade_brain.py tests/test_agent_cascade_prompt.py tests/test_agent_cascade_files.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): sandbox-capable backend, gated execute, durable memory" +``` + +--- + +### Task 6: Make `risk.py`'s `execute` branch live + assert it + +`risk.py`'s `execute` shell-risk warning was dormant (the live agent never bound a functional `execute`). It now surfaces on the approval prompt. The branch already exists; this task pins it with assertions so the now-live behavior can't regress (and the mutation gate on any touched line is satisfied). + +**Files:** +- Test: `tests/test_agent_cascade_risk.py` (create if absent; otherwise extend the existing risk test file — search `tests/` for `risk_warning`) +- Modify (comment only, if present): `aai_cli/agent_cascade/risk.py` docstring noting the branch is live. + +**Interfaces:** +- Consumes: `risk.risk_warning(name, args)` (existing). + +- [ ] **Step 1: Write the tests** + +```python +from __future__ import annotations + +from aai_cli.agent_cascade import risk + + +def test_execute_warns_on_destructive_command(): + assert risk.risk_warning("execute", {"command": "rm -rf build"}) is not None + assert risk.risk_warning("execute", {"command": "sudo make install"}) is not None + + +def test_execute_no_warning_on_benign_command(): + assert risk.risk_warning("execute", {"command": "pytest -q"}) is None + + +def test_execute_no_warning_when_command_missing_or_nonstring(): + assert risk.risk_warning("execute", {}) is None + assert risk.risk_warning("execute", {"command": 123}) is None +``` + +- [ ] **Step 2: Run to verify they pass (branch already exists)** + +Run: `uv run pytest tests/test_agent_cascade_risk.py -q` +Expected: PASS (the logic exists; these assertions make it *gate-enforced*). + +> If the file already exists with these exact assertions, skip — `risk.py` was always tested; only confirm coverage. If `risk.py` needs no code change, there's nothing for the mutation gate to scope here. + +- [ ] **Step 3: Commit (only if files changed)** + +```bash +git add tests/test_agent_cascade_risk.py aai_cli/agent_cascade/risk.py +AAI_ALLOW_COMMIT=1 git commit -m "test(live): pin risk.py execute branch now that execute is gated" +``` + +--- + +### Task 7: Docs, help string + snapshot, and the full gate + +Update the stale prose, the `--files` help string (regenerating its golden), and run the authoritative gate end-to-end. + +**Files:** +- Modify: `aai_cli/AGENTS.md` (the `--files` paragraph, ~line 154) +- Modify: `aai_cli/commands/agent_cascade/__init__.py` (`--files` help string, ~174–179) +- Modify: `REFERENCE.md` (the `--files` description, ~163–167); `README.md` only if its `--files` blurb needs it +- Regenerate: `tests/__snapshots__/test_snapshots_help_run.ambr` + +- [ ] **Step 1: Update the `--files` help string** + +In `aai_cli/commands/agent_cascade/__init__.py`, change the `help=` to reflect code execution + memory (terse, no trailing period): + +```python + help="Let the agent read, write, and run code in the current directory, sandboxed (writes and runs need confirmation)", +``` + +- [ ] **Step 2: Regenerate the affected help snapshot** + +Run: `uv run pytest tests/test_snapshots_help_run.py --snapshot-update -q` +Then eyeball the diff: `git diff tests/__snapshots__/test_snapshots_help_run.ambr` — only the `--files` line should change. + +- [ ] **Step 3: Update `aai_cli/AGENTS.md`** + +Replace the `--files` paragraph so it no longer says `execute` is inert. New text (keep it one paragraph, factual): + +``` +**`--files`** (off by default) swaps the brain's in-memory backend for a real-cwd, sandbox-capable +`SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before +(traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents +binds a *functional* `execute` that runs commands OS-sandboxed in cwd — `sandbox-exec` (SBPL) on +macOS, `bwrap` on Linux, refused on any other platform/missing binary, never an unconfined +fallback (no network, writes confined to cwd, credential stores read-denied). `write_file`/ +`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`; `brain._stream_gated` +detects the post-stream interrupt, asks an injected `Approver`, and resumes with `Command(resume=…)`, +bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply +deadline. The voice TUI supplies the approver via `modals.ApprovalScreen` (`y`/`a`/`n`); headless +runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via +`MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`). Reads (incl. `grep`) stay ungated. +``` + +- [ ] **Step 4: Update `REFERENCE.md`** + +Update the `--files` description (~163–167) to mention sandboxed code execution + per-project memory, matching the new help string. Keep the existing tone. Ensure the docs-consistency gate stays green (no new env var/command is introduced, so the gate only checks the `--files` command reference still resolves). + +- [ ] **Step 5: Run the full authoritative gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` Address anything it flags — likely candidates: +- patch-coverage < 100% → add the missing assertion for the uncovered changed line. +- a surviving mutant → strengthen the test so it *fails* when that line breaks. +- docstring-coverage → every public function in `sandbox.py` already has a docstring; add any missing. +- file-length → `sandbox.py` must be < 500 lines. +- docs-consistency → `REFERENCE.md`/`README.md` `--files` refs in sync. + +- [ ] **Step 6: Final commit (gated)** + +Once `check.sh` prints `All checks passed.`: + +```bash +git add aai_cli/AGENTS.md aai_cli/commands/agent_cascade/__init__.py REFERENCE.md README.md tests/__snapshots__/test_snapshots_help_run.ambr +git commit -m "feat(live): document sandboxed execute + memory; refresh --files help" +``` + +(No `AAI_ALLOW_COMMIT=1` — the gate just passed, so the commit hook is satisfied.) + +--- + +## Self-Review + +**Spec coverage (M1 only):** +- Sandboxed gated `execute` in real cwd → Tasks 1–5. ✅ +- OS sandbox, refuse-don't-fall-back → Task 3 (capability) + Task 4 (`none` branch, never-`super` test). ✅ +- cwd-scoped reads default-allow + secrets read-denylist → Tasks 1–2 (renderers) + parity test. ✅ +- writes confined to cwd + persistence write-denylist → Tasks 1–2. ✅ +- no network → asserted in both renderer tests. ✅ +- `execute` joins `interrupt_on`, flows through existing approver, `risk.py` warning live → Task 5 + Task 6. ✅ +- durable memory via `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`) → Task 5. ✅ +- `_TOOL_LABELS["execute"]`, capability phrasing, stale-comment fixes, help/docs → Tasks 5 + 7. ✅ +- no new dependency, live-only, never-raise contract → Global Constraints + Task 4 error handling. ✅ +- **Deferred to later PRs (correctly out of M1):** subagents/`task` tool + HITL spike (M2); spoken approval + engine STT race + destructive-tier keyboard fallback (M3). The `_TOOL_LABELS["task"]` and the `task` capability phrase land in M2. + +**Placeholder scan:** No TBD/"handle edge cases"/"similar to" — every code step shows the code. The two implementation notes (the `# noqa` removal in Tasks 3/4) are explicit instructions, not placeholders. + +**Type consistency:** `Runner`, `Capability`, `CompletedProcessLike`, `_Result`, `render_seatbelt_profile`, `build_bwrap_argv`, `detect_capability`, `default_runner`, `SandboxedShellBackend`, `ExecuteResponse` are used with identical names/signatures across Tasks 1–5. `ExecuteResponse(output=…, exit_code=…)` matches deepagents' dataclass (`output: str`, `exit_code: int | None = None`). + +## Execution Handoff + +Open question for the implementer to confirm during Task 4: the exact `LocalShellBackend.__init__` keyword set (the explore pass found `FilesystemBackend.__init__(root_dir, virtual_mode, max_file_size_mb)` and `LocalShellBackend(FilesystemBackend, SandboxBackendProtocol)`) — if `LocalShellBackend.__init__` adds required kwargs, forward them. The injected-`runner` tests don't exercise the real binary, so CI (which has neither sandbox) stays green. diff --git a/docs/superpowers/plans/2026-06-22-live-spoken-approval.md b/docs/superpowers/plans/2026-06-22-live-spoken-approval.md new file mode 100644 index 00000000..317a6b53 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-spoken-approval.md @@ -0,0 +1,35 @@ +# Spoken approval for `assembly live --files` (M3) Implementation Plan + +> **For agentic workers:** superpowers:subagent-driven-development / :executing-plans. + +**Goal:** Let the `--files` approval gate be answered by an unambiguous **spoken** yes/no (not only a keypress), so the safety gate doesn't contradict the hands-free premise — with a keyboard fallback for the highest-risk (destructive) commands. + +**Spec:** `docs/superpowers/specs/2026-06-22-live-sandboxed-execute-design.md` (Milestone **M3** — "the largest lift, touching engine/modals"). Builds on M1 (gated execute) + M2 (subagents), committed. + +## Status +- **DONE — token grammar (the safety core):** `aai_cli/agent_cascade/spoken_approval.py` + tests (commit `c68703c`). `interpret_spoken_approval(transcript) -> bool` is **fail-safe to reject**: only an unambiguous action-bearing affirmative ("approve" / "yes, run it" / "go ahead and run it") returns True; a bare "yes", any negation, unrelated/empty speech all return False. +- **REMAINING — the engine STT-vs-keypress race + destructive-tier keyboard fallback** (below). Touches `engine.py` / `tui.py` / `_exec.py`, which a concurrent session is actively rewriting — land once that settles to avoid building on a moving base. + +## Architecture of the remaining work + +Today the `Approver` (`brain.Approver = Callable[[str, dict], bool]`) is invoked **synchronously on the cascade worker thread** inside `brain._stream_gated`, bracketed by `ApprovalPause(active=True/False)` (so `engine._consume` suspends the reply deadline). The TUI supplies it via `app.approve_write(name, args)` (blocks on `modals.ApprovalScreen`'s keypress); headless uses `_exec._deny_writes`. Spoken approval makes the *answer source* multimodal without changing the gate's shape. + +### Task A — a voice-aware approver the engine supplies (`engine.py` + an injected token source) +- The engine owns the STT leg (`run_stt(on_turn)`); during an `ApprovalPause(active=True)` it must capture the **next final transcript** and offer it to the approval decision, racing a keypress. +- Add an injectable **spoken-token source** seam: `Callable[[float], str | None]` — "wait up to `timeout` s for the next final transcript, or None". The production impl reads from the live STT leg (a queue the `on_turn` final-transcript path feeds during a pause); tests inject a fake that returns a scripted phrase or None — **no mic, no sockets** (mirrors the existing `CascadeDeps` fakes). +- The voice-aware approver: when invoked, it (1) consults `risk.risk_warning(name, args)` — if it fires (destructive tier), **ignore voice, require the keyboard** (delegate to the existing keypress approver); else (2) races the spoken-token source against the keypress, resolving with whichever lands first: a spoken token → `interpret_spoken_approval(token)`; a keypress → its decision; timeout/None/ambiguous → reject (the existing `_DECLINED` path). +- Keep `modals.ApprovalScreen` (keypress) as the fallback and the floor for the destructive tier; `_deny_writes` (headless) unchanged. + +### Task B — wire it through `_exec.py` / `tui.py` +- The TUI run currently passes `approver=approve_write` (keypress). Wrap it in the voice-aware approver, handing it the spoken-token source (from the engine's STT leg) and the keypress approver as the fallback. The destructive-tier branch routes to `approve_write` (keyboard) directly. +- Surface the spoken-vs-keyboard affordance on `ApprovalScreen` copy ("say 'approve' or press y") — regenerate the TUI snapshot if the modal chrome changes. + +### Task C — tests (hermetic, via the injected seams) +- An explicit affirmative phrase from the fake token source approves; a bare "yes", a negative, an unrecognized utterance, and a timeout each reject; a keypress still approves; and a `risk.py`-flagged destructive command **ignores** the spoken affirmative and requires the keypress. Assert the *resolved decision*, not mere execution (kills the mutation-gate mutants on the race/risk branches). +- `risk.py`'s destructive branch is exercised by the destructive-tier test (it's already covered by `tests/test_live_risk.py`). + +## Constraints (carry from M1/M2) +- Hermetic: inject the spoken-token source + keypress; no mic/sockets. `from __future__ import annotations`; no new dependency. +- Fail-safe to reject is the invariant: every non-clear-affirmative path → reject. +- 100% patch coverage + mutation gate; no new escape hatches; tests-pyright via `-p pyrightconfig.tests.json` (add the file to its ignore list if it builds real deepagents graphs). +- Concurrent session churns `engine.py`/`tui.py`: commit only M3 files; `AAI_ALLOW_COMMIT=1` per task; final `./scripts/check.sh` (sandbox-disabled). diff --git a/docs/superpowers/plans/2026-06-22-live-streaming-reply-pipeline.md b/docs/superpowers/plans/2026-06-22-live-streaming-reply-pipeline.md new file mode 100644 index 00000000..e66ba54b --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-streaming-reply-pipeline.md @@ -0,0 +1,1130 @@ +# `assembly live` Streaming Reply Pipeline — 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:** Overlap LLM token generation, TTS synthesis, and playback in a live cascade turn so audio starts on the first clause instead of after the whole reply is generated. + +**Architecture:** The reply leg becomes a stream. `brain.build_streamer` drives the deepagents graph with `stream_mode="messages"` and yields `SpeechDelta`/`ToolNotice` events. The engine consumes them on the reply-worker thread (fed by a throwaway daemon producer thread + a `queue.Queue` that preserves today's wall-clock timeout), buffers token deltas, flushes complete clauses via a new `text.pop_clauses`, and synthesizes each clause with **streaming TTS** (`tts_session.synthesize(..., on_audio=...)`) so playback begins on its first audio frame. + +**Tech Stack:** Python 3.12–3.13, deepagents/langgraph (`graph.stream`), `langchain_core` message chunks, the streaming-TTS WebSocket in `aai_cli/tts/session.py`, `threading`/`queue`, pytest + syrupy. + +## Global Constraints + +Copied verbatim from the repo invariants (every task's requirements include these): + +- `from __future__ import annotations` at the top of every module; modern typing (`X | None`). +- Errors → stderr, data → stdout. Help/option copy is terse, imperative, sentence-case, **no trailing period** (not relevant here — no new flags — but keep docstrings periodful). +- **The gate is the source of truth.** `./scripts/check.sh` must print `All checks passed.` Notable diff-scoped gates: **100% patch coverage** vs `origin/main`, a **diff-scoped mutation gate** (a changed line needs a test that *fails* if the line breaks — not just coverage), **vulture** (no dead code — flags both unused new functions *and* code that becomes unused), **xenon** (function complexity ≤ B), the **500-line max file length**, and a **"no new escape hatches"** count gate (`# pragma: no mutate` / `# noqa` / `pragma: no cover` / `cast(` / `Any` counted against merge-base — a *net-new* one fails). The **Textual-module ≥90% coverage floor** also applies, but this change does not touch `tui.py`. +- **Commit discipline:** iterate with fast targeted `uv run pytest …`, then gate once at the end. Use `AAI_ALLOW_COMMIT=1 git commit …` for intermediate per-task WIP commits; the **final** commit must follow a full green `./scripts/check.sh` (the PreToolUse hook enforces this). End every commit message with `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Run every tool through `uv run`. +- **Escape-hatch budget is net-neutral by design:** Task 3 removes `_complete_within`'s `daemon=True # pragma: no mutate`; the new producer thread adds exactly one back. `_MIN_CLAUSE_CHARS` is pinned by a test (Task 1/3), so it needs **no** pragma. Do not introduce other new pragmas. +- **Do not touch `uv.lock`** — no dependency changes in this work. + +--- + +## File Structure + +- `aai_cli/agent_cascade/text.py` — add `pop_clauses` (pure incremental clause splitter). Keep `split_sentences`/`trim_history`. +- `aai_cli/agent_cascade/brain.py` — add `SpeechDelta`/`ToolNotice` + `build_streamer` + `_stream_graph` (messages-mode iteration with verbose logging). Task 4 removes the now-dead `build_completer`/`_run_graph`/`_drive_graph`/`_log_flow`/`_surface_event`/`_reply_text`. Keep `_clip`/`_tool_label`/`_content_text`/`build_graph`/`build_system_prompt`/`build_live_tools`. +- `aai_cli/agent_cascade/engine.py` — change `CascadeDeps` seam (`complete_reply: str` → `stream_reply: Iterable[event]`; `synthesize: (str)->bytes` → `synthesize: (str, sink)->None`), rewrite `greet`/`_generate_reply`, add producer/queue/clause helpers, remove `_complete_within`. +- `aai_cli/AGENTS.md` — update the `agent_cascade/` bullet describing the `-v` behavior and "per-sentence TTS". +- `tests/_cascade_fakes.py` — `make_session` seam: `stream_reply` + streaming `synthesize`. +- `tests/test_agent_cascade_engine.py` — rewrite reply-generation/timeout tests for the streaming seam. +- `tests/test_agent_cascade_brain.py` — add `build_streamer` tests (Task 2); remove `build_completer`/`_run_graph` tests (Task 4). +- `tests/test_agent_cascade_command.py` — update the two `CascadeDeps.real` leg tests + the `fake_real` constructions. + +--- + +## Task 1: `pop_clauses` incremental clause splitter + +**Files:** +- Modify: `aai_cli/agent_cascade/text.py` +- Test: `tests/test_agent_cascade_text.py` (new file) + +**Interfaces:** +- Produces: `pop_clauses(buffer: str, *, min_chars: int) -> tuple[list[str], str]` — returns complete speakable clauses pulled off the front of `buffer` plus the unflushed remainder. Hard terminators `.!?` (followed by whitespace) always end a clause; soft separators `,;:` (followed by whitespace) end one only when the pending clause is at least `min_chars` long. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_agent_cascade_text.py`: + +```python +"""Tests for the cascade's pure text helpers (sentence/clause splitting).""" + +from __future__ import annotations + +import pytest + +from aai_cli.agent_cascade.text import pop_clauses + + +def test_pop_clauses_flushes_hard_terminators_and_keeps_tail(): + chunks, remainder = pop_clauses("One. Two! Three", min_chars=1) + assert chunks == ["One.", "Two!"] + assert remainder == " Three" # no terminator yet -> stays buffered + + +def test_pop_clauses_flushes_soft_separator_only_past_min_chars(): + # The clause before the comma is long enough, so the comma ends a clause. + chunks, remainder = pop_clauses("the weather today is, in fact ", min_chars=10) + assert chunks == ["the weather today is,"] + assert remainder == " in fact " + + +def test_pop_clauses_holds_short_soft_clause_to_avoid_choppy_tts(): + # "Yes," is shorter than min_chars, so it is NOT flushed on the comma. + chunks, remainder = pop_clauses("Yes, it is sunny", min_chars=10) + assert chunks == [] + assert remainder == "Yes, it is sunny" + + +def test_pop_clauses_does_not_fragment_a_decimal_or_stacked_terminators(): + # A '.' inside $3.50 (no following space) and stacked '...'/'?!' are not boundaries. + chunks, remainder = pop_clauses("It costs $3.50 total... ", min_chars=1) + assert chunks == ["It costs $3.50 total..."] + assert remainder == " " + + +def test_pop_clauses_returns_nothing_for_an_unterminated_buffer(): + chunks, remainder = pop_clauses("still going", min_chars=1) + assert chunks == [] + assert remainder == "still going" + + +def test_pop_clauses_strips_whitespace_from_each_flushed_clause(): + chunks, _remainder = pop_clauses(" Hi there. Next.", min_chars=1) + assert chunks == ["Hi there.", "Next."] + + +@pytest.mark.parametrize("min_chars", [1, 25]) +def test_pop_clauses_flushes_hard_terminator_regardless_of_min_chars(min_chars): + # min_chars only gates SOFT separators; a sentence terminator always flushes. + chunks, remainder = pop_clauses("Hi. ", min_chars=min_chars) + assert chunks == ["Hi."] + assert remainder == " " +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_text.py -q` +Expected: FAIL — `ImportError: cannot import name 'pop_clauses'`. + +- [ ] **Step 3: Implement `pop_clauses`** + +Add to `aai_cli/agent_cascade/text.py` (below `_TERMINATORS`): + +```python +# Soft clause separators: a comma/semicolon/colon ends a *speakable* chunk too, but only +# once the pending clause is long enough (see pop_clauses) — flushing "Yes," on its own +# makes choppy TTS. Hard terminators (_TERMINATORS) always end a clause. +_SOFT_SEPARATORS = ",;:" + + +def _is_boundary(text: str, index: int) -> bool: + """True when the char at ``index`` ends a clause: a terminator/separator that is the + last char or is followed by whitespace (so a '.' inside "$3.50" never splits).""" + return index + 1 == len(text) or text[index + 1].isspace() + + +def pop_clauses(buffer: str, *, min_chars: int) -> tuple[list[str], str]: + """Pull complete speakable clauses off the front of ``buffer`` for incremental TTS. + + A hard terminator (``.``/``!``/``?``) followed by whitespace (or end-of-buffer) always + ends a clause; a soft separator (``,``/``;``/``:``) ends one only when the clause built + since the last boundary is at least ``min_chars`` long, so a tiny fragment ("Yes,") + isn't synthesized on its own. Returns the flushed clauses (each stripped, never blank) + and the still-incomplete remainder to keep buffering. The caller flushes the final tail + at end-of-stream. + """ + clauses: list[str] = [] + start = 0 + for index, char in enumerate(buffer): + is_hard = char in _TERMINATORS + is_soft = char in _SOFT_SEPARATORS + if not (is_hard or is_soft) or not _is_boundary(buffer, index): + continue + clause = buffer[start : index + 1].strip() + if is_soft and len(clause) < min_chars: + continue # too short to speak on its own — keep accumulating + if clause: + clauses.append(clause) + start = index + 1 + return clauses, buffer[start:] +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_text.py -q` +Expected: PASS (all 8 cases). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/text.py tests/test_agent_cascade_text.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add pop_clauses incremental clause splitter + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: brain — `build_streamer` reply event stream (added alongside `build_completer`) + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` +- Test: `tests/test_agent_cascade_brain.py` + +**Interfaces:** +- Produces: + - `class SpeechDelta` — frozen dataclass, field `text: str` (a top-level assistant-text token delta). + - `class ToolNotice` — frozen dataclass, field `label: str` (the speakable tool affordance label). + - `build_streamer(api_key: str, config: CascadeConfig, *, graph: CompiledAgent | None = None) -> Callable[[list[ChatCompletionMessageParam]], Iterator[SpeechDelta | ToolNotice]]`. The returned `stream_reply(messages)` drops the prepended `system` message, then iterates `graph.stream({"messages": conversation}, None, stream_mode="messages")` yielding `SpeechDelta`/`ToolNotice`. Graph exceptions wrap into `CLIError` (`agent_brain_error`); a `CLIError` passes through unchanged. Under `-v` it logs accumulated assistant text, tool calls, and tool results to `_FLOW_LOG`. +- Consumes: existing `_tool_label`, `_clip`, `_content_text`, `build_graph`, `debuglog`, `CLIError`. + +> NOTE: `build_completer` and its helpers stay untouched in this task (still used by `engine`); Task 4 removes them once the engine has switched. The new `build_streamer` is referenced by the tests below, so vulture sees it as used. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_agent_cascade_brain.py` (new imports at top: `from langchain_core.messages import AIMessageChunk`; reuse existing `AIMessage`/`ToolMessage`/`logging`/`pytest`): + +```python +# --- build_streamer (token streaming -> SpeechDelta / ToolNotice) ------------ + + +class _MessageStreamGraph: + """A graph whose .stream yields (message_chunk, metadata) pairs — the shape + langgraph emits under stream_mode='messages'. Records the stream_mode it saw.""" + + def __init__(self, items): + self._items = items + self.stream_mode = None + + def stream(self, graph_input, config, *, stream_mode): + del graph_input, config + self.stream_mode = stream_mode + yield from self._items + + +def _collect(graph, messages, **kwargs): + streamer = brain.build_streamer("k", CascadeConfig(), graph=graph) + return list(streamer(messages, **kwargs)) if kwargs else list(streamer(messages)) + + +def test_streamer_yields_speech_deltas_for_assistant_tokens(): + graph = _MessageStreamGraph( + [ + (AIMessageChunk(content="Hello "), {}), + (AIMessageChunk(content="there."), {}), + ] + ) + events = _collect(graph, [{"role": "user", "content": "hi"}]) + assert [e.text for e in events if isinstance(e, brain.SpeechDelta)] == ["Hello ", "there."] + assert graph.stream_mode == "messages" + + +def test_streamer_strips_system_message_before_streaming(): + captured = {} + + class _Capture(_MessageStreamGraph): + def stream(self, graph_input, config, *, stream_mode): + captured["roles"] = [m["role"] for m in graph_input["messages"]] + return super().stream(graph_input, config, stream_mode=stream_mode) + + graph = _Capture([(AIMessageChunk(content="ok"), {})]) + _collect(graph, [{"role": "system", "content": "p"}, {"role": "user", "content": "hi"}]) + assert captured["roles"] == ["user"] + + +def test_streamer_emits_a_tool_notice_when_a_tool_call_starts(): + call_chunk = AIMessageChunk( + content="", + tool_call_chunks=[ + {"name": brain.WEB_SEARCH_TOOL_NAME, "args": "", "id": "c1", "index": 0} + ], + ) + graph = _MessageStreamGraph( + [(call_chunk, {}), (AIMessageChunk(content="Here it is."), {})] + ) + events = _collect(graph, [{"role": "user", "content": "news?"}]) + notices = [e.label for e in events if isinstance(e, brain.ToolNotice)] + deltas = [e.text for e in events if isinstance(e, brain.SpeechDelta)] + assert notices == ["Searching the web"] + assert deltas == ["Here it is."] + + +def test_streamer_emits_one_notice_per_call_ignoring_arg_only_chunks(): + # The first tool-call chunk carries the name; later arg-only chunks (name=None) must NOT + # re-fire the affordance. + first = AIMessageChunk( + content="", tool_call_chunks=[{"name": "get_time", "args": "", "id": "c1", "index": 0}] + ) + rest = AIMessageChunk( + content="", tool_call_chunks=[{"name": None, "args": '{"tz":1}', "id": "c1", "index": 0}] + ) + graph = _MessageStreamGraph([(first, {}), (rest, {})]) + events = _collect(graph, [{"role": "user", "content": "time?"}]) + assert [e.label for e in events if isinstance(e, brain.ToolNotice)] == ["Using get_time"] + + +def test_streamer_wraps_graph_errors_in_cli_error(): + class _Boom: + def stream(self, graph_input, config, *, stream_mode): + del graph_input, config, stream_mode + raise ValueError("gateway said no") + yield # pragma: no cover (make it a generator) + + streamer = brain.build_streamer("k", CascadeConfig(), graph=_Boom()) + with pytest.raises(CLIError) as excinfo: + list(streamer([{"role": "user", "content": "hi"}])) + assert "couldn't complete the turn" in excinfo.value.message + assert "gateway said no" in excinfo.value.message + + +def test_streamer_passes_cli_error_through(): + class _CliBoom: + def stream(self, graph_input, config, *, stream_mode): + del graph_input, config, stream_mode + raise CLIError("already clean", error_type="x") + yield # pragma: no cover + + streamer = brain.build_streamer("k", CascadeConfig(), graph=_CliBoom()) + with pytest.raises(CLIError, match="already clean"): + list(streamer([{"role": "user", "content": "hi"}])) + + +def test_streamer_logs_flow_when_verbose(monkeypatch, caplog, preserve_logging_state): + monkeypatch.setattr(brain.debuglog, "active", lambda: True) + call_chunk = AIMessageChunk( + content="", tool_call_chunks=[{"name": "tavily_search", "args": "", "id": "c1", "index": 0}] + ) + items = [ + (AIMessageChunk(content="Let me "), {}), + (AIMessageChunk(content="search."), {}), + (call_chunk, {}), + (ToolMessage(content="rainy, 52F", name="tavily_search", tool_call_id="c1"), {}), + (AIMessageChunk(content="It's rainy."), {}), + ] + graph = _MessageStreamGraph(items) + with caplog.at_level(logging.INFO, logger="aai_cli.agent_cascade.brain"): + _collect(graph, [{"role": "user", "content": "weather?"}]) + messages = [r.getMessage() for r in caplog.records] + # Accumulated assistant text is logged as one line per assistant turn, around the + # tool call and its result. + assert messages == [ + "llm: Let me search.", + "tool call tavily_search", + "tool result tavily_search -> rainy, 52F", + "llm: It's rainy.", + ] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -k streamer -q` +Expected: FAIL — `AttributeError: module 'aai_cli.agent_cascade.brain' has no attribute 'build_streamer'`. + +- [ ] **Step 3: Add the event dataclasses** + +In `aai_cli/agent_cascade/brain.py`, add `from dataclasses import dataclass` to the imports, and define (just below `_TOOL_LABELS`/`_tool_label`): + +```python +@dataclass(frozen=True) +class SpeechDelta: + """A top-level assistant-text token delta to be spoken (one piece of the reply).""" + + text: str + + +@dataclass(frozen=True) +class ToolNotice: + """A speakable affordance label emitted when the agent starts a tool call mid-turn.""" + + label: str +``` + +- [ ] **Step 4: Add `build_streamer` and `_stream_graph`** + +Add `Iterator` to the `collections.abc` import (`from collections.abc import Callable, Iterator, Sequence`). Add these functions to `brain.py` (place near `build_completer`): + +```python +def build_streamer( + api_key: str, config: CascadeConfig, *, graph: CompiledAgent | None = None +) -> Callable[..., Iterator[SpeechDelta | ToolNotice]]: + """A streaming reply leg for the cascade engine, backed by the deepagents graph. + + The cascade prepends its own ``system`` message each turn; the graph owns the system + prompt, so it is dropped before streaming. The graph is driven with + ``stream_mode="messages"`` and each top-level assistant token delta is yielded as a + :class:`SpeechDelta`, each started tool call as a :class:`ToolNotice` (the live UI's + affordance). Under ``-v`` the flow is logged. ``graph`` is injected in tests so the + per-turn wiring runs against a fake with no network. + """ + resolved = build_graph(api_key, config) if graph is None else graph + + def stream_reply( + messages: list[ChatCompletionMessageParam], + ) -> Iterator[SpeechDelta | ToolNotice]: + conversation = [message for message in messages if message.get("role") != "system"] + return _stream_graph(resolved, conversation) + + return stream_reply + + +def _stream_graph( + graph: CompiledAgent, conversation: list[ChatCompletionMessageParam] +) -> Iterator[SpeechDelta | ToolNotice]: + """Stream one turn through the graph token-by-token, yielding speech/tool events. + + Wraps any graph failure as a CLIError (a clean ``CLIError`` passes through) so the + cascade surfaces it instead of the reply worker dying silently — the same contract the + old ``_run_graph`` had. Under ``-v`` the accumulated assistant text, each tool call, + and each tool result are logged to ``_FLOW_LOG``. + """ + verbose = debuglog.active() + pending: list[str] = [] # assistant deltas accumulated for one verbose "llm:" line + + def flush_log() -> None: + if verbose and pending: + _FLOW_LOG.info("llm: %s", "".join(pending)) + pending.clear() + + try: + for chunk, _meta in graph.stream({"messages": conversation}, None, stream_mode="messages"): + yield from _events_from_chunk(chunk, verbose, pending, flush_log) + flush_log() + except CLIError: + raise + except Exception as exc: + raise CLIError( + f"the agent couldn't complete the turn: {exc}", error_type="agent_brain_error" + ) from exc + + +def _events_from_chunk( + chunk: object, verbose: bool, pending: list[str], flush_log: Callable[[], None] +) -> Iterator[SpeechDelta | ToolNotice]: + """Translate one streamed message chunk into speech/tool events (and verbose logs).""" + if type(chunk).__name__ == "ToolMessage": + flush_log() + if verbose: + content = _content_text(getattr(chunk, "content", "")) + _FLOW_LOG.info("tool result %s -> %s", getattr(chunk, "name", ""), _clip(content)) + return + for call in getattr(chunk, "tool_call_chunks", None) or []: + name = call.get("name") + if name: + flush_log() + if verbose: + _FLOW_LOG.info("tool call %s", name) + yield ToolNotice(_tool_label(name)) + text = _content_text(getattr(chunk, "content", "")) + if text: + pending.append(text) + yield SpeechDelta(text) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -k streamer -q` +Expected: PASS (7 cases). + +- [ ] **Step 6: Run the whole brain suite (build_completer still present and green)** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -q` +Expected: PASS (old `build_completer`/`_run_graph` tests untouched). + +- [ ] **Step 7: Commit** + +```bash +git add aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): add build_streamer token-streaming reply leg + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: engine — streaming seam + `_generate_reply` rewrite + +**Files:** +- Modify: `aai_cli/agent_cascade/engine.py` +- Modify: `tests/_cascade_fakes.py` +- Modify: `tests/test_agent_cascade_engine.py` +- Modify: `tests/test_agent_cascade_command.py` + +**Interfaces:** +- Consumes: `brain.build_streamer`, `brain.SpeechDelta`, `brain.ToolNotice` (Task 2); `text.pop_clauses` (Task 1). +- Produces (new `CascadeDeps` shape): + - `stream_reply: Callable[..., Iterable[SpeechDelta | ToolNotice]]` — `(messages) -> iterable of reply events`. + - `synthesize: Callable[[str, Callable[[bytes], None]], None]` — `(text, sink)`; calls `sink(pcm)` per audio frame. + - `run_stt`, `spawn` unchanged. +- `CascadeDeps.real(api_key, config, *, audio, stt_params)` signature is **unchanged** (so `commands/agent_cascade/_exec.py` needs no edit) — only the legs it builds change. + +- [ ] **Step 1: Update the shared fakes** + +In `tests/_cascade_fakes.py`, replace `make_session` (and only it) so the seam matches. New `make_session`: + +```python +def make_session( + *, + stream_reply=None, + synthesize=lambda text, sink: sink(b"pcm:" + text.encode()), + spawn=sync_spawn, + run_stt=lambda on_turn: None, + config=None, +): + from aai_cli.agent_cascade.brain import SpeechDelta + + if stream_reply is None: + stream_reply = lambda messages: [SpeechDelta("Hello there.")] + deps = CascadeDeps( + run_stt=run_stt, stream_reply=stream_reply, synthesize=synthesize, spawn=spawn + ) + renderer = FakeRenderer() + player = FakePlayer() + session = CascadeSession( + deps=deps, renderer=renderer, player=player, config=config or CascadeConfig() + ) + return session, renderer, player +``` + +Add a small helper just below it so tests can script a reply as a list of deltas/notices: + +```python +def deltas(*texts): + """A stream_reply that yields the given strings as SpeechDelta events.""" + from aai_cli.agent_cascade.brain import SpeechDelta + + return lambda messages: [SpeechDelta(t) for t in texts] +``` + +- [ ] **Step 2: Rewrite the engine's `CascadeDeps`, `greet`, and reply path tests** + +Replace the reply-generation/timeout/greeting tests in `tests/test_agent_cascade_engine.py`. Delete `test_complete_within_*` (3 tests) and rewrite the reply tests against the streaming seam. New/changed tests (keep the barge-in/shutdown/`_is_final_turn`/`run_cascade` tests but update their `CascadeDeps(...)` constructions — see Step 7): + +```python +from tests._cascade_fakes import deltas as _deltas +from aai_cli.agent_cascade.brain import SpeechDelta, ToolNotice + + +def test_greet_speaks_and_seeds_history(): + session, renderer, player = make_session() + session.greet() + assert session.history == [{"role": "assistant", "content": session.config.greeting}] + assert ("agent_transcript", session.config.greeting, False) in renderer.calls + assert player.enqueued == [b"pcm:" + session.config.greeting.encode()] + + +def test_greet_records_tts_failure(): + def boom(text, sink): + raise APIError("tts down") + + session, _renderer, player = make_session(synthesize=boom) + session.greet() + assert isinstance(session.error, APIError) + assert session.error.message == "tts down" + assert player.enqueued == [] + + +def test_generate_reply_speaks_each_clause_as_it_streams(): + spoken = [] + session, renderer, player = make_session( + stream_reply=_deltas("One. ", "Two! ", "Three?"), + synthesize=lambda text, sink: spoken.append(text) or sink(text.encode()), + ) + session._generate_reply() + assert spoken == ["One.", "Two!", "Three?"] + assert player.enqueued == [b"One.", b"Two!", b"Three?"] + assert ("reply_started",) in renderer.calls + assert ("agent_transcript", "One.", False) in renderer.calls + assert session.history[-1] == {"role": "assistant", "content": "One. Two! Three?"} + assert ("reply_done", False) in renderer.calls + + +def test_generate_reply_forwards_tool_notice_and_drops_unspoken_preamble(): + # A ToolNotice surfaces the affordance AND clears any buffered-but-unspoken text, so a + # half-streamed preamble before a tool call is never spoken. + spoken = [] + + def stream(messages): + yield SpeechDelta("Let me check") # incomplete clause, not yet flushed + yield ToolNotice("Searching the web") + yield SpeechDelta("It is sunny today.") + + session, renderer, _player = make_session( + stream_reply=stream, + synthesize=lambda text, sink: spoken.append(text) or sink(b""), + ) + session._generate_reply() + assert ("tool_call", "Searching the web") in renderer.calls + assert spoken == ["It is sunny today."] # the preamble was dropped, never synthesized + assert session.history[-1] == {"role": "assistant", "content": "It is sunny today."} + + +def test_generate_reply_marks_speaking_on_first_delta_then_clears(): + observed = [] + session, _renderer, _player = make_session(stream_reply=_deltas("Hi. ", "Yes.")) + session.deps.synthesize = lambda text, sink: observed.append(session._speaking.is_set()) + session._generate_reply() + assert observed == [True, True] + assert not session._speaking.is_set() + + +def test_generate_reply_threads_system_prompt_and_history(): + captured = {} + + def capture(messages): + captured["messages"] = messages + return [SpeechDelta("Ok.")] + + session, _renderer, _player = make_session( + stream_reply=capture, config=CascadeConfig(system_prompt="be terse") + ) + session.history.append({"role": "user", "content": "prior"}) + session._generate_reply() + assert captured["messages"][0] == {"role": "system", "content": "be terse"} + assert {"role": "user", "content": "prior"} in captured["messages"] + + +def test_generate_reply_trims_history_window(): + session, _renderer, _player = make_session( + stream_reply=_deltas("a. b."), config=CascadeConfig(max_history=1) + ) + session.history.append({"role": "user", "content": "hi"}) + session._generate_reply() + assert session.history == [{"role": "assistant", "content": "a. b."}] + + +def test_generate_reply_stop_after_first_clause_records_partial(): + def synth(text, sink): + if text == "Two.": + session._stop.set() + sink(text.encode()) + + session, renderer, player = make_session(stream_reply=_deltas("One. Two. Three.")) + session.deps.synthesize = synth + session._generate_reply() + assert player.enqueued == [b"One."] + assert session.history[-1] == {"role": "assistant", "content": "One."} + assert ("reply_done", True) in renderer.calls + + +def test_generate_reply_stop_before_first_clause_speaks_nothing(): + session, renderer, player = make_session(stream_reply=_deltas("One. Two.")) + session._stop.set() + session._generate_reply() + assert player.enqueued == [] + assert all(item.get("role") != "assistant" for item in session.history) + assert ("reply_done", True) in renderer.calls + + +def test_generate_reply_times_out_via_the_backstop(monkeypatch): + release = threading.Event() + + def hang(messages): + release.wait(timeout=2.0) # self-releases so no mutated deadline can wedge the suite + yield SpeechDelta("late") + + monkeypatch.setattr(engine, "_REPLY_TIMEOUT_SECONDS", 0.05) + session, renderer, player = make_session(stream_reply=hang) + try: + session._generate_reply() + assert isinstance(session.error, CLIError) + assert session.error.error_type == "agent_timeout" + assert any(c[0] == "agent_transcript" and "longer than" in c[1] for c in renderer.calls) + assert ("reply_done", False) in renderer.calls + assert player.enqueued == [] + finally: + release.set() + + +def test_generate_reply_llm_failure_is_recorded_and_surfaced(): + def boom(messages): + raise APIError("gateway down") + yield # pragma: no cover + + session, renderer, player = make_session(stream_reply=boom) + session._generate_reply() + assert isinstance(session.error, APIError) + assert ("agent_transcript", "(error: gateway down)", False) in renderer.calls + assert ("reply_done", False) in renderer.calls + assert player.enqueued == [] + + +def test_generate_reply_tts_failure_midway_is_recorded(): + def boom(text, sink): + raise APIError("tts down") + + session, renderer, player = make_session(stream_reply=_deltas("Hi."), synthesize=boom) + session._generate_reply() + assert isinstance(session.error, APIError) + assert player.enqueued == [] + assert ("reply_started",) in renderer.calls + assert ("reply_done", False) in renderer.calls +``` + +Also update `test_on_turn_final_renders_and_replies`, `test_reply_forwards_tool_calls_to_the_renderer`, `test_on_turn_interim_shows_partial_and_does_not_reply`, and `test_on_turn_trims_history_window` to the new seam: + +```python +def test_on_turn_final_renders_and_replies(): + session, renderer, player = make_session(stream_reply=_deltas("Sure thing.")) + session.on_turn(_turn("what time is it")) + assert ("user_final", "what time is it") in renderer.calls + assert {"role": "user", "content": "what time is it"} in session.history + assert {"role": "assistant", "content": "Sure thing."} in session.history + assert player.enqueued == [b"pcm:Sure thing."] + assert ("reply_done", False) in renderer.calls + + +def test_reply_forwards_tool_calls_to_the_renderer(): + def stream(messages): + yield ToolNotice("Searching the web") + yield SpeechDelta("Found it.") + + session, renderer, _player = make_session(stream_reply=stream) + session.on_turn(_turn("what's the news")) + assert ("tool_call", "Searching the web") in renderer.calls + + +def test_on_turn_interim_shows_partial_and_does_not_reply(): + streamed = [] + session, renderer, _player = make_session( + stream_reply=lambda m: streamed.append(m) or [SpeechDelta("x")] + ) + session.on_turn(_turn("partial words", end_of_turn=False)) + assert ("user_partial", "partial words") in renderer.calls + assert streamed == [] + assert session.history == [] + + +def test_on_turn_trims_history_window(): + session, _renderer, _player = make_session( + stream_reply=_deltas(""), config=CascadeConfig(max_history=1) + ) + session.history.append({"role": "assistant", "content": "old"}) + session.on_turn(_turn("newest")) + assert session.history == [{"role": "user", "content": "newest"}] +``` + +- [ ] **Step 3: Run the engine tests to verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_engine.py -q` +Expected: FAIL — the engine still has the old `complete_reply`/`synthesize` seam (and `_complete_within`), so the new tests error on the changed `CascadeDeps` fields / removed method. + +- [ ] **Step 4: Rewrite the engine's seam, imports, and constants** + +In `aai_cli/agent_cascade/engine.py`: + +Add imports near the top (after `import threading`): + +```python +import queue +import time +``` + +Update the `text` import to include the splitter: + +```python +from aai_cli.agent_cascade.text import pop_clauses, trim_history +``` + +Replace the `_REPLY_TIMEOUT_SECONDS` comment/const block with the streaming rationale and add the clause threshold: + +```python +# Wall-clock backstop for one reply turn. The reply is streamed on a throwaway producer +# thread feeding a queue; a stalled gateway can block inside a token read the worker can't +# observe, so the consumer's queue.get is bounded by a monotonic deadline. After this long +# we stop waiting and surface a timeout so the session stays usable. Generous on purpose. +_REPLY_TIMEOUT_SECONDS = 60.0 # pragma: no mutate + +# A clause is flushed to TTS on a soft separator (comma/semicolon/colon) only once it is at +# least this long, so we don't synthesize a choppy two-word fragment. Pinned by a text test. +_MIN_CLAUSE_CHARS = 25 +``` + +Replace the `CascadeDeps` docstring comment + the two changed fields: + +```python + run_stt: Callable[[Callable[[object], None]], None] + # stream_reply(messages) -> iterable of SpeechDelta/ToolNotice events. The reply is + # streamed token-by-token so the engine can speak each clause as it lands; a ToolNotice + # surfaces the "Searching the web…" affordance (brain.build_streamer). + stream_reply: Callable[..., Iterable[object]] + # synthesize(text, sink): streaming TTS — sink is called with each PCM frame as it + # arrives so playback starts on the first frame instead of after the whole clause. + synthesize: Callable[[str, Callable[[bytes], None]], None] + spawn: Callable[[Callable[[], None]], _Worker] = _spawn_thread +``` + +Replace `CascadeDeps.real`'s body legs: + +```python + def run_stt(on_turn: Callable[[object], None]) -> None: + client.stream_audio(api_key, audio, params=stt_params, on_turn=on_turn) + + # The LLM leg is a deepagents graph (web search / MCP tools), streamed token-by-token + # so a spoken turn can transparently use tools and start speaking sooner. + stream_reply = brain.build_streamer(api_key, config) + + def synthesize(text: str, sink: Callable[[bytes], None]) -> None: + spec = SpeakConfig( + text=text, + voice=config.voice, + language=config.language, + sample_rate=TTS_SAMPLE_RATE, + extra=config.tts_extra, + ) + tts_session.synthesize(api_key, spec, on_audio=lambda chunk, _rate: sink(chunk)) + + return cls(run_stt=run_stt, stream_reply=stream_reply, synthesize=synthesize) +``` + +Add `Iterable` to the typing import line: `from collections.abc import Callable, Iterable`. + +- [ ] **Step 5: Rewrite `greet` and the reply path; remove `_complete_within`** + +In `greet`, change the synth call: + +```python + try: + self.deps.synthesize(greeting, self.player.enqueue) + except CLIError as exc: + self._record_error(exc) +``` + +Delete `_complete_within` entirely. Replace `_generate_reply` with the streaming consumer plus its helpers: + +```python + def _generate_reply(self) -> None: + """Stream the LLM reply, speak each clause as it lands, and record what was spoken + (so a barge-in still leaves the history alternating).""" + messages: list[ChatCompletionMessageParam] = [ + {"role": "system", "content": self.config.system_prompt}, + *self.history, + ] + events: queue.Queue[object] = queue.Queue() + producer = threading.Thread( # pragma: no mutate + target=lambda: self._pump(messages, events), daemon=True + ) + producer.start() + deadline = time.monotonic() + _REPLY_TIMEOUT_SECONDS + buffer = "" + spoken: list[str] = [] + started = False + aborted = False + while True: + try: + item = events.get(timeout=max(0.0, deadline - time.monotonic())) + except queue.Empty: + self._fail_leg(_timeout_error(), started) + return + if isinstance(item, _Failure): + self._fail_leg(item.error, started) + return + if isinstance(item, _Done): + break + if isinstance(item, brain.ToolNotice): + self.renderer.tool_call(item.label) + buffer = "" # drop any unspoken preamble — the answer comes after the tool + continue + if self._stop.is_set(): + aborted = True + break + if not started: + self._speaking.set() + self.renderer.reply_started() + started = True + buffer += item.text + chunks, buffer = pop_clauses(buffer, min_chars=_MIN_CLAUSE_CHARS) + if not self._speak(chunks, spoken): + aborted = True + break + if not aborted: + tail = buffer.strip() + if tail: + self._speak([tail], spoken) + self._record_spoken(spoken) + self._speaking.clear() + self.renderer.reply_done(interrupted=self._stop.is_set()) + + def _pump(self, messages: list[ChatCompletionMessageParam], events: queue.Queue[object]) -> None: + """Drive the streaming reply leg on a throwaway thread, forwarding events to the + queue and ending with a _Done (or _Failure on a clean leg error).""" + try: + for event in self.deps.stream_reply(messages): + events.put(event) + events.put(_Done()) + except CLIError as exc: + events.put(_Failure(exc)) + + def _speak(self, chunks: list[str], spoken: list[str]) -> bool: + """Render and synthesize each clause, feeding frames to the player. Returns False if + the turn was cut (barge-in stop or a TTS failure), True if every clause was spoken.""" + for chunk in chunks: + if self._stop.is_set(): + return False + self.renderer.agent_transcript(chunk, interrupted=False) + try: + self.deps.synthesize(chunk, self._feed) + except CLIError as exc: + self._record_error(exc) + return False + if self._stop.is_set(): + return False + spoken.append(chunk) + return True + + def _feed(self, pcm: bytes) -> None: + """Enqueue one synthesized PCM frame, unless a barge-in has already landed (then the + remaining frames of the in-flight clause are dropped).""" + if not self._stop.is_set(): + self.player.enqueue(pcm) + + def _record_spoken(self, spoken: list[str]) -> None: + """Append what was actually spoken to the history (kept alternating after a barge-in).""" + spoken_text = " ".join(spoken).strip() + if spoken_text: + self.history.append({"role": "assistant", "content": spoken_text}) + trim_history(self.history, self.config.max_history) + + def _fail_leg(self, exc: CLIError, started: bool) -> None: + """Surface a reply-leg failure (LLM/timeout) and close the turn. Before any audio, + the error is shown inline in the transcript so the turn doesn't vanish; mid-speech it + is only recorded (the spoken text already explains the turn).""" + self._record_error(exc) + if not started: + self.renderer.reply_started() + self.renderer.agent_transcript(f"(error: {exc.message})", interrupted=False) + self._speaking.clear() + self.renderer.reply_done(interrupted=self._stop.is_set()) +``` + +Add the producer item types and the timeout factory near the top of the module (after `_REPLY_TIMEOUT_SECONDS`/`_MIN_CLAUSE_CHARS`), and a `dataclass` import is already present: + +```python +@dataclass(frozen=True) +class _Done: + """Producer sentinel: the reply stream finished normally.""" + + +@dataclass(frozen=True) +class _Failure: + """Producer sentinel: the reply leg raised a (clean) CLIError.""" + + error: CLIError + + +def _timeout_error() -> CLIError: + """The backstop error raised when a reply overruns the wall-clock deadline.""" + return CLIError( + f"the agent took longer than {_REPLY_TIMEOUT_SECONDS:.0f}s to respond and was cut off", + error_type="agent_timeout", + ) +``` + +- [ ] **Step 6: Run the engine tests to verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_engine.py -q` +Expected: PASS. If `test_generate_reply_times_out_via_the_backstop` is flaky, confirm `events.get` uses the `max(0.0, deadline - now)` deadline (not a fixed timeout). + +- [ ] **Step 7: Fix the `run_cascade` and `command` constructions** + +In `tests/test_agent_cascade_engine.py`, every `CascadeDeps(...)` literal in the `run_cascade` tests uses the old field names — update each: `complete_reply=...` → `stream_reply=...` (a function returning a list of `SpeechDelta`), and `synthesize=lambda text: ...` → `synthesize=lambda text, sink: sink(...)`. Example for `test_run_cascade_greets_then_pumps_turns`: + +```python + def stream_reply(messages): + session_box["messages"] = messages + return [SpeechDelta("Hi back.")] + + ... + deps = CascadeDeps( + run_stt=run_stt, + stream_reply=stream_reply, + synthesize=lambda text, sink: sink(text.encode()), + spawn=_sync_spawn, + ) +``` + +Apply the same shape to `test_run_cascade_hands_the_session_to_on_session_before_greeting`, `test_run_cascade_shuts_down_inflight_worker`, `test_run_cascade_reraises_recorded_leg_error` (the `boom` becomes a generator that `raise`s an `APIError` then has an unreachable `yield`), and `test_run_cascade_closes_player_when_stt_raises`. + +In `tests/test_agent_cascade_command.py`: +- Update the `fake_real` near line 402 to `stream_reply=lambda _m: [], synthesize=lambda _t, _sink: None`. +- Replace `test_deps_real_complete_reply_is_built_by_the_deepagents_brain` with a streamer version: + +```python +def test_deps_real_stream_reply_is_built_by_the_deepagents_brain(monkeypatch): + from aai_cli.agent_cascade.brain import SpeechDelta + + def fake_build_streamer(api_key, config): + del api_key, config + return lambda messages: [SpeechDelta("reply to " + messages[-1]["content"])] + + monkeypatch.setattr(engine.brain, "build_streamer", fake_build_streamer) + cfg = CascadeConfig() + deps = CascadeDeps.real("k", cfg, audio=[], stt_params=_stt_params()) + events = list(deps.stream_reply([{"role": "user", "content": "hi"}])) + assert [e.text for e in events] == ["reply to hi"] +``` + +- Replace `test_deps_real_synthesize_threads_voice_language_and_extra` to drive the streaming `on_audio`: + +```python +def test_deps_real_synthesize_streams_frames_and_threads_voice(monkeypatch): + captured = {} + + def fake_synth(api_key, spec, *, on_audio): + captured["voice"] = spec.voice + captured["sample_rate"] = spec.sample_rate + on_audio(b"AUDIO", spec.sample_rate or 0) + return engine.tts_session.SpeakResult(b"AUDIO", spec.sample_rate or 0, 0.0) + + monkeypatch.setattr(engine.tts_session, "synthesize", fake_synth) + cfg = CascadeConfig(voice="luna") + deps = CascadeDeps.real("k", cfg, audio=[], stt_params=_stt_params()) + frames = [] + deps.synthesize("say this", frames.append) + assert frames == [b"AUDIO"] + assert captured["voice"] == "luna" + assert captured["sample_rate"] == 24000 # TTS always synthesizes at the live player's rate +``` + +- [ ] **Step 8: Run both touched test files** + +Run: `uv run pytest tests/test_agent_cascade_engine.py tests/test_agent_cascade_command.py -q` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add aai_cli/agent_cascade/engine.py tests/_cascade_fakes.py tests/test_agent_cascade_engine.py tests/test_agent_cascade_command.py +AAI_ALLOW_COMMIT=1 git commit -m "feat(live): stream the reply through clause-level streaming TTS + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: brain — remove the now-dead `build_completer` cluster + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` +- Modify: `tests/test_agent_cascade_brain.py` + +**Interfaces:** +- Removes: `build_completer`, `_run_graph`, `_drive_graph`, `_log_flow`, `_surface_event`, `_reply_text`. Keeps `_clip`, `_tool_label`, `_content_text`, `build_graph`, `build_system_prompt`, `build_live_tools`, the new `build_streamer`/`_stream_graph`/`_events_from_chunk`, and `SpeechDelta`/`ToolNotice`. + +- [ ] **Step 1: Confirm nothing in `aai_cli/` still imports the old symbols** + +Run: `grep -rn "build_completer\|_run_graph\|_drive_graph\|_log_flow\|_surface_event\|_reply_text" aai_cli/` +Expected: no matches (engine now uses `build_streamer`). If any remain, fix them before deleting. + +- [ ] **Step 2: Delete the dead functions from `brain.py`** + +Remove `build_completer`, `_run_graph`, `_drive_graph`, `_log_flow`, `_surface_event`, and `_reply_text` (the contiguous block from `def build_completer` through `def _reply_text`/its body, excluding `_content_text`, `_clip`, `_tool_label` which stay). Also drop any imports left unused (e.g. if `code_agent.events.message_events` was only used by `_log_flow`/`_surface_event` — verify with `uv run ruff check aai_cli/agent_cascade/brain.py`). + +- [ ] **Step 3: Delete the dead tests** + +In `tests/test_agent_cascade_brain.py`, remove the tests that exercised the deleted code: `test_completer_*`, `test_run_graph_*`, `test_on_tool_sink_streams_*`, `test_log_flow_ignores_non_list_messages`, and the `_reply_text`/`_content_text` block (`test_reply_text_*`, `test_content_text_coerces_unexpected_content`) — **except** keep one `_content_text` test if `_content_text` survives; re-point it: + +```python +def test_content_text_coerces_unexpected_content(): + assert brain._content_text(123) == "123" + + +def test_content_text_joins_list_content_blocks(): + assert brain._content_text([{"type": "text", "text": "Hello "}, "world"]) == "Hello world" +``` + +Also remove the now-unused `_StreamingGraph`, `_search_call_message`, `_graph`, and `FakeChatModel`/`ChatGeneration`/`ChatResult` imports **only if** no surviving test (e.g. `test_build_graph_uses_gateway_model_and_runs_offline`) still uses them. `test_build_graph_uses_gateway_model_and_runs_offline` uses `FakeChatModel` and called `build_completer` — re-point it to `build_streamer`: + +```python +def test_build_graph_uses_gateway_model_and_runs_offline(monkeypatch): + captured = {} + + def fake_build_model(api_key, *, model, max_tokens, extra): + captured["model"] = model + captured["max_tokens"] = max_tokens + captured["extra"] = dict(extra) + return FakeChatModel(responses=[AIMessage(content="hi from the agent")]) + + monkeypatch.setattr(model_mod, "build_model", fake_build_model) + cfg = CascadeConfig(model="claude-x", max_tokens=128, llm_extra={"temperature": 0.2}) + graph = brain.build_graph("k", cfg, tools=[]) + assert captured == {"model": "claude-x", "max_tokens": 128, "extra": {"temperature": 0.2}} + streamer = brain.build_streamer("k", cfg, graph=graph) + spoken = "".join(e.text for e in streamer([{"role": "user", "content": "hi"}])) + assert spoken == "hi from the agent" +``` + +(`FakeChatModel` streams through the real deepagents graph; `build_streamer`'s messages-mode iteration collects its tokens. Keep `FakeChatModel` and its imports.) + +Likewise re-point `test_build_graph_uses_gateway_model_and_runs_offline`'s sibling MCP tests (`test_build_graph_loads_mcp_tools_from_config_when_not_injected` calls `build_graph` only — unaffected). + +- [ ] **Step 4: Run the brain suite + a vulture check** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -q` +Expected: PASS. +Run: `uv run vulture aai_cli/agent_cascade/brain.py` (or rely on the gate) — expect no unused-code report. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py +AAI_ALLOW_COMMIT=1 git commit -m "refactor(live): drop the superseded build_completer reply path + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: docs + full gate + +**Files:** +- Modify: `aai_cli/AGENTS.md` + +- [ ] **Step 1: Update the architecture note** + +In `aai_cli/AGENTS.md`, in the `agent_cascade/` bullet, replace the description of the LLM leg and per-sentence TTS to reflect streaming. Change the phrase "the cascade — greeting, per-sentence TTS, barge-in, history window —" to "the cascade — greeting, clause-level streaming TTS, barge-in, history window —", and rewrite the `-v` sentence: + +> The LLM leg is a deepagents graph (`brain.py`) streamed token-by-token via `brain.build_streamer` (`stream_mode="messages"`): the engine buffers deltas, flushes complete clauses with `text.pop_clauses`, and synthesizes each with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. Under `-v` (`debuglog.active()`) `brain._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. + +- [ ] **Step 2: Run the docs consistency gate** + +Run: `uv run python scripts/docs_consistency_gate.py` +Expected: PASS (no env-var/exit-code/command drift — this change adds none). + +- [ ] **Step 3: Run the full gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` Likely fixups and how to clear them: + - **Patch coverage / mutation**: every changed engine branch needs a *failing-on-break* assertion. The new tests cover the clause flush, the `ToolNotice` buffer-clear, the `_speaking` first-delta gate, the `_feed` stop-drop, the timeout, and both error paths. If the mutation gate flags `_MIN_CLAUSE_CHARS` or a boundary in `pop_clauses`, add/adjust a `tests/test_agent_cascade_text.py` case that distinguishes the two values (Task 1 already pins `min_chars=10` vs short fragments). + - **Escape hatches**: confirm net-neutral — the producer's `daemon=True # pragma: no mutate` replaces `_complete_within`'s removed one; do not add others. The two `# pragma: no cover` `yield` lines in the error-raising fake generators are test-only and count against the gate — if they tip the budget, rewrite those fakes as a tiny class with a `stream` method that `raise`s (no generator, no pragma needed), mirroring `_Boom` in Task 2. + - **Textual coverage floor**: unaffected (no `tui.py` change), but `check.sh` runs it anyway — should stay ≥90%. + - **xenon**: `_generate_reply` must stay ≤ B complexity. If it trips, the `_speak`/`_pump`/`_fail_leg`/`_record_spoken` helpers already factor most branches out; move the queue-item dispatch into a small `_handle_item` helper if needed. + +- [ ] **Step 4: Final commit (gated)** + +After `check.sh` prints `All checks passed.`, make the final commit normally (the gate marker is now recorded, so the commit hook permits it without `AAI_ALLOW_COMMIT`): + +```bash +git add aai_cli/AGENTS.md +git commit -m "docs(live): describe the streaming reply pipeline + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review + +**Spec coverage:** +- §1 brain reply event stream → Task 2 (`build_streamer`, `SpeechDelta`/`ToolNotice`, verbose logging) + Task 4 (remove old path). ✅ +- §2 TTS frame sink → Task 3 (`CascadeDeps.synthesize` signature + `CascadeDeps.real` + `greet` + `_feed`). ✅ +- §3 engine streaming `_generate_reply` (producer thread + queue + monotonic deadline + buffer-clear-on-tool + `_speaking` first-delta + error paths) → Task 3. ✅ +- §4 incremental clause splitter `pop_clauses` → Task 1. ✅ +- §5 testing (engine fake seam, brain fake graph `.stream`, `pop_clauses` table tests) → Tasks 1–4. ✅ +- Risks/out-of-scope → no `--no-format-turns` or no-tools-completion work here (out of scope, as specified). ✅ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code. The full-gate fixups in Task 5 are described with concrete remedies, not "handle errors." ✅ + +**Type consistency:** `stream_reply` (engine seam) returns an iterable of `brain.SpeechDelta | brain.ToolNotice`; `build_streamer` returns exactly that iterator; the fakes yield those types. `synthesize(text, sink)` is consistent across `CascadeDeps`, `CascadeDeps.real`, `greet`, `_speak`, and every fake. `pop_clauses(buffer, *, min_chars)` matches between Task 1's definition and Task 3's call. `_Done`/`_Failure`/`_timeout_error` are defined and used in Task 3. ✅ diff --git a/docs/superpowers/plans/2026-06-22-live-subagents.md b/docs/superpowers/plans/2026-06-22-live-subagents.md new file mode 100644 index 00000000..12ec14b8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-subagents.md @@ -0,0 +1,138 @@ +# Subagents (`task` tool) for `assembly live` (M2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or :executing-plans. + +**Goal:** Under `--files`, give the live agent deepagents' `task` tool — one gateway-bound, sandbox-backed, **gated** general-purpose subagent it can delegate a focused multi-step subtask to. + +**Spec:** `docs/superpowers/specs/2026-06-22-live-sandboxed-execute-design.md` (Milestone **M2**). Builds on M1 (sandboxed `execute` + memory), already committed. + +**Verification spike — RESOLVED (PASS).** The spec's one genuine unknown was whether a subagent's HITL write/exec interrupt surfaces at the PARENT graph's `get_state(config).interrupts` (what `brain._pending_writes` reads). A spike built a real deepagents graph with a `subagents=[…]` spec carrying `interrupt_on`, drove `main → task → subagent → write_file`, and confirmed: the parent `state.interrupts` carries the subagent's `action_requests` (write **not** run), and `Command(resume={"decisions":[{"type":"approve"}]})` then lands the write and clears the interrupt — identical to M1's main-agent gating. **Therefore: ship the FULL-TOOLS subagent** (no read-only fallback). A regression test formalizes the spike. + +## Global Constraints +- `from __future__ import annotations`; modern typing. No new dependency. +- Live-only, behind `--files`. `--files`-off path byte-identical. +- **`brain.py` is at 495/500 lines** (concurrent work). Keep brain edits minimal (≈3 lines); the subagent spec lives in a NEW module `aai_cli/agent_cascade/subagents.py`. +- **AssemblyAI-only invariant:** the subagent spec MUST omit `model` (inherits the gateway-bound model). A test asserts the spec has no `model` key. +- **Gated-mutation invariant:** the subagent's `interrupt_on` mirrors `_WRITE_TOOLS` (`write_file`/`edit_file`/`execute`) so its mutations prompt through the same approver. Never an ungated mutating subagent. +- 100% patch coverage + diff-scoped mutation gate; no new escape hatches; tests-pyright via `pyright -p pyrightconfig.tests.json`. +- Concurrent session is live on this branch: commit only M2 files (selective `git add`); `AAI_ALLOW_COMMIT=1` per task; final `./scripts/check.sh` (sandbox-disabled for swift `mktemp`). + +--- + +### Task 1: The general-purpose subagent spec (`subagents.py`) + +**Files:** Create `aai_cli/agent_cascade/subagents.py`; Test `tests/test_agent_cascade_subagents.py`. + +**Interface:** `general_purpose_subagent(interrupt_on: dict[str, bool]) -> dict[str, object]` — a deepagents `SubAgent` dict with `name`/`description`/`system_prompt`/`interrupt_on`, and **no** `model` or `tools` keys (both inherit: gateway-bound model, full sandboxed toolset). Takes `interrupt_on` as a param to avoid importing `_WRITE_TOOLS` from `brain` (would be circular). + +- [ ] **Step 1: Tests (write first, run, see fail)** +```python +from aai_cli.agent_cascade.subagents import general_purpose_subagent + +def test_spec_has_required_keys_and_no_model(): + spec = general_purpose_subagent({"write_file": True, "edit_file": True, "execute": True}) + assert spec["name"] == "general-purpose" + assert isinstance(spec["description"], str) and spec["description"] + assert isinstance(spec["system_prompt"], str) and spec["system_prompt"] + # AssemblyAI-only: never a provider:model string — must inherit the gateway-bound model. + assert "model" not in spec + # Full-tools path: tools omitted so it inherits the sandboxed main toolset. + assert "tools" not in spec + +def test_spec_interrupt_on_gates_every_mutating_tool(): + io = {"write_file": True, "edit_file": True, "execute": True} + spec = general_purpose_subagent(io) + assert spec["interrupt_on"] == io # its write_file/edit_file/execute also prompt + +def test_spec_interrupt_on_is_the_passed_mapping_not_hardcoded(): + spec = general_purpose_subagent({"write_file": True}) + assert spec["interrupt_on"] == {"write_file": True} +``` +- [ ] **Step 2: Implement** +```python +"""The general-purpose subagent for `assembly live --files` (deepagents' `task` tool). + +One subagent the live agent delegates a focused multi-step subtask to. It OMITS `model` (so it +inherits the AssemblyAI gateway-bound model — never a provider:model string) and `tools` (so it +inherits the main sandboxed toolset, keeping its `execute` OS-confined). Its `interrupt_on` +mirrors the main agent's write tools, so its mutations prompt through the same approval loop. +""" +from __future__ import annotations + +_SYSTEM_PROMPT = ( + "You are a focused coworker handling one delegated subtask in the user's project. Work in " + "the current directory, use the available tools to research or make a contained change, and " + "return a concise, spoken-length summary of what you did or found — not a transcript." +) + +def general_purpose_subagent(interrupt_on: dict[str, bool]) -> dict[str, object]: + """The `task` subagent spec: gateway-bound (no `model`), full sandboxed tools (no `tools`), + with `interrupt_on` mirroring the caller's write tools so its mutations stay gated.""" + return { + "name": "general-purpose", + "description": ( + "Delegate a focused multi-step subtask — research, gather context, or implement a " + "contained change — and get back a short summary. Keeps the main voice turn lean." + ), + "system_prompt": _SYSTEM_PROMPT, + "interrupt_on": interrupt_on, + } +``` +- [ ] **Step 3: run tests green; commit** (`feat(live): general-purpose subagent spec for the task tool`) + +--- + +### Task 2: Wire `subagents` + the `task` label into `brain.py` + +**Files:** Modify `aai_cli/agent_cascade/brain.py`; Test `tests/test_agent_cascade_brain.py`. + +**Edits (locate by content):** +- import: `from aai_cli.agent_cascade.subagents import general_purpose_subagent` +- in `_graph_kwargs` return dict (when `config.files`): add `"subagents": [general_purpose_subagent(dict.fromkeys(_WRITE_TOOLS, True))]` +- `_TOOL_LABELS`: add `"task": "Working on a subtask"` + +- [ ] **Step 1: Tests** (extend the existing `_graph_kwargs` test or add): +```python +def test_graph_kwargs_wires_one_gated_gateway_bound_subagent(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + kwargs = brain._graph_kwargs(CascadeConfig(files=True)) + subs = kwargs["subagents"] + assert isinstance(subs, list) and len(subs) == 1 + spec = subs[0] + assert spec["name"] == "general-purpose" + assert "model" not in spec # inherits the gateway-bound model + assert spec["interrupt_on"] == {"write_file": True, "edit_file": True, "execute": True} + +def test_graph_kwargs_off_has_no_subagents(): + assert "subagents" not in brain._graph_kwargs(CascadeConfig(files=False)) + +def test_tool_label_task_is_working_on_a_subtask(): + assert brain._tool_label("task") == "Working on a subtask" +``` +- [ ] **Step 2: Implement the 3 edits.** Re-check `wc -l brain.py < 500` after. +- [ ] **Step 3: green; commit** (`feat(live): wire the gated general-purpose subagent + task label`) + +--- + +### Task 3: Subagent HITL-surfacing regression test (formalize the spike) + +**Files:** Test `tests/test_agent_cascade_files.py` (or `_subagents` test) — a real deepagents graph with the spec, driving `main → task → subagent → write_file`, asserting the parent approval loop sees it. + +**Interface consumed:** `brain._pending_writes(graph, config)`, `brain._stream_gated` / `build_streamer`. + +- [ ] **Step 1: Test** — build a `_gated_graph`-style real graph WITH `subagents=[general_purpose_subagent({"write_file":True,...})]`, a `FakeChatModel` scripted `[task_call, write_file_call, AIMessage("done"), AIMessage("ok")]`. Stream one turn through `build_streamer` with a recording approver; assert the approver was consulted for the subagent's `write_file` (i.e. the interrupt surfaced through `_pending_writes`/`_decide`), and on approve the file is written under cwd; on reject it is not. (This is the go/no-go, now PASS, locked as a regression.) +- [ ] **Step 2: green; commit** (`test(live): lock subagent write surfacing through the parent gate`) + +--- + +### Task 4: Capability phrase + docs + full gate + +**Files:** `aai_cli/agent_cascade/prompt.py` (+ its test), `aai_cli/AGENTS.md`, `REFERENCE.md`. + +- [ ] Advertise delegation when `--files` is on (task is bound iff `--files`): extend the `--files` capability phrase to mention delegating a bigger job to a helper. Test asserts the phrase appears under `files=True`, absent under `files=False`. (System prompt isn't snapshot-pinned.) +- [ ] `aai_cli/AGENTS.md` `--files` paragraph + `REFERENCE.md`: note the `task` delegation tool. (Coordinate with the concurrent session if `prompt.py` is dirty — commit only M2 hunks.) +- [ ] Run `./scripts/check.sh` (sandbox-disabled) to green; final commit. + +## Self-Review +- Spec M2 coverage: subagent passed to `create_deep_agent` ✅ (T2); spec omits `model` ✅ (T1+T2); full-tools `interrupt_on` includes execute/write/edit ✅; `_tool_label("task")` ✅; task capability phrase ✅ (T4); HITL-surfacing spike → full-tools, regression-locked ✅ (T3). Read-only fallback NOT needed (spike PASS). +- Deferred to M3: spoken approval. diff --git a/docs/superpowers/plans/2026-06-22-live-weather-tool.md b/docs/superpowers/plans/2026-06-22-live-weather-tool.md new file mode 100644 index 00000000..f934bfd9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-live-weather-tool.md @@ -0,0 +1,590 @@ +# Live Weather Tool 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:** Give the `assembly live` voice agent a keyless, always-available weather tool (current conditions + short forecast) backed by Open-Meteo. + +**Architecture:** A new live-only module `aai_cli/agent_cascade/weather_tool.py` following the `fetch_tool.py` shape — pure, directly-tested helpers (WMO-code text, formatting) plus a single injectable network seam (`Fetcher = Callable[[str], object]`). The tool geocodes a place name, fetches a 3-day forecast, and renders a short speakable string. `brain.py` binds it into the deepagents graph, advertises it in the system prompt, and gives it a live-UI affordance label. + +**Tech Stack:** Python 3.12+, `httpx` (already a dependency, used by `fetch_tool.py`), LangChain `@tool`, `aai_cli.core.jsonshape` for safe JSON parsing, pytest. + +## Global Constraints + +Copied verbatim from the repo invariants — every task's requirements include these: + +- `from __future__ import annotations` at the top of every module; modern typing (`X | None`). +- mypy is **strict** on `aai_cli` (`disallow_untyped_defs`); no new `# type: ignore` / `Any` / `cast(` — the gate count-checks these against the merge-base. Parse untyped JSON through `aai_cli.core.jsonshape` (`as_mapping`, `mapping_list`, `object_list`, `as_int`, `as_float`) rather than indexing raw `object`. +- The tool is **keyless** — it must **not** read any env var (no `aai_cli.core.env` use, no API key); Open-Meteo needs none. +- Tests are **hermetic**: pytest-socket is armed (`--disable-socket`). Never make a real HTTP call in a test — always drive the tool through the injected `fetch` seam. +- The gate requires **100% patch coverage** vs `origin/main` **and** survives **diff-scoped mutation testing**: every changed line needs an assertion that would *fail* if the line broke (assert behavior, not just execution). A pure tuning constant that can't be asserted gets `# pragma: no mutate`. +- Docstrings on public functions are imperative, sentence-case; internal helper docstrings keep normal punctuation. Run-logic shared beyond one command would live in `app/`, but this is live-only, so it stays in `agent_cascade/`. +- Files stay under 500 lines; cyclomatic complexity max B per function. +- Do not edit `uv.lock` — no new dependencies (`httpx` is already present). + +--- + +### Task 1: The `weather_tool.py` module + +**Files:** +- Create: `aai_cli/agent_cascade/weather_tool.py` +- Test: `tests/test_agent_cascade_weather.py` + +**Interfaces:** +- Consumes: `aai_cli.core.jsonshape` (`as_mapping`, `mapping_list`, `object_list`, `as_int`, `as_float`); `httpx` (in the default seam only); `langchain_core.tools.tool` / `BaseTool`. +- Produces (later tasks rely on these exact names/types): + - `WEATHER_TOOL_NAME: str = "get_weather"` + - `Fetcher = Callable[[str], object]` + - `describe_weather_code(code: int) -> str` + - `format_report(name: str, data: dict[str, object]) -> str` + - `_geocode(name: str, *, fetch: Fetcher) -> tuple[str, float, float] | None` + - `_forecast(lat: float, lon: float, *, fetch: Fetcher) -> dict[str, object]` + - `build_weather_tool(fetch: Fetcher = _get_json) -> BaseTool` — exposes `get_weather(location: str) -> str` + +- [ ] **Step 1: Write the failing test file (all behaviors at once)** + +Create `tests/test_agent_cascade_weather.py`: + +```python +"""Tests for the keyless Open-Meteo weather tool behind `assembly live`. + +The tool's only network seam is the injected ``fetch`` callable, so the whole +geocode -> forecast -> format flow runs with no sockets (pytest-socket stays armed). +""" + +from __future__ import annotations + +import pytest + +from aai_cli.agent_cascade import weather_tool + +# Canned Open-Meteo payloads keyed by URL prefix, replayed through the fetch seam. +_GEOCODE = { + "results": [ + {"name": "Paris", "latitude": 48.85, "longitude": 2.35, "country": "France"} + ] +} +_FORECAST = { + "current": {"temperature_2m": 14.3, "weather_code": 2}, + "daily": { + "time": ["2026-06-22", "2026-06-23", "2026-06-24"], + "temperature_2m_max": [17.2, 17.0, 19.1], + "temperature_2m_min": [9.0, 9.4, 11.2], + "weather_code": [2, 61, 0], + }, +} + + +def _fake_fetch(geocode=_GEOCODE, forecast=_FORECAST): + """A fetch seam that returns canned geocode/forecast JSON by URL.""" + + def fetch(url: str) -> object: + return geocode if "geocoding-api" in url else forecast + + return fetch + + +# --- describe_weather_code --------------------------------------------------- + + +def test_describe_weather_code_known(): + assert weather_tool.describe_weather_code(0) == "clear sky" + assert weather_tool.describe_weather_code(61) == "light rain" + + +def test_describe_weather_code_unknown_falls_back(): + # An unmapped WMO code must not raise; it returns the generic fallback. + assert weather_tool.describe_weather_code(999) == "unsettled weather" + + +# --- _geocode ---------------------------------------------------------------- + + +def test_geocode_returns_top_match_and_hits_geocoding_host(): + seen = {} + + def fetch(url: str) -> object: + seen["url"] = url + return _GEOCODE + + result = weather_tool._geocode("Paris", fetch=fetch) + assert result == ("Paris", 48.85, 2.35) + assert "geocoding-api.open-meteo.com" in seen["url"] + assert "name=Paris" in seen["url"] + + +def test_geocode_no_results_is_none(): + assert weather_tool._geocode("Nowhereville", fetch=lambda url: {"results": []}) is None + + +def test_geocode_missing_results_key_is_none(): + assert weather_tool._geocode("x", fetch=lambda url: {}) is None + + +# --- _forecast --------------------------------------------------------------- + + +def test_forecast_requests_current_and_daily_for_coordinates(): + seen = {} + + def fetch(url: str) -> object: + seen["url"] = url + return _FORECAST + + data = weather_tool._forecast(48.85, 2.35, fetch=fetch) + assert data == _FORECAST + assert "api.open-meteo.com/v1/forecast" in seen["url"] + assert "latitude=48.85" in seen["url"] + assert "longitude=2.35" in seen["url"] + assert "current=temperature_2m" in seen["url"] + assert "daily=temperature_2m_max" in seen["url"] + assert "forecast_days=3" in seen["url"] + + +# --- format_report ----------------------------------------------------------- + + +def test_format_report_renders_current_in_both_units_and_two_forecast_days(): + report = weather_tool.format_report("Paris", _FORECAST) + # Current line: rounded °C, derived °F, and the condition text. + assert "In Paris it's 14°C (58°F) and partly cloudy." in report + # Two forecast days, labelled, °C lows-to-highs with their own conditions. + assert "Tomorrow 9 to 17°C, light rain." in report + assert "Then 11 to 19°C, clear sky." in report + + +# --- build_weather_tool (end to end via the seam) ---------------------------- + + +def test_tool_name_and_happy_path(): + tool = weather_tool.build_weather_tool(fetch=_fake_fetch()) + assert tool.name == weather_tool.WEATHER_TOOL_NAME == "get_weather" + out = tool.invoke({"location": "Paris"}) + assert "In Paris it's 14°C (58°F) and partly cloudy." in out + assert "Tomorrow 9 to 17°C, light rain." in out + + +def test_tool_location_not_found_message(): + tool = weather_tool.build_weather_tool(fetch=lambda url: {"results": []}) + assert tool.invoke({"location": "Nowhereville"}) == ( + "I couldn't find a place called 'Nowhereville'." + ) + + +def test_tool_network_error_is_graceful(): + def boom(url: str) -> object: + raise RuntimeError("open-meteo down") + + tool = weather_tool.build_weather_tool(fetch=boom) + assert tool.invoke({"location": "Paris"}) == "I couldn't get the weather right now." +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/test_agent_cascade_weather.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'aai_cli.agent_cascade.weather_tool'`. + +- [ ] **Step 3: Write the module** + +Create `aai_cli/agent_cascade/weather_tool.py`: + +```python +"""A keyless live-weather tool for the `assembly live` voice agent. + +Backed by Open-Meteo, which needs no API key — so unlike the optional Firecrawl +search, this tool is *always* present, giving every live session at least one real +capability. The flow is geocode (place name -> coordinates) -> forecast (current + +a short daily outlook) -> a single short string the agent reads aloud. + +The only network seam is :data:`Fetcher` (a ``url -> parsed JSON`` callable), +injected in tests so the whole flow runs with no sockets — the same shape +``code_agent.fetch_tool`` uses. Everything else (the WMO-code text, the spoken +formatting) is pure and tested directly. Failures never raise out to the graph: +``get_weather`` catches them and returns a short spoken apology so a weather +outage can't sink a live turn. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +from aai_cli.core import jsonshape + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +# The registered tool name. ``brain.py`` detects weather availability and labels the +# live-UI affordance by this name, so a test pins it. +WEATHER_TOOL_NAME = "get_weather" + +# A fetcher GETs a URL and returns parsed JSON. Injected in tests (the only net seam). +Fetcher = Callable[[str], object] + +_GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" +_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +_TIMEOUT = 15.0 # pragma: no mutate — a tuning knob; ±a few seconds is equivalent +_FORECAST_DAYS = 3 # today + the next two days (the two spoken outlook lines) + +# WMO weather-interpretation codes -> short spoken phrases. A code not listed here +# (Open-Meteo can add more) falls back in :func:`describe_weather_code` rather than +# raising, so an unfamiliar code never sinks a turn. +_WMO_DESCRIPTIONS: dict[int, str] = { + 0: "clear sky", + 1: "mainly clear", + 2: "partly cloudy", + 3: "overcast", + 45: "fog", + 48: "freezing fog", + 51: "light drizzle", + 53: "drizzle", + 55: "heavy drizzle", + 61: "light rain", + 63: "rain", + 65: "heavy rain", + 66: "freezing rain", + 67: "heavy freezing rain", + 71: "light snow", + 73: "snow", + 75: "heavy snow", + 77: "snow grains", + 80: "light showers", + 81: "showers", + 82: "heavy showers", + 85: "light snow showers", + 86: "heavy snow showers", + 95: "thunderstorms", + 96: "thunderstorms with hail", + 99: "severe thunderstorms with hail", +} + +# Spoken labels for the next two forecast days (index 1 and 2 of the daily arrays). +_DAY_LABELS = ("Tomorrow", "Then") + + +def describe_weather_code(code: int) -> str: + """Return a short spoken phrase for a WMO weather code, or a generic fallback.""" + return _WMO_DESCRIPTIONS.get(code, "unsettled weather") + + +def _c_to_f(celsius: float) -> int: + """Convert Celsius to a rounded Fahrenheit integer for the spoken report.""" + return round(celsius * 9 / 5 + 32) + + +def _get_json(url: str) -> object: + """GET ``url`` and return its parsed JSON body (the default network seam).""" + import httpx + + response = httpx.get(url, timeout=_TIMEOUT) + response.raise_for_status() + return response.json() + + +def _geocode(name: str, *, fetch: Fetcher) -> tuple[str, float, float] | None: + """Resolve a place name to ``(display name, latitude, longitude)``, or None. + + Asks Open-Meteo's geocoding endpoint for the single best match. No match (an + empty or absent ``results`` list) returns None so the tool can speak a clear + "couldn't find that place" instead of guessing. + """ + query = urlencode({"name": name, "count": 1, "language": "en", "format": "json"}) + payload = jsonshape.as_mapping(fetch(f"{_GEOCODE_URL}?{query}")) + results = jsonshape.mapping_list(payload.get("results")) if payload is not None else [] + if not results: + return None + top = results[0] + return ( + str(top.get("name", name)), + jsonshape.as_float(top.get("latitude")), + jsonshape.as_float(top.get("longitude")), + ) + + +def _forecast(lat: float, lon: float, *, fetch: Fetcher) -> dict[str, object]: + """Fetch the current conditions plus a short daily outlook for coordinates.""" + query = urlencode( + { + "latitude": lat, + "longitude": lon, + "current": "temperature_2m,weather_code", + "daily": "temperature_2m_max,temperature_2m_min,weather_code", + "forecast_days": _FORECAST_DAYS, + "timezone": "auto", + } + ) + return jsonshape.as_mapping(fetch(f"{_FORECAST_URL}?{query}")) or {} + + +def _forecast_lines(daily: dict[str, object]) -> list[str]: + """The spoken outlook lines for the next days, e.g. ``Tomorrow 9 to 17°C, rain.``""" + highs = jsonshape.object_list(daily.get("temperature_2m_max")) + lows = jsonshape.object_list(daily.get("temperature_2m_min")) + codes = jsonshape.object_list(daily.get("weather_code")) + lines: list[str] = [] + for offset, label in enumerate(_DAY_LABELS, start=1): + if offset < len(highs) and offset < len(lows) and offset < len(codes): + low = round(jsonshape.as_float(lows[offset])) + high = round(jsonshape.as_float(highs[offset])) + cond = describe_weather_code(jsonshape.as_int(codes[offset])) + lines.append(f"{label} {low} to {high}°C, {cond}.") + return lines + + +def format_report(name: str, data: dict[str, object]) -> str: + """Render the Open-Meteo forecast as one short, speakable string. + + The current temperature is given in both units (the agent speaks whichever fits + the conversation); the outlook days stay in °C to keep the spoken reply short. + """ + current = jsonshape.as_mapping(data.get("current")) or {} + daily = jsonshape.as_mapping(data.get("daily")) or {} + temp = jsonshape.as_float(current.get("temperature_2m")) + desc = describe_weather_code(jsonshape.as_int(current.get("weather_code"))) + lines = [f"In {name} it's {round(temp)}°C ({_c_to_f(temp)}°F) and {desc}."] + lines.extend(_forecast_lines(daily)) + return " ".join(lines) + + +def build_weather_tool(fetch: Fetcher = _get_json) -> BaseTool: + """Wrap the Open-Meteo lookup as the ``get_weather`` tool (``fetch`` injectable).""" + from langchain_core.tools import tool + + @tool(WEATHER_TOOL_NAME) + def get_weather(location: str) -> str: + """Get the current weather and a short forecast for a place by name (e.g. a + city). Use when asked about the weather, temperature, or forecast somewhere.""" + try: + located = _geocode(location, fetch=fetch) + if located is None: + return f"I couldn't find a place called '{location}'." + name, lat, lon = located + return format_report(name, _forecast(lat, lon, fetch=fetch)) + except Exception: + # Best-effort: a transient Open-Meteo outage (the fetch seam raises) must + # not bubble into brain's "couldn't complete the turn" path and kill the + # spoken reply — speak a short apology instead. Mirrors mcp_tools._safe_load. + return "I couldn't get the weather right now." + + return get_weather +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_weather.py -q` +Expected: PASS (all 11 tests). + +Note on the °F assertion: 14.3 °C → `round(14.3*9/5+32)` = `round(57.74)` = **58**, matching the test. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/agent_cascade/weather_tool.py tests/test_agent_cascade_weather.py +git commit -m "feat: keyless Open-Meteo weather tool for assembly live" +``` + +--- + +### Task 2: Wire the weather tool into the live agent's brain + +**Files:** +- Modify: `aai_cli/agent_cascade/brain.py` (imports near line 26; `_TOOL_LABELS` ~line 47; `_tool_capabilities` ~line 83; `build_live_tools` ~line 137) +- Test: `tests/test_agent_cascade_brain.py` (add new tests; update the two existing `build_live_tools` tests at lines 378-388) + +**Interfaces:** +- Consumes (from Task 1): `weather_tool.WEATHER_TOOL_NAME`, `weather_tool.build_weather_tool`. +- Produces: `build_live_tools()` now always includes the weather tool; `_tool_capabilities()` advertises weather; `_tool_label(WEATHER_TOOL_NAME)` → `"Checking the weather"`. + +- [ ] **Step 1: Update the two existing `build_live_tools` tests to expect the always-present weather tool** + +In `tests/test_agent_cascade_brain.py`, the current tests (lines 378-388) assert the toolset is *only* web search / empty. Weather is now always present, so replace both: + +```python +def test_build_live_tools_has_weather_and_web_search_when_keyed(monkeypatch): + search = _NamedTool(brain.WEB_SEARCH_TOOL_NAME) + monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: search) + names = [tool.name for tool in brain.build_live_tools()] + # Web search is the optional keyed leg; the keyless weather tool is always present. + assert brain.WEB_SEARCH_TOOL_NAME in names + assert weather_tool.WEATHER_TOOL_NAME in names + + +def test_build_live_tools_is_just_weather_without_firecrawl_key(monkeypatch): + monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: None) + # No FIRECRAWL_API_KEY -> no web search, but the keyless weather tool still loads. + names = [tool.name for tool in brain.build_live_tools()] + assert names == [weather_tool.WEATHER_TOOL_NAME] +``` + +Add the import at the top of the test module (near the other `from aai_cli.agent_cascade import …` imports, ~line 18): + +```python +from aai_cli.agent_cascade import weather_tool +``` + +Note: `_NamedTool(name)` already exists in this file (it exposes a `.name`); using it for the search double keeps `[tool.name for tool …]` working without a real Firecrawl object. + +- [ ] **Step 2: Add new tests for the weather wiring (capability phrase + affordance label)** + +Append to `tests/test_agent_cascade_brain.py`: + +```python +def test_weather_tool_advertised_in_system_prompt(): + prompt = brain.build_system_prompt( + "persona", tools=[_NamedTool(weather_tool.WEATHER_TOOL_NAME)] + ) + assert "current weather and short forecast" in prompt + # And it isn't the no-tools fallback. + assert "no external tools" not in prompt + + +def test_tool_label_maps_weather(): + assert brain._tool_label(weather_tool.WEATHER_TOOL_NAME) == "Checking the weather" +``` + +- [ ] **Step 3: Run the new/updated tests to verify they fail** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -q -k "weather or build_live_tools"` +Expected: FAIL — `build_live_tools` still returns only web search (no weather), the capability phrase isn't in the prompt, and `_tool_label` returns `"Using get_weather"`. + +- [ ] **Step 4: Implement the wiring in `brain.py`** + +Add the import beside the existing `firecrawl_search` import (line 26): + +```python +from aai_cli.agent_cascade import weather_tool +``` + +Add the affordance label to `_TOOL_LABELS` (line 47) — keep web search, add weather: + +```python +_TOOL_LABELS = { + WEB_SEARCH_TOOL_NAME: "Searching the web", + weather_tool.WEATHER_TOOL_NAME: "Checking the weather", +} +``` + +Extend `_tool_capabilities` (lines 83-94) to advertise weather when the tool is bound: + +```python +def _tool_capabilities(tools: Sequence[BaseTool]) -> list[str]: + """The spoken-capability phrases backed by present built-in tools. + + The live agent's built-in legs are the keyless Open-Meteo weather tool (always + present) and Firecrawl web search (only when ``FIRECRAWL_API_KEY`` is set) — so the + prompt advertises each only when the agent can really do it. Advertising a missing + tool made it announce an action ("I'll search…") it then couldn't take. + """ + names = {tool.name for tool in tools} + capabilities: list[str] = [] + if WEB_SEARCH_TOOL_NAME in names: + capabilities.append("search the web for current or unfamiliar facts") + if weather_tool.WEATHER_TOOL_NAME in names: + capabilities.append("tell someone the current weather and short forecast for a place") + return capabilities +``` + +Update `build_live_tools` (lines 137-151) to always include the weather tool: + +```python +def build_live_tools() -> list[BaseTool]: + """The live agent's built-in tools: the keyless weather tool, plus Firecrawl web + search when ``FIRECRAWL_API_KEY`` is set. + + Deliberately minimal. A low-latency spoken turn does best with a few obvious tools + rather than a large menu it must choose among. Open-Meteo needs no key, so the + weather tool is always present (every session has at least one real capability); + web search is reused (un-approval-gated) from the coding agent and added only when + keyed. Extra tools remain strictly opt-in via ``--mcp-config``. + """ + from aai_cli.agent_cascade.weather_tool import build_weather_tool + from aai_cli.code_agent.firecrawl_search import build_web_search_tool + + tools: list[BaseTool] = [build_weather_tool()] + search = build_web_search_tool() + if search is not None: + tools.append(search) + return tools +``` + +Note: keep the module-level `from aai_cli.agent_cascade import weather_tool` (used by `_TOOL_LABELS` and `_tool_capabilities` at import time); the function-local `build_weather_tool` import mirrors the existing lazy `build_web_search_tool` import so module import stays light. + +- [ ] **Step 5: Run the brain tests to verify they pass** + +Run: `uv run pytest tests/test_agent_cascade_brain.py -q` +Expected: PASS (the two updated `build_live_tools` tests, the two new weather tests, and all pre-existing tests — e.g. `test_build_system_prompt_*`, `test_tool_label_maps_web_search_and_falls_back_for_others` — still green). + +- [ ] **Step 6: Check for snapshot/help drift** + +`build_live_tools` and the system prompt are not part of any `--help` golden or TUI SVG (the prompt is internal, the tool list isn't rendered), so no snapshot regeneration is expected. Confirm: + +Run: `uv run pytest tests/test_snapshots_help_root.py tests/test_agent_cascade_command.py -q` +Expected: PASS with no snapshot changes. (If anything fails on a golden, regenerate with `uv run pytest --snapshot-update` and eyeball the diff before committing — but none is anticipated.) + +- [ ] **Step 7: Commit** + +```bash +git add aai_cli/agent_cascade/brain.py tests/test_agent_cascade_brain.py +git commit -m "feat: bind the live weather tool into the assembly live agent" +``` + +--- + +### Task 3: Full gate + documentation consistency + +**Files:** +- Possibly modify: `REFERENCE.md` / `README.md` only if they enumerate the live agent's built-in tools (the docs-consistency gate checks env vars / exit codes / command refs, not tool lists — verify before editing). + +- [ ] **Step 1: Check whether docs enumerate the live tools** + +Run: `git grep -n -i "web search\|firecrawl\|built-in tool" REFERENCE.md README.md` +If a passage lists the live agent's built-in tools (e.g. "the live agent can search the web"), add weather alongside it in the same terse style. If nothing enumerates them, skip — do not invent a section (YAGNI). + +- [ ] **Step 2: Run the full gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` This is the authoritative gate — it runs ruff/mypy/pyright/vulture/import-linter, the 100%-patch-coverage gate, and the diff-scoped mutation gate against `origin/main`. Do not claim done until it prints that line. + +Likely failure points and fixes: +- **Mutation survivor on `_TIMEOUT` / `_FORECAST_DAYS`** — `_TIMEOUT` is already `# pragma: no mutate`. If `_FORECAST_DAYS` survives (a `±1` mutant the tests don't kill), assert it via the `forecast_days=3` URL substring (already in `test_forecast_requests_current_and_daily_for_coordinates`) — that pins it. +- **Mutation survivor on a `_WMO_DESCRIPTIONS` entry** — the dict literal lines aren't individually asserted. The two codes the tests check (0, 61) and the fallback are killed; other entries are pure data. If the gate flags an unasserted entry as a changed line, either assert that code in `test_describe_weather_code_known` or accept it's data (the gate only mutates *changed* lines, so add the assertion rather than a pragma on data). +- **Patch-coverage gap** — if `diff-cover` reports an uncovered line, it is almost certainly a branch in `_geocode`/`format_report` (e.g. the `payload is not None` guard or the `or {}` fallback). Add a test feeding a wrong-shaped payload (e.g. `fetch=lambda url: []`) to cover it. + +- [ ] **Step 3: Re-run targeted gates if either tail gate failed** + +```bash +uv run pytest -q -n auto --cov=aai_cli --cov-branch --cov-context=test --cov-report=xml +uv run diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 +uv run python scripts/mutation_gate.py origin/main +``` + +- [ ] **Step 4: Final commit (only if Step 1 changed docs)** + +```bash +git add REFERENCE.md README.md +git commit -m "docs: note the live weather tool" +``` + +If no docs changed, Tasks 1 and 2 are already committed and the feature is complete. + +--- + +## Self-Review + +**Spec coverage:** +- Keyless Open-Meteo source → Task 1 (`_get_json`, no env read). ✓ +- Geocode → forecast → format flow → Task 1 (`_geocode`/`_forecast`/`format_report`). ✓ +- Current conditions + short forecast (today + 2 days) → `_FORECAST_DAYS = 3`, `_DAY_LABELS`. ✓ +- Both °C and °F for current → `format_report` + `_c_to_f`; °C-only outlook → `_forecast_lines`. ✓ +- WMO-code text with unknown fallback → `describe_weather_code`. ✓ +- Live-only placement in `agent_cascade/` → Task 1 file path. ✓ +- Always present, web search stays keyed → Task 2 `build_live_tools`. ✓ +- System-prompt capability phrase → Task 2 `_tool_capabilities`. ✓ +- Live-UI affordance label → Task 2 `_TOOL_LABELS` / `_tool_label`. ✓ +- Best-effort error handling (not-found + network) → Task 1 `get_weather` try/except + tests. ✓ +- Hermetic tests via injected `fetch` → Task 1 `_fake_fetch`, no sockets. ✓ +- Out of scope (no units flag, no opt-out flag, top-match only) → respected; nothing added. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; commands have expected output. ✓ + +**Type consistency:** `Fetcher = Callable[[str], object]`, `_geocode -> tuple[str, float, float] | None`, `_forecast -> dict[str, object]`, `format_report(name, data: dict[str, object])`, `WEATHER_TOOL_NAME = "get_weather"`, `build_weather_tool(fetch=_get_json) -> BaseTool` — used identically in Tasks 1, 2, and the tests. ✓ diff --git a/docs/superpowers/plans/2026-06-22-remove-assembly-code.md b/docs/superpowers/plans/2026-06-22-remove-assembly-code.md new file mode 100644 index 00000000..a5ca1d0c --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-remove-assembly-code.md @@ -0,0 +1,445 @@ +# Remove `assembly code` 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:** Remove the `assembly code` command and all code used only by it, while keeping `assembly live` (`agent_cascade/`) fully working. + +**Architecture:** `assembly code` = `commands/code/` + the `code_agent/` slice (24 modules). `assembly live` borrows 8 of those modules. We **relocate the 8 shared modules into the `agent_cascade/` slice** (its sole remaining consumer), surgically strip the code-only voice path out of `modals.py`, re-point all live imports, then delete `code_agent/` and `commands/code/` entirely. `code_gen/` (`--show-code`) is unrelated and untouched. + +**Tech Stack:** Python 3.12–3.13, Typer CLI, `uv`, deepagents/langgraph/langchain, Textual TUI, pytest + syrupy snapshots. + +## Global Constraints + +- Run every tool through `uv run` (locked env). The authoritative gate is `./scripts/check.sh` — it must print `All checks passed.` before the work is done. +- Commits are gated: a PreToolUse hook blocks `git commit` unless `check.sh` passed for the current tree. For intermediate WIP commits use `AAI_ALLOW_COMMIT=1 git commit …`; run the full gate before the **final** commit of the branch. +- `from __future__ import annotations` at the top of every module; modern typing (`X | None`). +- Help copy is terse, imperative, sentence-case, **no trailing period**. `--help` goldens are syrupy `.ambr` — regenerate with `--snapshot-update`, never hand-edit. +- Errors → stderr, data → stdout. Patch coverage must be 100% vs `origin/main`; the diff-scoped mutation gate requires changed lines to be assertion-covered. +- The `.claude/worktrees/live-tool-call-impl/` directory is another session's worktree — **never touch it**. +- **Dependency change is in-scope here** (the user OK'd folding it in): removing `langgraph-checkpoint-sqlite` rewrites `uv.lock`. Keep all other deps. + +--- + +## Pre-flight (do once before Task 1) + +The working tree is on branch `live-tool-call-ux` with uncommitted `agent_cascade` edits, and this removal heavily edits `agent_cascade`. Land the removal on a clean base. + +- [ ] **Step 1: Confirm a clean base** + +```bash +git -C /Users/alexkroman/Code/docs/assemblyai-cli status --short +``` + +If `aai_cli/commands/agent_cascade/` or `aai_cli/agent_cascade/` files are dirty, commit or stash them first (coordinate with the user — they own that in-flight work). Do not start Task 1 until `git status --short` shows only files this plan will touch. + +- [ ] **Step 2: Sanity-check the current live command works** + +```bash +uv run assembly live --help +``` + +Expected: help text renders, exit 0. This is the smoke test you re-run after every task. + +--- + +## Task 1: Relocate the 8 shared modules into `agent_cascade/` + +The 8 modules used by live move out of `code_agent/` into `agent_cascade/`. This is one atomic refactor (partial moves break imports). Move leaf modules first, then dependents. + +**Files:** +- Move (`git mv aai_cli/code_agent/X.py aai_cli/agent_cascade/X.py`): `model.py`, `firecrawl_search.py`, `banner.py`, `tui_status.py`, `summarize.py`, `risk.py`, `messages.py`, `modals.py` +- Modify: `aai_cli/agent_cascade/brain.py`, `aai_cli/agent_cascade/tui.py`, `aai_cli/agent_cascade/prompt.py`, `aai_cli/agent_cascade/weather_tool.py`, `aai_cli/commands/agent_cascade/_exec.py` +- Modify (after move): the moved `risk.py`, `messages.py`, `modals.py`, `brain.py` (intra-import re-points + surgeries) + +**Interfaces:** +- Produces (new locations, same signatures): `aai_cli.agent_cascade.model.build_model(...)`, `aai_cli.agent_cascade.firecrawl_search.build_web_search_tool()` + `WEB_SEARCH_TOOL_NAME`, `aai_cli.agent_cascade.banner`, `aai_cli.agent_cascade.tui_status`, `aai_cli.agent_cascade.summarize.{describe_args,full_args,summarize_call,summarize_result}`, `aai_cli.agent_cascade.risk`, `aai_cli.agent_cascade.messages.{AssistantMessage,ErrorMessage,Note,UserMessage}`, `aai_cli.agent_cascade.modals.ApprovalScreen` +- Produces: `aai_cli.agent_cascade.brain.CompiledAgent` (Protocol, extracted from the deleted `agent.py`) + +- [ ] **Step 1: Move the 6 leaf/standalone modules** + +```bash +cd /Users/alexkroman/Code/docs/assemblyai-cli +for m in model firecrawl_search banner tui_status summarize risk; do + git mv aai_cli/code_agent/$m.py aai_cli/agent_cascade/$m.py +done +``` + +- [ ] **Step 2: Inline `FETCH_TOOL_NAME` in the moved `risk.py`** + +`risk.py` imported `FETCH_TOOL_NAME` from `code_agent/fetch_tool.py` (value `"fetch_url"`), which is being deleted. Replace the import with a module-level literal. + +In `aai_cli/agent_cascade/risk.py`, delete the line: + +```python +from aai_cli.code_agent.fetch_tool import FETCH_TOOL_NAME +``` + +and add, near the top of the module body (after the imports): + +```python +# The fetch tool's name, inlined here — its defining module lived in the removed +# `assembly code` agent. Risk scoring is purely advisory. +FETCH_TOOL_NAME = "fetch_url" +``` + +(The existing `elif name == FETCH_TOOL_NAME:` reference now resolves to this local constant.) + +- [ ] **Step 3: Move `messages.py` and re-point its `summarize` import** + +```bash +git mv aai_cli/code_agent/messages.py aai_cli/agent_cascade/messages.py +``` + +In `aai_cli/agent_cascade/messages.py`, change: + +```python +from aai_cli.code_agent.summarize import summarize_call, summarize_result +``` + +to: + +```python +from aai_cli.agent_cascade.summarize import summarize_call, summarize_result +``` + +- [ ] **Step 4: Move `modals.py`, re-point imports, and strip the code-only voice path** + +```bash +git mv aai_cli/code_agent/modals.py aai_cli/agent_cascade/modals.py +``` + +In `aai_cli/agent_cascade/modals.py` make these edits: + +1. Re-point the surviving imports: + +```python +from aai_cli.agent_cascade import banner, risk +from aai_cli.agent_cascade.summarize import describe_args, full_args +``` + +2. Delete the `TYPE_CHECKING` import of `_VoiceIO`: + +```python + from aai_cli.code_agent.voice_ui import _VoiceIO +``` + +3. Delete the voice helpers `_spawn(...)` and `approval_from_speech(...)` (lines ~34–55) — they exist only for the spoken-answer path that live never uses. + +4. In `ApprovalScreen`: drop the `voice` parameter and `self._voice`/`self._answered` voice bookkeeping, delete the `on_mount` voice branch (`_spawn(lambda: self._drive_by_voice(voice))`), and delete `_drive_by_voice(...)` and `_spoken_prompt(...)`. The keyboard path (`compose`, `action_expand/approve/auto/reject`, `_decide`, `_detail_markup`) stays. Final constructor signature: + +```python + def __init__(self, name: str, args: Mapping[str, object]) -> None: +``` + +5. Delete the entire `AskScreen` class (lines ~159–end) — live's voice-only TUI never opens an ask modal; it is code-only. + +6. Clean the module docstring's voice references and remove now-unused imports (`Callable`, `Input`, `threading`) — the post-edit ruff hook will not auto-remove them, so delete by hand; the gate's `ruff check` would otherwise fail. + +- [ ] **Step 5: Extract `CompiledAgent` into `brain.py` and re-point brain's imports** + +In `aai_cli/agent_cascade/brain.py`: + +1. Replace the import line: + +```python +from aai_cli.code_agent.agent import CompiledAgent +``` + +with the Protocol defined locally (copied verbatim from the deleted `agent.py`), placed after the existing imports: + +```python +class CompiledAgent(Protocol): + """The slice of the compiled langgraph graph the live reply leg drives. + + A structural type so we needn't name langgraph's deeply-generic + ``CompiledStateGraph`` (and don't drag its type params through our code). + """ + + def invoke( + self, input: object, config: Mapping[str, object] | None = None + ) -> dict[str, object]: + """Run one step of the graph, returning the updated state (incl. messages).""" +``` + +Ensure `Protocol` and `Mapping` are imported at the top of `brain.py` (`from typing import Protocol`, `from collections.abc import Mapping`) — add whichever is missing. + +2. Re-point the two remaining code_agent imports: + +```python +from aai_cli.agent_cascade.firecrawl_search import WEB_SEARCH_TOOL_NAME +``` + +and inside the functions (lines ~220, ~287): + +```python + from aai_cli.agent_cascade.firecrawl_search import build_web_search_tool + from aai_cli.agent_cascade.model import build_model +``` + +- [ ] **Step 6: Re-point `tui.py`, `_exec.py`, and the `weather_tool.py` comment** + +In `aai_cli/agent_cascade/tui.py`: + +```python +from aai_cli.agent_cascade import banner, tui_status +from aai_cli.agent_cascade.messages import AssistantMessage, ErrorMessage, Note, UserMessage +from aai_cli.agent_cascade.modals import ApprovalScreen +``` + +In `aai_cli/commands/agent_cascade/_exec.py`: + +```python +from aai_cli.agent_cascade import firecrawl_search +``` + +In `aai_cli/agent_cascade/prompt.py` (extracted from brain.py in a recent commit; it imports the tool name): + +```python +from aai_cli.agent_cascade.firecrawl_search import WEB_SEARCH_TOOL_NAME +``` + +In `aai_cli/agent_cascade/weather_tool.py`: change the comment mentioning `code_agent.fetch_tool` to reference the behavior generically (no `code_agent` path). + +- [ ] **Step 7: Verify no `code_agent` import remains in the live slice** + +```bash +grep -rn "code_agent" aai_cli/agent_cascade aai_cli/commands/agent_cascade +``` + +Expected: **no output**. + +- [ ] **Step 8: Verify the live command still imports & runs** + +```bash +uv run assembly live --help && uv run python -c "import aai_cli.agent_cascade.brain, aai_cli.agent_cascade.tui, aai_cli.agent_cascade.modals" +``` + +Expected: help renders (exit 0), import succeeds with no error. + +- [ ] **Step 9: Relocate the moved-module tests and re-point live tests** + +Rename the tests that cover the moved modules (keep their assertions; drop the now-deleted voice/AskScreen cases in modals): + +```bash +git mv tests/test_code_model.py tests/test_live_model.py +git mv tests/test_code_messages.py tests/test_live_messages.py +git mv tests/test_code_risk.py tests/test_live_risk.py +git mv tests/test_code_summarize.py tests/test_live_summarize.py +git mv tests/test_code_tui_status.py tests/test_live_tui_status.py +git mv tests/test_code_modals.py tests/test_live_modals.py +``` + +In each relocated test, change `from aai_cli.code_agent.X` → `from aai_cli.agent_cascade.X`. In `tests/test_live_modals.py`, **delete** every test that imports/uses `AskScreen`, `approval_from_speech`, or passes `voice=`/`FakeVoice` (those targets no longer exist); keep the keyboard `ApprovalScreen` tests, dropping the `voice=` argument. + +Re-point the already-live tests and the snapshot helpers: +- `tests/test_agent_cascade_brain.py`: `from aai_cli.code_agent import model …` / `firecrawl_search` → `from aai_cli.agent_cascade import …`. +- `tests/test_live_tui.py`: re-point every `aai_cli.code_agent.*` import to `aai_cli.agent_cascade.*`. +- `tests/_tui_snapshot.py` and `tests/test_tui_snapshots.py`: re-point `ApprovalScreen` and any moved-module imports to `aai_cli.agent_cascade.*`; **remove** the `AskScreen` import and the AskScreen snapshot case (it is code-only). Leave the `test_live_*` snapshot cases. + +- [ ] **Step 10: Run the relocated + live tests** + +```bash +uv run pytest tests/test_live_model.py tests/test_live_messages.py tests/test_live_risk.py \ + tests/test_live_summarize.py tests/test_live_tui_status.py tests/test_live_modals.py \ + tests/test_live_tui.py tests/test_agent_cascade_brain.py -q +``` + +Expected: all pass. Fix import/strip fallout until green. + +- [ ] **Step 11: Commit (WIP — gate not yet run)** + +```bash +git add -A +AAI_ALLOW_COMMIT=1 git commit -m "refactor(live): relocate shared agent modules from code_agent into agent_cascade" +``` + +--- + +## Task 2: Delete the code-only `code_agent/` modules, `commands/code/`, and code-only tests + +After Task 1, `code_agent/` holds only code-only modules. Remove them, the command, and the code-only tests. + +**Files:** +- Delete dir: `aai_cli/commands/code/` +- Delete dir: `aai_cli/code_agent/` (now contains only: `__init__.py`, `_config_root.py`, `agent.py`, `ask_tool.py`, `cli_tool.py`, `docs_mcp.py`, `events.py`, `fetch_tool.py`, `memory.py`, `prompt.py`, `render.py`, `session.py`, `skills.py`, `store.py`, `tui.py`, `voice.py`, `voice_ui.py`) +- Delete tests: `tests/test_code_agent.py`, `tests/test_code_command.py`, `tests/test_code_session_stream.py`, `tests/test_code_tui.py`, `tests/test_code_tui_voice.py`, `tests/test_code_tui_voice_switch.py`, `tests/test_code_voice.py` +- Delete code-only TUI snapshot rasters: `tests/__snapshots__/test_tui_snapshots/test_code_*.raw` + +- [ ] **Step 1: Confirm `code_agent/` has no remaining live consumer** + +```bash +cd /Users/alexkroman/Code/docs/assemblyai-cli +grep -rln "code_agent" aai_cli/ | grep -v __pycache__ +``` + +Expected: only `aai_cli/AGENTS.md` (a doc, handled in Task 5). If any `.py` under `aai_cli/` still references `code_agent`, stop and re-point it (Task 1 missed something). + +- [ ] **Step 2: Delete the command, the slice, and code-only tests/snapshots** + +```bash +git rm -r aai_cli/commands/code aai_cli/code_agent +git rm tests/test_code_agent.py tests/test_code_command.py tests/test_code_session_stream.py \ + tests/test_code_tui.py tests/test_code_tui_voice.py tests/test_code_tui_voice_switch.py \ + tests/test_code_voice.py +git rm tests/__snapshots__/test_tui_snapshots/test_code_*.raw +``` + +- [ ] **Step 3: Verify command discovery drops `code` and the suite still collects** + +```bash +uv run assembly --help | grep -i "coding agent" ; echo "exit: $?" +uv run python -c "from aai_cli import command_registry; names=[c for r in command_registry.discover() for c in r.spec.commands]; assert 'code' not in names, names; assert 'live' in names; print('ok')" +uv run pytest --collect-only -q 2>&1 | tail -5 +``` + +Expected: the `grep` finds nothing (exit 1 from grep is fine — the "Coding Agent" panel is gone); the discovery assertion prints `ok`; collection reports no import errors. + +- [ ] **Step 4: Commit (WIP)** + +```bash +git add -A +AAI_ALLOW_COMMIT=1 git commit -m "feat(code): remove the assembly code command and its code_agent slice" +``` + +--- + +## Task 3: Remove the `CODE` help panel and regenerate snapshots + +**Files:** +- Modify: `aai_cli/help_panels.py` +- Modify: `tests/_snapshot_surface.py:29` +- Regenerate: `tests/__snapshots__/test_snapshots_help_root.ambr`, `tests/__snapshots__/test_snapshots_help_run.ambr` + +- [ ] **Step 1: Drop the `CODE` panel** + +In `aai_cli/help_panels.py`, delete the `CODE = "Coding Agent" …` line and remove `CODE` from the `PANEL_ORDER` tuple. + +- [ ] **Step 2: Drop the `CODE` entry from the snapshot partition** + +In `tests/_snapshot_surface.py`, delete the line: + +```python + help_panels.CODE: "run", +``` + +(`code` was the panel's only member; `live` stays mapped via `TRANSCRIPTION: "run"`.) + +- [ ] **Step 3: Regenerate the affected `--help` goldens** + +```bash +uv run pytest tests/test_snapshots_help_root.py tests/test_snapshots_help_run.py \ + tests/test_snapshots_help_groups.py --snapshot-update -q +``` + +- [ ] **Step 4: Verify the snapshots and group guard pass cleanly (no update)** + +```bash +uv run pytest tests/test_snapshots_help_root.py tests/test_snapshots_help_run.py \ + tests/test_snapshots_help_groups.py -q +``` + +Expected: pass, with no snapshots reported as updated. Inspect the `git diff` of the `.ambr` files to confirm the only change is the removed `code` command / `Coding Agent` panel. + +- [ ] **Step 5: Commit (WIP)** + +```bash +git add -A +AAI_ALLOW_COMMIT=1 git commit -m "chore(help): drop the Coding Agent panel after removing assembly code" +``` + +--- + +## Task 4: Clean `pyproject.toml`, `.importlinter`, and drop the orphaned dependency + +**Files:** +- Modify: `pyproject.toml` +- Modify: `.importlinter` +- Modify: `uv.lock` (via `uv lock`) + +- [ ] **Step 1: Remove the orphaned dependency** + +In `pyproject.toml`, delete the `"langgraph-checkpoint-sqlite>=3.1.0",` dependency line (only the deleted `code_agent/store.py` used `SqliteSaver`; live uses `InMemorySaver` from langgraph core). Leave `deepagents`, `langgraph`, `langchain-mcp-adapters`, `langchain-firecrawl`, `langchain-openai` — live still uses them. + +- [ ] **Step 2: Re-lock** + +```bash +uv lock +``` + +- [ ] **Step 3: Update mypy module-overrides and ruff per-file-ignores** + +In `pyproject.toml`: +- In the mypy per-module override list, delete `"aai_cli.code_agent.agent"`, `"aai_cli.code_agent.skills"`, `"aai_cli.code_agent.memory"`, `"aai_cli.code_agent.store"` and change `"aai_cli.code_agent.model"` → `"aai_cli.agent_cascade.model"`. +- In `[tool.ruff.lint.per-file-ignores]`, delete the entries for `aai_cli/code_agent/docs_mcp.py`, `aai_cli/code_agent/session.py`, `aai_cli/code_agent/tui.py`, `aai_cli/code_agent/cli_tool.py`, and the `A002` entry for `aai_cli/code_agent/agent.py`. Add `A002` for the `CompiledAgent`-hosting module — append to the existing `aai_cli/agent_cascade/brain.py` ignore (it already appears in the docstring-ignore list): `"aai_cli/agent_cascade/brain.py" = ["A002"]` (merge with any existing key for that file). +- In the docstring-coverage ignore list, remove `"aai_cli/code_agent"` and `"aai_cli/commands/code"` (keep `aai_cli/agent_cascade/brain.py`). +- Update the stale `# assembly code …` comments in the dependency section and the snapshot comment to read `live` / `agent_cascade`. + +In `.importlinter`: remove `aai_cli.code_agent` from the feature-slice independence contract's module list and drop it from the explanatory comment (line ~16). `aai_cli.agent_cascade` is already listed. + +- [ ] **Step 4: Verify the static-analysis gates pass** + +```bash +uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run lint-imports +uv run deptry . +uv lock --check +``` + +Expected: all pass; `deptry` reports no obsolete/missing dependency; `uv lock --check` clean. + +- [ ] **Step 5: Commit (WIP)** + +```bash +git add -A +AAI_ALLOW_COMMIT=1 git commit -m "chore(deps): drop langgraph-checkpoint-sqlite + code_agent lint config" +``` + +--- + +## Task 5: Update docs and run the full gate + +**Files:** +- Modify: `README.md` +- Modify: `aai_cli/AGENTS.md` +- Verify: `REFERENCE.md`, `aai_cli/skills/aai-cli/SKILL.md` (no `assembly code` refs expected — confirm) + +- [ ] **Step 1: Remove the README command-table row** + +In `README.md`, delete the `| `assembly code` | …` table row. Leave the `assembly setup` row and the "Agent-ready" bullet (they describe `setup`, not `code`). + +- [ ] **Step 2: Rewrite the `aai_cli/AGENTS.md` subsystem docs** + +In `aai_cli/AGENTS.md`: delete the `code_agent/` subsystem bullet. In the `agent_cascade/` bullet, replace the phrase that says it reuses `assembly code`'s chrome (`code_agent.banner`/`messages`/`tui_status`/`modals.ApprovalScreen`) with a statement that those modules **now live in `agent_cascade/`**. Remove `code_agent` from the feature-slice list in the layout section. + +- [ ] **Step 3: Confirm no stray `assembly code` references remain in shipped docs/skill** + +```bash +cd /Users/alexkroman/Code/docs/assemblyai-cli +grep -rn "assembly code\|code_agent" README.md REFERENCE.md aai_cli/skills/aai-cli/SKILL.md aai_cli/AGENTS.md +``` + +Expected: no output. Fix any hit (the docs-consistency gate fails on a doc-referenced command that no longer exists). + +- [ ] **Step 4: Run the full authoritative gate** + +```bash +./scripts/check.sh +``` + +Expected: ends with `All checks passed.` Pay attention to: `vulture` (prune any export inside a moved module that is now dead — e.g. a `summarize`/`risk` helper only the old code TUI used), the docstring-coverage gate, the TUI ≥90% coverage floor, `diff-cover` 100% patch coverage, and the diff-scoped mutation gate. Iterate with targeted commands, then re-run `./scripts/check.sh` to completion. + +- [ ] **Step 5: Final commit (gated)** + +Because `check.sh` passed, the commit hook is satisfied — no `AAI_ALLOW_COMMIT` needed: + +```bash +git add -A +git commit -m "docs: drop assembly code from README and architecture guide" +``` + +--- + +## Self-Review notes (for the executor) + +- **Coverage of moved code:** the moved modules (`model`, `firecrawl_search`, `banner`, `tui_status`, `summarize`, `risk`, `messages`, `modals`) keep their relocated tests; if stripping `modals` voice/AskScreen drops coverage below the TUI floor, add keyboard-path assertions rather than re-adding dead code. +- **Mutation gate:** the `risk.py` inlined `FETCH_TOOL_NAME` and the `modals` constructor change are changed lines — make sure a relocated test asserts behavior that depends on them (e.g. a risk-scoring test for `fetch_url`, an `ApprovalScreen` keyboard-decision test), or the surviving mutant fails the gate. +- **Don't touch** `code_gen/`, any `test_code_gen*`, `test_agent_cascade_show_code`, or the `.claude/worktrees/` directory. diff --git a/docs/superpowers/specs/2026-06-22-live-datetime-tool-design.md b/docs/superpowers/specs/2026-06-22-live-datetime-tool-design.md new file mode 100644 index 00000000..1d1ebf2c --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-datetime-tool-design.md @@ -0,0 +1,107 @@ +# Date/time tool for `assembly live` + +**Date:** 2026-06-22 +**Status:** Approved design — ready for implementation plan + +## Goal + +Give the `assembly live` voice agent (the `agent-cascade` command) a keyless, +always-present tool that reports the **current local date and time**, so it can +answer "what time is it?", "what's today's date?", or "what day is it?" — the +kind of thing a live multimodal assistant is expected to know. + +## Context + +`assembly live` answers each spoken turn with a deepagents graph +(`aai_cli/agent_cascade/brain.py`). Built-in tools are added in +`build_live_tools()`; today that is the keyless Open-Meteo weather tool (always +present) plus Firecrawl web search (only when `FIRECRAWL_API_KEY` is set). The +established pattern for a custom live tool is `aai_cli/agent_cascade/weather_tool.py` +/ `webpage_tool.py`: pure, directly-testable helpers plus a single injected seam +(a `Callable`) so the suite needs no real I/O. + +The current date/time is the simplest such tool: **no network, no key**. Its only +non-determinism is the system clock, which is injected as a `Clock` seam so tests +are hermetic (the suite pins `TZ` and forbids unmocked time — see `tests/CLAUDE.md`). + +## Scope + +- **Live-only.** The tool lives in `aai_cli/agent_cascade/` and is bound only in + the live voice agent. +- **Local time only.** Returns the current date and time in the host's local + timezone. No timezone/place argument (YAGNI — chosen explicitly). +- **Always present** (keyless, no I/O), like the weather tool. + +### Out of scope (YAGNI) + +- No timezone or place-name argument ("what time is it in Tokyo?"). +- No date arithmetic ("how many days until…"). +- No configurable format. + +## Architecture + +A new module `aai_cli/agent_cascade/datetime_tool.py`, beside `weather_tool.py`. + +``` +get_current_datetime() ──▶ now() (Clock seam, default _now) ──▶ aware datetime + └──▶ _format(now) ──▶ short spoken string +``` + +`get_current_datetime` takes **no arguments**. + +### Components + +- `DATETIME_TOOL_NAME = "get_current_datetime"` — the registered tool name. + `brain.py` keys its UI label and capability phrase off this, so a test pins it. +- `Clock = Callable[[], datetime]`, default `_now` → `datetime.now().astimezone()` + (a timezone-aware local datetime). **The only seam**; tests inject a fixed + `datetime` so the whole flow is deterministic with no real clock. +- `_format(now: datetime) -> str` — pure → a short, speakable string. Uses only + **cross-platform** `strftime` codes (no `%-d`/`%-I`, which break on Windows where + the suite also runs). Example: `"It's Monday, June 22, 2026 at 02:30 PM PDT."` + (zero-padded day/hour is fine — the model re-speaks it). +- `build_datetime_tool(now: Clock = _now) -> BaseTool` — the + `@tool(DATETIME_TOOL_NAME)` wrapper exposing `get_current_datetime() -> str`. + No try/except: reading the local clock has no failure mode to swallow, so — + unlike weather/read_url — there is **no blind `except Exception`** and therefore + **no `pyproject.toml` `BLE001` per-file-ignore**. + +### Data flow per call + +1. The model calls `get_current_datetime` (no args). +2. `now()` returns the current timezone-aware local datetime. +3. `_format` renders a short, speakable date+time string the model reads aloud. + +## Wiring into `brain.py` + +The three spots a built-in tool touches (mirroring weather): + +- `build_live_tools()` — **always** includes `build_datetime_tool()` (keyless, + no I/O), alongside the weather tool; web search stays key-gated. +- `_tool_capabilities()` — adds *"tell you the current date and time"* when the + tool is present. +- `_TOOL_LABELS[DATETIME_TOOL_NAME] = "Checking the time"` for the live-UI + affordance. + +## Error handling + +None needed. The tool performs no I/O and the clock call cannot fail in normal +operation, so it returns a value unconditionally — no apology path, no blind +except, no lint exemption. + +## Testing + +Hermetic via the injected `Clock`; targets 100% patch coverage + the diff-scoped +mutation gate (assertions must *fail* if a changed line breaks). + +- `_format` tested directly against a fixed, timezone-aware `datetime` → the + EXACT expected string (kills format mutations). +- The tool driven end-to-end with an injected fixed clock → the EXACT string. +- `_now()` returns a `datetime` that is timezone-aware (`tzinfo is not None`), + covering the default seam. +- `brain` wiring: + - `build_live_tools()` includes a tool named `DATETIME_TOOL_NAME` (update the + existing EXACT-set `build_live_tools` assertions — both the keyed and the + no-firecrawl-key tests — to include it). + - `build_system_prompt`/`_tool_capabilities` advertises the date/time phrase. + - `_tool_label(DATETIME_TOOL_NAME) == "Checking the time"` (exact string). diff --git a/docs/superpowers/specs/2026-06-22-live-echo-barge-in-guard-design.md b/docs/superpowers/specs/2026-06-22-live-echo-barge-in-guard-design.md new file mode 100644 index 00000000..1846c33c --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-echo-barge-in-guard-design.md @@ -0,0 +1,242 @@ +# Live voice agent: echo guard against the agent's own TTS (half-duplex) + +Date: 2026-06-22 +Branch: `live-tool-call-ux` +Status: design (awaiting review) + +## Problem + +`assembly live` (the `agent_cascade`) runs mic + speaker through a single +full-duplex PortAudio stream (`DuplexAudio`, `aai_cli/agent/audio.py`). The mic +stays open while the agent speaks, and `CascadeSession.on_turn` barges in on +**any** non-empty interim transcript. On laptop speakers the mic hears the +agent's own TTS, STT transcribes it, and the agent **interrupts itself** — it +cuts its reply off mid-sentence as if the user spoke. + +The team already knows this: `_exec._open_audio` prints + +> "Use headphones — the mic stays open while the agent speaks, so speakers would +> let it hear itself." + +That headphones warning is the *current* mitigation. This spec replaces it with +a real guard so speakers work. + +We confirmed OpenClaw does **not** implement acoustic echo cancellation (AEC): +its browser path delegates `echoCancellation` to the OS via `getUserMedia` +constraints, and its native realtime path leans on server VAD plus a +`minBargeInAudioEndMs` debounce. Our raw `sd.RawStream` gets neither for free, +so AEC is not prior art to copy — a half-duplex gate is the robust option. + +## Goal + +While the agent is producing audio, feed **silence** to STT so it never +transcribes the agent's own voice and can't trigger a self-barge-in. Re-open the +mic the moment the agent's audio finishes draining. + +Non-goals / accepted tradeoff: + +- **Voice "talk-over" barge-in is disabled while the agent speaks.** The user + cannot interrupt by voice mid-reply; they interrupt with the existing UI + control (Escape / Ctrl-C → `interrupt_reply`), which flushes playback, drains + the buffer, and re-opens the mic so voice resumes immediately. This is the + explicit, chosen consequence of half-duplex. +- No AEC, no DSP, no platform-specific audio APIs. +- No CLI flag / config field — on by default (v1). + +## Design decisions (from brainstorming) + +| Decision | Choice | +| --- | --- | +| Strategy | **Half-duplex mic gate during playback.** Mute capture (feed silence to STT) for the whole speaking phase and until the playback buffer drains. | +| Configurable? | **No.** Always-on in v1. A `--full-duplex` toggle for headphone users who want talk-over barge-in can come later. | +| Clock / timing | **None.** Gate on engine speaking-phase state + buffer-non-empty, so it's deterministic and clock-free (no flaky tail timer). | + +## Why half-duplex, and why scoped to the cascade + +`DuplexAudio` is shared with `assembly agent` (the Voice Agent endpoint path, +`agent/session.py`). The guard must not change that path. It won't, because the +guard is **driven by the consumer**: only `engine.run_cascade` calls the new +"output active" hooks. `assembly agent` runs `run_session`, never touches them, +so its behavior is unchanged with no opt-in flag needed on `DuplexAudio`. + +## Components + +### 1. `aai_cli/agent/audio.py` — a second, independent mic gate + +`DuplexAudio` already has one mic gate: `_listening` (the user's Space-to-mute). +Add a **second, independent** gate for the echo guard so the two never clobber +each other: + +- New `self._output_active: threading.Event` (clear by default), with + `set_output_active(on: bool)`. +- `capture_frames` feeds silence to STT when **any** of these hold (compose with + the existing `_listening` check): + + ```text + mute if (not self._listening.is_set()) # user muted (existing) + or self._output_active.is_set() # engine: speaking phase + or len(self._out) > 0 # NEW: playback buffer draining + ``` + + - `_output_active` covers the **whole** speaking phase, including the silent + inter-clause gaps while the next clause is still synthesizing (so the mic + doesn't flicker open between sentences). + - `len(self._out) > 0` (the same quantity `pending()` exposes) covers the + **drain tail** after the engine clears `_output_active`: the last clause's + audio is still in the buffer, so the mic stays muted until it empties — no + clock needed. + - Existing `_listening` keeps the user's manual mute authoritative and + composes (mic is live only when listening **and** no output activity). + + The silence is produced exactly as the existing muted path does + (`chunk = bytes(len(chunk))` before resample), so the STT socket stays alive + and reconnect-free — a proven path (it's how Space-to-mute already works). + +`set_output_active` flips an `Event`, so it's safe to call from the engine / +reply-worker threads while `capture_frames` reads it on the capture thread (same +pattern as `_listening`). + +### 2. `engine.py` Player protocol — `begin_output()` / `end_output()` + +The engine's only handle to the duplex device is the `Player` protocol (it +receives `duplex.player`; the mic is hidden inside `deps.run_stt`'s audio +iterable). Extend the protocol: + +- `begin_output()` — called when audible output starts. +- `end_output()` — called when the engine has finished enqueuing the last frame + of the speaking phase. + +Implementations: + +- `_DuplexPlayer` (audio.py) delegates to its `DuplexAudio.set_output_active` + (`begin_output` → `set_output_active(on=True)`, `end_output` → + `set_output_active(on=False)`). +- `NullPlayer` (file-driven / headless) — no-ops (no live mic; `pending()` is + always 0, so the drain term is moot there too). +- The cascade test fake player gains the two no-op/recording methods. + +### 3. `engine.py` `CascadeSession` — drive the hooks + +Mute around every audible phase, leaving the drain term to cover the tail: + +- **Greeting** (`greet`): `player.begin_output()` before `synthesize(greeting, + …)`, `player.end_output()` after it returns. The greeting audio keeps the mic + muted while it drains via the buffer term. +- **Reply** (`_consume` / `_generate_reply`): call `begin_output()` at the point + `started` flips True (the first audible clause — same place `reply_started()` + fires), and `end_output()` in the reply's teardown alongside + `_speaking.clear()` / `reply_done(...)`, so it runs on every exit path (clean + finish, barge-in, TTS/leg failure, timeout). +- **Thinking / tool calls produce no audio**, so the mic stays open during them + — the user can still speak while the agent thinks or runs a tool (no echo to + guard against there). Muting is strictly tied to *audible output*. + +`on_turn` is **unchanged**: with the mic fed silence during playback, STT simply +emits no interim/final turns then, so the existing barge-in code never fires on +echo. The fix is localized to the audio layer plus the two engine hooks. + +### 4. `_exec.py` — relax the headphones warning + +`_open_audio`'s notice (lines 168-171) becomes accurate for the new behavior, +e.g.: + +> "Speakers are fine — the mic mutes while the agent speaks. To interrupt it, +> press Esc (talking over it won't cut in)." + +Update any test asserting the old copy. The voice-only TUI's listen indicator +(driven by the user's `toggle_listening`) is unaffected — the echo-guard gate is +a separate `Event`, invisible to the manual mute state. + +## Data flow + +``` +greet(): begin_output() → synthesize(greeting) → end_output() + └─ mic muted (output_active) … then muted while _out drains … reopens + +reply turn: + thinking / tool: mic OPEN (no audio; user may speak) + first clause: begin_output() + reply_started() + clauses stream: synthesize → player.enqueue; mic muted (output_active) across inter-clause gaps + last clause done: end_output(); mic stays muted while _out drains, then reopens + barge-in (Esc): interrupt_reply → flush() (_out cleared) ; worker teardown → end_output() + └─ _out empty + output_active cleared → mic reopens at once → voice resumes +``` + +## Interruption semantics (the tradeoff, stated plainly) + +- **During playback:** voice over-talk does nothing (STT hears silence). Esc / + Ctrl-C (`interrupt_reply`) cuts the agent off; it flushes the buffer and clears + output-active, so the mic reopens immediately and the user can speak. +- **After playback drains:** normal turn handling resumes; the next spoken turn + is detected and answered as today. +- A spoken barge-in that arrives in the gap *after* the agent finishes (mic + already reopened) works exactly as before. + +## Error handling + +- `begin_output`/`end_output` are pure state flips; they can't fail. If the + reply leg raises (TTS/timeout), `end_output()` still runs in teardown, so the + mic is never left stuck muted after an error. +- File-driven runs (`NullPlayer`) and a future non-duplex player are unaffected + (no-op hooks, `pending()==0`). + +## Testing + +`DuplexAudio` already injects `stream_factory`/`rate_query`/`poll_timeout` for +hermetic tests; no real device needed. The cascade is tested through +`CascadeDeps` fakes. + +Audio-layer (`test` for `DuplexAudio`): + +1. **Muted while output active.** `set_output_active(True)`; push a captured + chunk through the injected callback; assert `capture_frames` yields zeroed + PCM. (Kills a mutant dropping the `_output_active` term.) +2. **Muted while buffer draining.** `feed()` some audio so `len(_out)>0` with + `_output_active` clear; assert capture is silenced until the buffer empties, + then real audio resumes. (Kills a mutant dropping the `len(_out)>0` term.) +3. **User mute composes.** `set_listening(off)` alone still mutes; clearing + output-active does not un-mute a user-muted mic. (Kills a mutant that ORs the + gates wrong, e.g. replaces `or` with `and`.) +4. **Open by default.** With everything clear and buffer empty, capture yields + the real (resampled) audio. (Guards against an always-muted regression.) + +Engine-layer (`test_agent_cascade_engine.py`, fake player records calls): + +5. **Greeting brackets output.** Assert `begin_output` is called before the + greeting synth and `end_output` after. +6. **Reply brackets output; thinking does not.** A turn with a `ToolNotice` + then `SpeechDelta`s: assert `begin_output` fires only when the first clause + speaks (not during the tool/thinking phase) and `end_output` fires in + teardown. +7. **`end_output` on every exit.** Barge-in, TTS failure, and timeout paths each + still call `end_output` (mic never stuck muted). (Kills a mutant that puts + `end_output` on only the happy path.) + +Per the repo gate: each changed line needs an assertion that *fails* if the line +breaks (mutation gate), and the diff needs 100% patch coverage. + +## Risks / open questions + +- **Sub-100 ms acoustic tail.** Gating on buffer-non-empty covers the digital + buffer but not the speaker's physical decay / room reverb in the ~one-blocksize + window right after `_out` empties. A captured chunk straddling that boundary + could carry faint echo. In practice STT won't form a turn from a sub-100 ms + fragment, so v1 omits a timed tail to stay clock-free; if field testing shows + boundary self-interrupts, add a short injectable tail (OpenClaw-style + watchdog) as a follow-up. +- **Headphone users lose talk-over barge-in unnecessarily.** They have no echo, + so full-duplex would be safe for them. v1 is uniformly half-duplex; a + `--full-duplex` opt-out is the natural follow-up (deferred per the config + decision). +- **Warning copy** is a wording decision to settle during implementation; it + must stay terse and period-less only if it's option/summary help (this is a + runtime notice, so normal punctuation is fine). + +## Out of scope + +- Acoustic echo cancellation / OS audio-processing APIs. +- A `--full-duplex` / `--no-echo-guard` toggle. +- Text-match echo heuristics to preserve talk-over barge-in. +- The spoken tool-call filler (separate spec) — note the filler is audible + output too, so it is correctly bracketed by `begin_output`/`end_output` once + both ship. diff --git a/docs/superpowers/specs/2026-06-22-live-file-readwrite-design.md b/docs/superpowers/specs/2026-06-22-live-file-readwrite-design.md new file mode 100644 index 00000000..fba5255b --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-file-readwrite-design.md @@ -0,0 +1,198 @@ +# `assembly live` — file read/write in the launch directory + +**Date:** 2026-06-22 +**Status:** Design approved, pending spec review + +## Summary + +Give the `assembly live` voice agent the ability to **read and write files in the +directory it is launched in**, opt-in and behind a confirmation gate for writes. +The capability reuses the filesystem plumbing already proven in `assembly code` +(deepagents' filesystem backend + the interrupt/resume approval loop), so `live` +gains files — not a shell — with minimal new surface area. + +## Motivation + +`assembly live` is the client-orchestrated voice agent (`agent_cascade`): Streaming +STT → a deepagents brain on the LLM Gateway → streaming TTS. Today its toolset is +deliberately tiny and **read-only** (Firecrawl web search when keyed, plus opt-in +read-only MCP tools), because a spoken turn cannot pause for a keyboard +confirmation. Users want the agent to act on local files during a conversation +("read me notes.txt", "save that summary to summary.md") without leaving the voice +session. + +## Decisions (locked during brainstorming) + +1. **Opt-in, not default.** A new flag enables the capability; default behavior is + unchanged (tool-free / web-search-only). Mirrors the strictly-opt-in posture of + `--mcp-config`. +2. **Reads free, writes confirmed.** Read tools auto-approve; `write_file` / + `edit_file` require explicit confirmation. +3. **Confirmation is a TUI keypress (y/n).** A pending write pauses the turn and the + voice TUI shows the target path with a `y/n` prompt. Robust and unambiguous; + reuses `assembly code`'s interrupt/resume `Approver`. (Spoken yes/no was + considered and rejected as fragile and a larger change to the turn flow.) +4. **Files, not a shell.** Use `FilesystemBackend` (read/write/edit/ls/glob/grep), + **not** `LocalShellBackend`. deepagents' filesystem middleware *always* binds an + `execute` tool, but with a non-sandbox backend (`FilesystemBackend`) `execute` is + **inert** — it returns "provide a backend that implements SandboxBackendProtocol" + and physically cannot run a shell command. So "files, not a shell" holds: we do + not use a sandbox backend, we do not advertise `execute` in the system prompt, and + we do not gate it (an inert tool needs no gate). This matches today's behavior — + the current live graph already binds an inert `execute`. **Search/`grep` is a + required capability** and is one of the backend's built-in tools, so it comes for + free (ungated, like the other reads). +5. **Rooted at the launch directory (cwd)**, with `virtual_mode=True` blocking + traversal escapes — identical containment to `assembly code`. + +### Open choices to confirm at spec review + +- **Flag name:** proposed `--files` (boolean). Alternatives: `--workdir`, + `--allow-files`. The root is always cwd for now (no path argument — YAGNI). +- **Read-tool gating:** reads ungated (`read_file` / `ls` / `glob` / `grep` + auto-approve — including content search via `grep`). Only `write_file` / + `edit_file` are confirmed. + +## Architecture + +### Toolset — what actually changes + +A key fact discovered during design: `create_deep_agent` **always** installs +deepagents' filesystem middleware, so the **current** live graph already binds +`ls`/`read_file`/`write_file`/`edit_file`/`glob`/`grep` (+ `write_todos`/`task`/inert +`execute`). Today these run against deepagents' default *in-memory* backend, so file +ops touch ephemeral graph state — **not** the launch directory — and the system +prompt never advertises them. They are harmless and unused. + +So the feature is **not** "add file tools." It is three focused changes, gated on the +new flag: + +1. **Point the backend at the real cwd.** `aai_cli/agent_cascade/brain.py::build_graph` + passes `FilesystemBackend(root_dir=str(Path.cwd()), virtual_mode=True)` (from + `deepagents.backends`) instead of relying on the default in-memory backend. Now + `read_file`/`write_file`/`edit_file`/`grep`/… operate on the launch directory. + `virtual_mode=True` maps the model's `/`-rooted paths under cwd and blocks + traversal escapes — identical containment to `assembly code`'s + `LocalShellBackend`. +2. **Gate writes** (below) — because they now touch real disk. +3. **Advertise the capability** in the system prompt (below). + +`execute` stays bound but inert (no sandbox backend); it is neither advertised nor +gated. When the flag is **off**, `build_graph` is unchanged from today (default +in-memory backend, no gating, nothing advertised). + +### Approval (reuse `assembly code`'s interrupt/resume) + +When files are enabled, `build_graph`: + +- sets `interrupt_on={"write_file": True, "edit_file": True}` (reads are **not** + gated), and +- attaches an `InMemorySaver` checkpointer (interrupt/resume requires one) plus a + stable `thread_id` in the per-invoke config. + +The brain's completer (`build_completer` / `_run_graph`) gains an +interrupt-resolution loop modeled on `aai_cli/code_agent/session.py::_resolve_interrupts`: +on a write interrupt it calls an injected `Approver(name, args) -> bool` and resumes +the graph with an approve/reject `Command(resume=...)`, looping until the turn no +longer pauses. The `Approver` type and the resume-decision shape are lifted from the +code agent. + +When files are **disabled**, none of this is wired — `build_graph` behaves exactly +as today (no backend, no checkpointer, no interrupt_on). + +### Confirmation channel (front-end supplies the `Approver`) + +The `Approver` is injected from the front-end through `CascadeDeps`, so the engine +and brain stay testable against plain functions. + +- **Voice TUI (`LiveAgentApp`)** — interactive mic, human mode. A pending write + pauses the reply turn; the TUI surfaces the target path and a `y/n` prompt (a + small approval line/modal — the TUI already owns the keyboard via its `BINDINGS`). + The reply worker thread blocks on a `threading.Event` that the UI thread sets on + keypress, then resumes the graph — the same block-the-worker pattern the code + agent's TUI approver uses. +- **Plain / headless renderer** — file/URL input, `--json`, `-o text`, or non-TTY + (where `_should_use_tui` is false). No keyboard channel, so the approver + **auto-denies** writes (reads still work). The declined write is surfaced inline + so the turn explains itself rather than silently doing nothing. + +### Capability advertisement (system prompt) + +`brain.build_system_prompt` / `_tool_capabilities` already tailor the prompt to the +bound tools (so the agent never promises a capability it lacks). When the filesystem +tools are bound, add a phrase like "read and write files in your working directory" +to the capability clause. The existing `_SPOKEN_TAIL` still applies — replies stay +short, spoken, and markdown-free even though the agent can now write files. Tool +labels (`_TOOL_LABELS`, shown as the live "…" affordance) get speakable entries: +"Reading a file", "Writing a file", "Editing a file", "Listing files", +"Searching files". + +## Data flow (a write turn, TUI) + +1. User speaks → STT finalizes a turn → `CascadeSession.on_turn` starts a reply. +2. The reply worker drives the deepagents graph. The model calls `write_file`. +3. The graph **interrupts** (write is in `interrupt_on`). The completer's resolution + loop calls the injected `Approver` with `("write_file", {path, content, …})`. +4. The TUI approver hops to the UI thread, shows the path + `y/n`, and blocks the + worker on an `Event` until a keypress sets approve/reject. +5. The completer resumes the graph with the decision. On approve the file is written + under cwd; on reject the model is told the user declined (the code agent's + `_DECLINED` message pattern). +6. The graph finishes; the spoken reply streams out through TTS as usual. + +## Error handling + +- **Reply timeout vs. human think time.** The reply worker runs the graph under a + 60s wall-clock backstop (`_REPLY_TIMEOUT_SECONDS` in `engine.py`). Time spent + awaiting human approval must **not** count against that deadline, or a slow + keypress would cut off the write mid-turn. The design excludes approval-wait time + from the reply timeout (pause/restart the clock around the approval round-trip, or + restructure so the approval wait is not under the timed call). +- **Containment.** `virtual_mode=True` rejects paths that escape cwd; such a tool + call fails inside the graph and is surfaced like any other tool error (the existing + `brain._run_graph` wraps graph/tool failures as a `CLIError` shown in the + transcript). +- **Headless writes.** Auto-denied (above) — never a silent no-op. + +## Out of scope / minimal touch + +- **No shell.** `execute` is never bound; `FilesystemBackend` only. +- **No access outside cwd.** No path argument; root is always the launch directory. +- **Default unchanged.** Without the flag, `live` is exactly as today. +- **`--show-code`.** Verify whether the generated SDK snippet models the brain's + tools at all. If it does not (it likely renders the STT/LLM/TTS cascade, not the + deepagents toolset), the flag is reflected minimally or not at all — confirmed + during implementation. + +## Testing + +All against fakes — no mic, socket, or real disk-escape. + +- **Brain (`tests/test_agent_cascade_*`):** + - With the flag on, `build_graph` constructs a real-cwd `FilesystemBackend` + (`root_dir == str(Path.cwd())`, `virtual_mode=True`); with the flag off it does + not (default in-memory backend, as today). Assert by injecting/patching the + backend factory seam rather than introspecting langgraph internals. + - `FilesystemBackend` is constructed rooted at cwd with `virtual_mode=True`. + - A write interrupt invokes the `Approver`; resume with approve runs the write, + resume with reject relays the decline and does not write. + - The system prompt advertises file read/write only when the tools are bound. +- **Engine (`tests/test_agent_cascade_engine.py`):** + - The `Approver` is threaded through `CascadeDeps` to the completer. + - The reply timeout excludes approval-wait time. + - The headless/plain renderer's approver auto-denies writes. +- **TUI (`tests/test_live_tui.py` + snapshots):** + - Snapshot of the approval prompt (path + `y/n`). + - A `y` keypress approves and `n` rejects, driving the injected approver `Event`. + +## Affected files (anticipated) + +- `aai_cli/agent_cascade/brain.py` — backend, interrupt_on, checkpointer, approval + resolution loop, capability phrase, tool labels. +- `aai_cli/agent_cascade/engine.py` — `Approver` on `CascadeDeps`; timeout vs. + approval-wait handling. +- `aai_cli/agent_cascade/config.py` — config knob for the enabled flag (+ cwd root). +- `aai_cli/agent_cascade/tui.py` — TUI approval prompt + keypress → `Event` approver. +- `aai_cli/commands/agent_cascade/__init__.py` + `_exec.py` — the new flag → options + → config; wire the plain renderer's auto-deny approver. +- Tests + `--help` / TUI snapshots regenerated. diff --git a/docs/superpowers/specs/2026-06-22-live-keyless-tools-design.md b/docs/superpowers/specs/2026-06-22-live-keyless-tools-design.md new file mode 100644 index 00000000..75bbe3b5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-keyless-tools-design.md @@ -0,0 +1,378 @@ +# Eight keyless tools for `assembly live` + +**Date:** 2026-06-22 +**Status:** Approved design — ready for implementation plan + +## Goal + +Broaden what the `assembly live` voice agent (the `agent-cascade` command) can +do for everyday spoken requests by adding eight new tools. All eight are +**always bound** (none needs an API key): they use keyless public APIs, offline +local computation, or bundled offline-data libraries. Each returns output short +enough to read aloud, extending the existing weather / read-url / datetime trio +toward "talk to a multimodal assistant" parity — with no API-key setup for the +user. + +The eight tools: + +1. `look_up_topic` — Wikipedia REST summary ("who is…", "what is…", "tell me about…"). +2. `calculate` — safe arithmetic + curated math/statistics functions via `simpleeval` ("15% of 240", "square root of 150", "standard deviation of these numbers"). +3. `convert_units` — physical units (via `pint`) + currency (via keyless frankfurter.app). +4. `define_word` — dictionary definition + synonyms (dictionaryapi.dev, keyless). +5. `get_time_in` — current local time in a named place (Open-Meteo geocode → `zoneinfo`). +6. `date_math` — date arithmetic via `python-dateutil` ("days until Christmas", "what weekday is July 4"). +7. `check_holiday` — public-holiday lookup via the `holidays` library ("is Monday a holiday", "next US holiday"). +8. `sun_times` — sunrise/sunset + moon phase via `astral`, reusing the shared geocoder. + +## Context + +`assembly live` answers each spoken turn with a deepagents graph +(`aai_cli/agent_cascade/brain.py`). Its built-in tools today are `get_weather`, +`read_url`, and `get_current_datetime` (all keyless, always present) plus +`firecrawl_search` (bound only when `FIRECRAWL_API_KEY` is set). The only path +to "tell me about X" today is the *keyed* Firecrawl search; `look_up_topic` +closes that gap keylessly. + +Tools are LangChain `BaseTool`s. The established pattern for a custom tool is +`aai_cli/agent_cascade/weather_tool.py` (and `datetime_tool.py`, +`webpage_tool.py`): pure, directly-testable helpers plus at most one thin seam +(a `Callable`) injected in tests so the suite needs no sockets/clock, and a tool +body that **never raises** — it catches its own failures and returns a short +spoken apology so a single tool outage can't sink a live turn. + +## Scope + +- **Live-only.** All eight modules live in `aai_cli/agent_cascade/` and are bound + only in the live voice agent. The coding agent's toolset is unchanged. +- **Keyless-first.** `look_up_topic`, `define_word`, `get_time_in`, and + `sun_times` use keyless public APIs (the last two geocode via the shared + `geocode.py`); `calculate`, `date_math`, and `check_holiday` need no network + (offline libraries); `convert_units` uses keyless frankfurter.app for currency + and the bundled `pint` library for physical units. No new environment + variables. +- **Speakable output.** Each tool returns one short string suitable for TTS. + +### Out of scope (YAGNI) + +- No per-tool opt-out flags — the tools are read-only and cheap; they are always + bound (no key gate, since none needs a key). +- No disambiguation UI anywhere — `look_up_topic` and the geocoding tools + (`get_time_in`, `sun_times`, `convert_units`'s sibling `get_weather`) take the + top match; ambiguity is handled in the spoken reply, not a prompt. +- No locale/units configuration — `convert_units` converts exactly the units the + model names; `calculate` returns a plainly-formatted number. + +## Dependencies (separate PR) + +Five new dependencies back this feature, all pure-Python and offline (no key, no +service): + +- [`pint`](https://pint.readthedocs.io/) — physical-unit conversion (`convert_units`). +- [`simpleeval`](https://github.com/danthedeckie/simpleeval) — safe arithmetic-expression evaluation (`calculate`). +- [`python-dateutil`](https://dateutil.readthedocs.io/) — date arithmetic (`date_math`). +- [`holidays`](https://github.com/vacanza/holidays) — offline public-holiday data (`check_holiday`). +- [`astral`](https://astral.readthedocs.io/) — sunrise/sunset + moon phase computation (`sun_times`). + +Per the repo rule that dependency/`uv.lock` changes ship in their own +single-purpose PR, all five are added in **PR-A** (dependencies only — one +logical "add the libraries the new tools need" change), and the feature **PR-B** +lands on top of it. + +- Add all five to `[project.dependencies]` in `pyproject.toml` + regenerate + `uv.lock`. **Declare each directly even if already present transitively** + (`python-dateutil` very likely is) — `deptry` flags using a transitive + dependency directly. +- Heed the safe-chain version-floor caveat: pin each floor to the second-newest + release, or resolution fails under the age gate. +- Each is imported **lazily** inside its tool factory (keeping the import off + CLI startup) — matching `webpage_tool`'s lazy `core.webpage` import. + +## Shared component: `geocode.py` (refactor) + +Both `weather_tool` and `get_time_in` resolve a spoken place name to +coordinates via Open-Meteo's keyless geocoding endpoint, and `get_time_in` +additionally needs the IANA `timezone` field that endpoint already returns. + +A new module `aai_cli/agent_cascade/geocode.py` factors this out: + +- `Fetcher = Callable[[str], object]` — GETs a URL → parsed JSON (the existing + weather-tool seam shape). +- `GeoResult` — `(name: str, latitude: float, longitude: float, timezone: str)`. +- `geocode(name, *, fetch) -> GeoResult | None` — query + `https://geocoding-api.open-meteo.com/v1/search?name=&count=1&language=en&format=json`, + return the top match (now including `timezone`), or `None` when there is no + match. + +`weather_tool._geocode` is refactored to delegate to `geocode.geocode` (it +ignores the extra `timezone` field). This keeps one geocoding implementation and +gives `get_time_in` the timezone it needs. The weather tool's public behavior +and its `format_report`/`describe_weather_code` helpers are unchanged; only the +internal geocode call moves. + +## The five tools + +Each is a new module beside `weather_tool.py`. All expose exactly two public +names: the `*_TOOL_NAME` constant and the `build_*_tool(...)` factory. + +### 1. `topic_tool.py` — `look_up_topic` + +- `LOOKUP_TOOL_NAME = "look_up_topic"`. +- Seam: `Fetcher` (the weather-tool shape). Default `_get_json` uses `httpx`. +- Endpoint: `https://en.wikipedia.org/api/rest_v1/page/summary/` with the + title URL-encoded. The response carries `extract` (a clean ~1-paragraph plain + text purpose-built to read aloud), `title`, and `type`. +- `build_lookup_tool(fetch=_get_json)` exposes + `look_up_topic(topic: str) -> str`: fetch, return the `extract`, capped to a + speakable length (`_MAX_CHARS`, `# pragma: no mutate` — a tuning knob). +- Failure → apology: + - `type == "disambiguation"` or empty `extract` → *"I couldn't find a clear + summary for '<topic>'."* + - 404 / HTTP / network error (the `fetch` seam raises) → *"I couldn't look + that up right now."* + +### 2. `calc_tool.py` — `calculate` + +- `CALC_TOOL_NAME = "calculate"`. +- **No seam — fully deterministic and offline** (the only tool with no + non-determinism, so no injected callable). +- Evaluates with **`simpleeval`** (lazily imported), seeded with a **curated + whitelist** of pure functions and constants so the agent can "run code to get + an answer" without any shell. The `SimpleEval` instance is built with an + explicit `functions` table and `names` table (which *replace* simpleeval's + defaults, dropping `rand`/`randint` and leaving no variables): + - **functions:** `abs`, `round`, `min`, `max`, `sum` (builtins); `sqrt`, + `floor`, `ceil`, `log`, `log10`, `hypot`, `gcd` (`math`); `mean`, `median`, + `stdev` (`statistics`). + - **constants (`names`):** `pi`, `e`. +- **Resource-exhaustion guarantee preserved.** `simpleeval` bounds the `**` + operator via `MAX_POWER`, but it does **not** bound function arguments — so + the whitelist deliberately **excludes** anything that can explode from small + inputs (`factorial`, `pow`, `perm`, `comb`). Everything exposed is O(n) on its + input and can't grow a giant number from a small one, so there is no + unguarded-exponentiation backdoor. (Trig and a few others are easy to add + later; the set is intentionally tight to bound the test surface.) +- `build_calc_tool()` exposes `calculate(expression: str) -> str`. The model + turns the spoken question into an expression over that vocabulary; **the + tool's docstring tells it how** (see below). The tool only evaluates + formats. +- **Tool docstring (the model-facing usage guidance):** the `@tool` docstring + lists the allowed operators (`+ - * / // % **` and parentheses), the constants + (`pi`, `e`), and the functions by name, notes that aggregate functions take a + list (`mean([2,4,9])`), and gives worked examples — *"15% of 240" → `0.15 * + 240`*, *"square root of 150" → `sqrt(150)`*, *"standard deviation of 2, 4, 4, + 4, 5" → `stdev([2,4,4,4,5])`*, *"split 87 three ways" → `87 / 3`*. The usage + contract lives in the tool definition, not in `brain.py`'s prompt. +- **Output formatting (the real fiddly part):** render the result so it reads + aloud cleanly — integers print without a decimal (`36`, not `36.0`), and + non-integers are rounded to a sensible precision so float artifacts never leak + (`87 / 3` reads as `29` after rounding, not `28.999999999999996`). This + rounding requirement is explicit because it, not parsing safety, is the + tool's genuine risk. +- Failure → apology: any `simpleeval` error (invalid syntax, a disallowed + name/call, an over-`MAX_POWER` exponent) or `ZeroDivisionError` → *"I couldn't + compute that."* + +### 3. `units_tool.py` — `convert_units` + +- `CONVERT_TOOL_NAME = "convert_units"`. +- Signature: `convert_units(value: float, from_unit: str, to_unit: str) -> str`. +- Seam: `Fetcher` — **used only on the currency path**; the `pint` path is + deterministic. +- Path selection: a module-level set of ISO-4217 currency codes. If both + `from_unit` and `to_unit` are currency codes → currency path; otherwise → + physical-unit path. + - **Currency:** `https://api.frankfurter.app/latest?amount=<value>&from=<F>&to=<T>` + (keyless). Read `rates[<T>]`, format e.g. *"100 USD is 92.4 EUR."* + - **Physical units:** lazily `import pint`, then + `ureg.Quantity(value, from_unit).to(to_unit)`, format the magnitude + unit, + e.g. *"5 miles is 8.05 kilometers."* / *"350 °F is 176.67 °C."* +- Failure → apology: + - `pint` `UndefinedUnitError` / `DimensionalityError` (incompatible or unknown + units) → *"I couldn't convert those units."* + - currency fetch error / unknown code in `rates` → *"I couldn't get that + exchange rate right now."* + +### 4. `define_tool.py` — `define_word` + +- `DEFINE_TOOL_NAME = "define_word"`. +- Seam: `Fetcher`. Endpoint: + `https://api.dictionaryapi.dev/api/v2/entries/en/<word>` — a JSON **array** of + entries on success; a JSON **object** (with a "No Definitions Found" title) on + a miss. +- `build_define_tool(fetch=_get_json)` exposes `define_word(word: str) -> str`: + take the first entry's first meaning — part of speech + first definition — and + append up to a couple of synonyms when present, e.g. *"ephemeral (adjective): + lasting a very short time. Synonyms: transient, fleeting."* +- Failure → apology: + - Response is not a non-empty array (word not found) → *"I couldn't find a + definition for '<word>'."* + - HTTP / network error → *"I couldn't look up that word right now."* + +### 5. `worldclock_tool.py` — `get_time_in` + +- `WORLDCLOCK_TOOL_NAME = "get_time_in"`. +- Seams: `Fetcher` (geocoding, via `geocode.geocode`) **and** `Clock` + (`() -> datetime`, the `datetime_tool` shape) — the current instant, injected + in tests. +- `build_worldclock_tool(fetch=…, now=…)` exposes `get_time_in(place: str) -> str`: + geocode the place → its IANA `timezone` → convert `now()` into that zone with + `zoneinfo.ZoneInfo(tz)` → format with the same cross-platform `strftime` codes + `datetime_tool` uses, e.g. *"In Tokyo it's Monday, June 22, 2026 at 11:30 PM + JST."* +- Failure → apology: + - No geocoding match → *"I couldn't find a place called '<place>'."* + - Bad/unknown timezone or fetch error → *"I couldn't get the time there right + now."* + +### 6. `datemath_tool.py` — `date_math` + +- `DATEMATH_TOOL_NAME = "date_math"`. +- Seam: `Clock` (`() -> datetime`, the `datetime_tool` shape) — supplies "today" + so signed "from now" deltas are computable; injected in tests. `python-dateutil` + is imported lazily. +- Signature: `date_math(date: str, other_date: str | None = None) -> str`, dates + as ISO `YYYY-MM-DD`. +- **Division of labor (in the tool docstring):** the model is good at *knowing* + calendar facts and bad at *counting* across them, so the docstring instructs + it to work out the relevant date(s) itself and pass them as ISO strings; the + tool does the exact day-counting and weekday. Worked examples in the docstring: + *"days until Christmas" → `date_math("2026-12-25")`*, *"what weekday is July + 4th" → `date_math("2026-07-04")`*, *"days between March 1 and August 25" → + `date_math("2026-03-01", "2026-08-25")`*. +- Behavior: + - One date → its weekday plus a signed distance from today via the `Clock`, + e.g. *"July 4, 2026 is a Saturday — 12 days from now."* / *"…— 8 days ago."* + / *"…— that's today."* + - Two dates → the total days between plus a human breakdown via + `dateutil.relativedelta`, e.g. *"There are 177 days between March 1 and + August 25, 2026 — about 5 months and 3 weeks."* +- Failure → apology: an unparseable date (`ValueError` from `dateutil.parser`) → + *"I couldn't work out those dates."* + +### 7. `holiday_tool.py` — `check_holiday` + +- `HOLIDAY_TOOL_NAME = "check_holiday"`. +- Seam: `Clock` — supplies "today" for the next-holiday mode; injected in tests. + The `holidays` library is offline data, imported lazily. +- Signature: `check_holiday(country: str = "US", date: str | None = None) -> str`. + `country` is an ISO-3166 alpha-2 code (the `holidays` library's key, e.g. + `US`, `GB`, `DE`); the docstring says so and notes it defaults to the US when + the user names no country. +- Behavior: + - With a date → name the holiday on it, or say there isn't one, e.g. + *"December 25, 2026 is Christmas Day in the US."* / *"March 3, 2026 is not a + public holiday in the US."* + - Without a date → the next upcoming public holiday from today (via the + `Clock`), e.g. *"The next US public holiday is Independence Day on July 4, + 2026 — in 12 days."* +- Failure → apology: + - Unknown country code (`holidays` raises `NotImplementedError`) → *"I don't + have holiday data for that country."* + - Unparseable date → *"I couldn't work out that date."* + +### 8. `suntimes_tool.py` — `sun_times` + +- `SUNTIMES_TOOL_NAME = "sun_times"`. +- Seams: `Fetcher` (geocoding, via `geocode.geocode`) **and** `Clock` (today's + date in the target zone) — both injected in tests. `astral` is imported lazily. +- `build_suntimes_tool(fetch=…, now=…)` exposes `sun_times(place: str) -> str`: + geocode the place → lat/lon + IANA `timezone` → `astral.sun.sun()` for today in + that zone → sunrise/sunset, plus `astral.moon.phase()` mapped to a phase name + (new / waxing crescent / first quarter / waxing gibbous / full / …). One + speakable string, e.g. *"In Paris today the sun rises at 6:01 AM and sets at + 9:45 PM, and the moon is a waxing gibbous."* +- Reuses the shared `geocode.py` — no second network call beyond geocoding; + astral computes the rest offline. +- Failure → apology: + - No geocoding match → *"I couldn't find a place called '<place>'."* + - Astral/timezone error or fetch error → *"I couldn't get the sun times there + right now."* + +## Wiring into `brain.py` + +All eight tools converge on three additive edits (the one shared file, edited +once): + +1. `build_live_tools()` appends all eight. All are keyless ⇒ always present (no + `FIRECRAWL_API_KEY`-style gate); the new libraries add no key. +2. `_tool_capabilities()` adds a spoken-capability phrase per tool, each gated on + the tool's name being present in the bound set: + - `look_up_topic` → *"look up facts about people, places, and topics"* + - `calculate` → *"do arithmetic, percentages, and math like square roots and averages"* + - `convert_units` → *"convert units and currencies"* + - `define_word` → *"define words and give synonyms"* + - `get_time_in` → *"tell the current time in a place"* + - `date_math` → *"do date math, like days until a date or the weekday of a date"* + - `check_holiday` → *"tell you about public holidays"* + - `sun_times` → *"tell you sunrise, sunset, and the moon phase for a place"* +3. `_TOOL_LABELS` gains a present-tense affordance label per tool: + - `look_up_topic` → *"Looking that up"* + - `calculate` → *"Calculating"* + - `convert_units` → *"Converting units"* + - `define_word` → *"Looking up a definition"* + - `get_time_in` → *"Checking the time there"* + - `date_math` → *"Working out dates"* + - `check_holiday` → *"Checking holidays"* + - `sun_times` → *"Checking sun times"* + +The existing `_NO_TOOLS_GUIDANCE` path is unaffected (reached only when +`build_system_prompt` is handed an explicitly empty toolset, which tests do). + +## Error handling (cross-cutting) + +Every tool body is best-effort and **never raises** out to the graph — a tool +outage must not trip `brain`'s "couldn't complete the turn" path. Each catches +its own failures and returns one of the short spoken strings listed per tool +above. `calculate` is the one tool with no network failure mode; its only +apology path is an invalid/disallowed expression. + +## Testing + +Targets the gate's 100% patch-coverage + diff-scoped mutation requirements: +assertions must *fail* if a changed line breaks, not merely execute it. One +`tests/test_agent_cascade_<x>_tool.py` per tool (plus a `geocode` test), all +hermetic via injected seams — no real network/clock. + +- **`geocode.py`:** URL building (params present/correct), top-match extraction + incl. the new `timezone` field, and the no-match → `None` path. `weather_tool` + tests stay green through the refactor (its geocode now delegates). +- **`topic_tool`:** happy path (canned `extract` → expected string), the + disambiguation/empty-extract apology, and the fetch-error apology; truncation + at `_MAX_CHARS`. +- **`calc_tool`:** correct evaluation for several expressions incl. precedence + and unary minus; the integer-vs-float **output formatting** (`36` not `36.0`; + `87 / 3` → `29`, asserting no float artifact leaks); a **parametrized case per + whitelisted function and constant** (so a mutated function-table name is + caught); and the apology for each failure mode — invalid syntax, a disallowed + name (`foo + 1`), an **un-whitelisted function** (`factorial(5)` — proving the + DoS exclusion holds), division by zero, and an over-`MAX_POWER` exponent. +- **`units_tool`:** a physical conversion via `pint` (e.g. miles→km, °F→°C), a + currency conversion via a fake `fetch`, the unit-error apology, and the + currency-fetch-error apology; the currency-vs-unit path selection. +- **`define_tool`:** happy path with synonyms and without, the not-found + (object response) apology, and the fetch-error apology. +- **`worldclock_tool`:** happy path with an injected clock + fake geocode + (assert the place, weekday/date/time, and tz abbreviation), the no-match + apology, and the bad-timezone/fetch-error apology. +- **`datemath_tool`:** one-date mode (weekday + signed delta past/future/today, + via an injected clock), two-date mode (total days + `relativedelta` + breakdown), and the unparseable-date apology. +- **`holiday_tool`:** date-on-a-holiday and date-not-a-holiday, next-holiday mode + (injected clock), the unknown-country apology, and the bad-date apology. +- **`suntimes_tool`:** happy path with fake geocode + injected clock (assert + sunrise/sunset and the mapped moon-phase name), the no-match apology, and the + astral/fetch-error apology. +- **`brain` wiring:** `build_live_tools()` includes each new `*_TOOL_NAME`; + `_tool_capabilities()` / `build_system_prompt` advertises each; `_tool_label` + returns each new label. These assert behavior (the exact phrase/label), not + mere execution, to kill mutants. + +No new env vars or commands ⇒ the docs-consistency gate stays green (verify +during implementation). + +## PR sequence + +- **PR-A (dependencies only):** add `pint`, `simpleeval`, `python-dateutil`, + `holidays`, and `astral` to `pyproject.toml` + `uv.lock`. No feature code. +- **PR-B (feature):** `geocode.py` + the eight tool modules + the `weather_tool` + geocode refactor + the `brain.py` wiring + all tests. Lands after PR-A so the + new libraries are available. diff --git a/docs/superpowers/specs/2026-06-22-live-read-url-tool-design.md b/docs/superpowers/specs/2026-06-22-live-read-url-tool-design.md new file mode 100644 index 00000000..0381d6dd --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-read-url-tool-design.md @@ -0,0 +1,167 @@ +# Read-a-URL tool (web pages + PDFs) for `assembly live` + +**Date:** 2026-06-22 +**Status:** Approved design — ready for implementation plan + +## Goal + +Give the `assembly live` voice agent (the `agent-cascade` command) a keyless, +always-available tool that fetches a **web page or PDF by URL** and returns its +readable text. The agent can then read an article or document the user names, or +follow a link surfaced by web search — bringing the live agent closer to the +"talk to a multimodal assistant" experience with no API-key setup. + +## Context + +`assembly live` answers each spoken turn with a deepagents graph +(`aai_cli/agent_cascade/brain.py`). Its only built-in tool today is Firecrawl +web *search*, bound only when `FIRECRAWL_API_KEY` is set — so an unkeyed session +runs tool-free. Reading a specific URL is a distinct capability from searching: +search finds pages, this one reads one. + +The CLI already has a purpose-built reader: `core/webpage.py:fetch_article()`. +It fetches a URL with the project's pinned `httpx2` client, then narrows the body +to readable text — **HTML** via trafilatura (nav/sidebars/footers stripped), +**PDF** via pypdf (text layer of every page, detected by Content-Type or the +`%PDF-` magic bytes). It already backs `assembly speak --url`, so its output is +narration-oriented — exactly what a spoken agent wants. It rejects non-http(s) +URLs and raises on an empty/failed fetch. + +> Note: `transcribe` itself uses **no** PDF/webpage tools — it pipes transcript +> text through the LLM Gateway. The reusable reader is `fetch_article` (powers +> `speak --url`); the coding agent's `fetch_tool.py:fetch_url` returns *raw* +> truncated HTML with no PDF extraction and is the wrong fit here. + +The established pattern for a live tool is `aai_cli/agent_cascade/weather_tool.py`: +pure/directly-testable helpers plus a single thin network seam (a `Callable`) +injected in tests so the suite needs no sockets, and best-effort error handling +that returns a short spoken string rather than raising into the graph. + +## Scope + +- **Live-only.** The tool lives in `aai_cli/agent_cascade/` and is bound only in + the live voice agent. The coding agent's toolset is unchanged. +- **Reader: reuse `core/webpage.py:fetch_article`** (HTML + PDF, keyless). No new + fetching/parsing logic. +- **Always present.** `fetch_article` needs no API key, so the tool is bound in + every live session — the first built-in tool that is *always* available, so an + unkeyed session is no longer tool-free. + +### Out of scope (YAGNI) + +- No local-filesystem read/write — that is the separate `live-file-readwrite` + design. This tool reads **remote URLs only**. +- No approval gate. A spoken turn can't pause for a keyboard confirmation, so + live tools are read-only and auto-approved (the existing stance). See the + security note below. +- No `--no-read` opt-out flag; no per-call content-type selection (the reader + auto-detects HTML vs PDF). + +## Architecture + +A new module `aai_cli/agent_cascade/webpage_tool.py`, beside `weather_tool.py`. + +``` +read_url(url) ──▶ read(url) ──▶ core.webpage.fetch_article ──▶ Article(text, title, url) + └──▶ _format(article) ──▶ truncated "title + text" string for the model +``` + +`agent_cascade` → `core` is an allowed import direction (the layers contract +forbids feature slices from importing `commands`, not `core`). + +### Components + +- `READ_URL_TOOL_NAME = "read_url"` — the registered tool name. `brain.py` + detects availability and labels the live-UI affordance by this name, so a test + pins it. +- `Reader = Callable[[str], Article]`, default `fetch_article` — **the only + network seam**. Tests inject a fake returning a canned `Article` (happy path) + or raising a `CLIError` (failure paths), so the whole flow runs with no + sockets. +- `_MAX_CHARS` — truncation cap (~16000), so a long article or multi-page PDF + can't blow the model's context budget. A `±` shift is behaviorally equivalent, + so the constant line is `# pragma: no mutate`. +- `_format(article) -> str` — pure. Leads with the title (when present) then the + readable body, truncated to `_MAX_CHARS` with a trailing `…[truncated]` marker + when it overflows. The body is *source text for the model to summarize aloud*, + not spoken verbatim, so it needn't be "speakable" — only bounded. +- `build_read_url_tool(read=fetch_article) -> BaseTool` — the + `@tool(READ_URL_TOOL_NAME)` wrapper exposing `read_url(url: str) -> str`. The + `read` seam is injectable for hermetic tests. Plus `READ_URL_TOOL_NAME`, these + are the module's only public names. + +### Data flow per call + +1. The model calls `read_url` with a URL string (from the conversation or a + prior web-search result). +2. `read` (`fetch_article`) fetches and extracts readable text — HTML via + trafilatura, PDF via pypdf — returning an `Article(text, title, url)`. +3. `_format` renders `title + text`, truncated, for the model to read and + summarize aloud. + +## Wiring into `brain.py` + +The three spots a built-in tool touches: + +- `build_live_tools()` — **always** includes the read-url tool (keyless), so even + an unkeyed session has a real capability. Firecrawl search stays key-gated and + is appended alongside it when present. +- `_tool_capabilities()` — restructured to collect *multiple* built-in capability + phrases (today it returns at most one). Adds *"read a web page or PDF you have + the URL for"* when the read-url tool is present; web search's phrase is + appended when that tool is present. `_join_clause` already renders a list. +- `_TOOL_LABELS[READ_URL_TOOL_NAME] = "Reading the page"` so the live UI shows a + meaningful affordance while the tool runs (matching `"Searching the web"`). + +The `_NO_TOOLS_GUIDANCE` path still works: it is reached only when +`build_system_prompt` is handed an explicitly empty toolset (which tests do), +not in a normal live session (which now always has ≥1 tool). + +The committed-but-dormant `weather_tool.py` is **left untouched** by this change. + +## Error handling + +The tool is best-effort and **never raises** into the graph — a fetch failure +must not trip `brain`'s "the agent couldn't complete the turn" path or sink a +live turn. `fetch_article` raises `UsageError` (not an http(s) URL, or no +readable text — e.g. a scanned/image-only PDF or a paywalled/JS page) and +`APIError` (DNS/timeout/non-2xx), both `CLIError`. `read_url` catches its own +failures and returns a short speakable string instead: + +- No readable text / bad URL (`UsageError`) → *"I couldn't find readable text on + that page."* +- Fetch failed (`APIError`, or any other exception) → *"I couldn't read that + page right now."* + +## Security note (accepted) + +An un-gated URL fetch can reach internal/SSRF targets. The **coding** agent +gates its `fetch_url` for exactly this reason, but it can pause for keyboard +approval; a spoken live turn cannot. Live therefore auto-approves read-only +tools — and already exposes an un-gated web-search tool that returns content +from arbitrary URLs — so reading a URL is consistent with the existing posture, +not a new class of exposure. Recorded here as a known, accepted trade-off rather +than a gate. + +## Testing + +Targets the gate's 100% patch-coverage + diff-scoped mutation requirements: +assertions must *fail* if a changed line breaks, not merely execute it. All +tests are hermetic — no real network — via the injected `read` seam, in keeping +with the rest of the cascade's STT/LLM/TTS fakes. + +- `_format` tested directly: + - title present → leads with the title, then the body. + - title absent → body only. + - body over `_MAX_CHARS` → truncated to the cap with the `…[truncated]` marker; + a short body is returned untruncated. +- The tool driven end-to-end with a fake `read`: + - Happy path: canned `Article` → `_format`'s output. + - `UsageError` raised → the "couldn't find readable text" message. + - `APIError` raised → the "couldn't read that page" message. +- `brain` wiring: + - `build_live_tools()` includes a tool named `READ_URL_TOOL_NAME` (and still + includes web search when keyed). + - `_tool_capabilities()` / `build_system_prompt` advertises the read-url + capability (and both capabilities together when search is also present). + - `_tool_label(READ_URL_TOOL_NAME)` returns "Reading the page". diff --git a/docs/superpowers/specs/2026-06-22-live-sandboxed-execute-design.md b/docs/superpowers/specs/2026-06-22-live-sandboxed-execute-design.md new file mode 100644 index 00000000..5d62cfa0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-sandboxed-execute-design.md @@ -0,0 +1,387 @@ +# Hands-free sandboxed cowork for `assembly live` + +**Date:** 2026-06-22 +**Status:** Approved design — ready for implementation plan + +## Goal + +Turn the `assembly live` voice agent (the `agent-cascade` command) into one that +can **cowork on the project in your current directory, hands-free**. Today, even +with `--files`, the agent can read and edit files (each edit gated by a +*keyboard* y/a/n) but it cannot run code, remember anything across sessions, +delegate, or be approved by voice. This work adds four capabilities — all folded +into the existing `--files` flag: + +1. **Sandboxed, gated `execute`.** Light up deepagents' built-in `execute` tool + (today bound but inert, because `--files` uses a plain `FilesystemBackend` + that is not a `SandboxBackendProtocol`). `execute` runs commands **in the + real cwd** — so it can `pytest` the repo, `git diff`, `npm run build` the + files the agent just edited — kernel-confined by an OS sandbox so they can't + escape the directory or reach the network, and every run is **approved**. +2. **Durable cross-session memory.** Enable deepagents' built-in + `MemoryMiddleware` (via `create_deep_agent(memory=…)`) over a per-project + memory file, so the agent resumes knowing what it was working on. +3. **Delegation via the `task` tool.** Wire up deepagents' subagents (available + but unwired) so the agent can hand a focused multi-step subtask to a + fresh-context, gateway-bound helper, keeping the main voice turn lean. +4. **Spoken approval.** The approval gate accepts an unambiguous **spoken** + yes/no — not only a keypress — so the safety gate doesn't contradict the + hands-free premise (with a keyboard fallback for the highest-risk commands). + +## Context + +`assembly live` answers each spoken turn with a deepagents graph +(`aai_cli/agent_cascade/brain.py`). Tools are normally auto-approved — a +low-latency spoken turn can't pause for a confirmation — but `--files` is the +exception: it swaps the in-memory backend for a real-cwd +`FilesystemBackend(virtual_mode=True)` and gates `write_file`/`edit_file` behind +a **keyboard** y/a/n approval (`brain._stream_gated` brackets the wait with +`ApprovalPause` events and calls an injected `Approver`; the voice TUI supplies +it via `agent_cascade.modals.ApprovalScreen`; headless runs auto-deny via +`_exec._deny_writes`; resumed through an `InMemorySaver` checkpointer). This +work extends that exact machinery — a sandbox-capable backend, `execute` in the +gate, voice-aware approval — without replacing it. + +deepagents adds the `execute` tool automatically when the backend implements +`SandboxBackendProtocol`; for non-sandbox backends it returns an error +("inert"). The shipped backends are `LocalShellBackend` (unrestricted host +shell — deepagents explicitly warns against untrusted use) or a `BaseSandbox` +subclass. `risk.py` already carries shell-risk scoring for `execute` (dormant +today because `execute` isn't gated; this work makes it live — and reuses it to +pick the highest-risk tier for the keyboard fallback). Subagents are likewise +*available but unwired*: `SubAgentMiddleware` raises "At least one subagent must +be specified" and `create_deep_agent` only adds the `task` node when +`subagents=[…]` is passed; `assembly live` passes none today. + +There is **no first-class Python macOS-sandbox library**. The idiomatic +mechanism is `sandbox-exec -p '<SBPL profile>'` (Apple Seatbelt — still shipping, +used by AI coding-agent sandboxes); on Linux it's the `bwrap` (bubblewrap) +binary. Both are pure-subprocess — no new dependency — which fits this repo +(`S603/S607` are ignored project-wide for controlled shell-outs). + +**Prior art — `@anthropic-ai/sandbox-runtime` (srt).** Anthropic's own sandbox +(behind Claude Code's `/sandbox`) uses these same primitives. We borrow its +**posture** (default-allow reads, deny secrets; deny-by-default writes; confine +to the working directory; block network) but **not the dependency** — srt is +Node/TypeScript with no Python binding, so depending on it would add a Node + +`npx` runtime requirement that cuts against the agent's keyless/no-setup ethos. + +**Persistence reality.** Core langgraph (already installed) ships only +*in-memory* savers/stores (`InMemorySaver`, `InMemoryStore`); neither persists +to disk. A persistent checkpointer needs `langgraph-checkpoint-sqlite`, which +this repo **deliberately removed** (`e585f08`). deepagents' built-in +`MemoryMiddleware` gives cross-session continuity with **no new dependency** by +loading/persisting an on-disk memory file — the right fit now that cowork has a +real filesystem. + +## Decisions + +1. **Isolation:** OS-level sandbox. `sandbox-exec -p '<SBPL>'` on macOS, `bwrap` + on Linux. **Inert (safe refusal) on every other platform or when the sandbox + binary is missing — never a fallback to unconfined execution.** No new + dependency. +2. **Scope:** general shell — deepagents' native `execute(command)`. +3. **Activation:** folded into the existing `--files` flag (no new flag). +4. **Workspace — cwd-scoped cowork.** `execute` runs **in the real cwd**. + *Reads* allowed by default (system + cwd + `$HOME`) so tools work, with a + **secrets read-denylist** (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`, + `~/.npmrc`, `.env`/`.env.*`, `.claude/`). *Writes* allowed only within cwd + (plus the OS temp dir), with a **persistence write-denylist** even inside cwd + (`.git/hooks/`; and shell rc files, which only fall inside the write region + in the `cwd == $HOME` case but are denied to cover it). **No network.** Can't + escape cwd. Damage is bounded to the project dir and git-recoverable. +5. **Gating — every mutation is approved.** `execute` joins + `write_file`/`edit_file` in the `interrupt_on` set and flows through the + existing approver (`risk.py`'s shell-risk warning surfaces on the prompt). The + OS sandbox is **defense-in-depth**: even an approved command can't reach the + network or escape cwd. +6. **Persistence — `MemoryMiddleware` via `memory=`.** When `--files` is on, pass + `create_deep_agent(memory=["./.deepagents/AGENTS.md"])`; deepagents builds the + `MemoryMiddleware` over the cwd backend itself. The agent maintains the file + during work (a normal gated cwd write); it reloads next session. No new + dependency. This *durable working memory* is distinct from the in-session + `InMemorySaver` (which stays, solely to drive interrupt/resume within a + session). +7. **Subagents (`task`) — full tools, gated, gateway-bound.** Pass one + general-purpose subagent to `create_deep_agent(subagents=[…])` under + `--files`. It **omits `model`** (inherits the gateway-bound model — + `spec.get("model", model)` + `resolve_model` passes instances through, keeping + the live agent AssemblyAI-only) and inherits the full toolset against the same + sandboxed backend, with its own `interrupt_on` mirroring `_WRITE_TOOLS` so its + `write_file`/`edit_file`/`execute` also prompt. **Verification-gated (see + Architecture): whether a subagent's HITL interrupt surfaces through our + approval loop is unverified; if implementation can't prove it, the subagent + falls back to a read-only toolset — never an ungated mutating subagent.** +8. **Spoken approval — voice or keyboard, fail-safe to reject.** During an + approval pause the agent accepts an **unambiguous spoken token** (an explicit + phrase like "yes, run it" / "approve" — never a bare "yes", which STT + mishears) **or** a keypress, whichever comes first. Anything ambiguous — + silence, a timeout, a low-confidence or unrecognized utterance — **rejects**. + For the highest-risk tier (commands `risk.py` flags as destructive), spoken + approval is **not** accepted; those require the keyboard. + +### Why these, over the alternatives (rejected) + +- **Ephemeral scratch dir / fully isolated from cwd** — rejected: that's "run + arbitrary code safely," not cowork. Confining writes to `/tmp` and deny-reading + cwd means `execute` can't `pytest` the repo or build the files just edited. +- **`execute` unprompted (trust the sandbox alone)** — rejected: even confined + to cwd, an approved-by-default agent could delete or rewrite project files; an + approval keeps the human in the loop, with the sandbox limiting blast radius. +- **Keyboard-only approval** — rejected: a voice cowork agent whose safety gate + requires the keyboard is a contradiction; spoken approval resolves it (with the + destructive-tier keyboard fallback as the safety floor). +- **Enumerate-the-allowed-system-read-paths (deny reads by default)** — + rejected: hand-maintaining the `/usr` / `/System` / `/Library` set a Python + install needs is the most fragile part of a macOS sandbox. srt is + default-allow-reads + deny-secrets for exactly this reason; we adopt that. +- **Persistent sqlite checkpointer for cross-session resume** — rejected: re-adds + the deliberately-removed `langgraph-checkpoint-sqlite` dep, and the fresh- + `thread_id`-per-turn design (avoids re-accumulating history) means thread-state + resume doesn't map cleanly. `MemoryMiddleware` fits better. +- **`LocalShellBackend` unconfined** — rejected: deepagents itself warns it gives + no isolation; the sandbox is the whole point. +- **Docker / container sandbox** — rejected: heavy daemon dependency, slow + per-session cold start for a keyless CLI voice agent. +- **Depend on `@anthropic-ai/sandbox-runtime` directly** — rejected: Node-only, + adds an `npx` runtime dependency. We borrow its posture, not its code. + +## Scope + +- **Live-only.** All new code lives in `aai_cli/agent_cascade/`; gated behind + `--files`. Nothing else in the CLI changes. +- **No new dependency.** Pure subprocess over OS binaries; `MemoryMiddleware`, + `InMemorySaver`, and the subagent middleware are already available. +- **Speakable contract preserved.** `execute` never raises into the graph; on + any failure it returns a short string for the agent to speak. + +### Out of scope (YAGNI) + +- Windows sandboxing → `execute` stays inert there. +- Network access or package installation inside the sandbox. +- Docker / remote / cloud sandboxes. +- Full-transcript checkpointer resume; global/cross-project memory (memory is + per-project, in cwd). +- A separate `--sandbox`/`--exec` flag or per-tool opt-outs. +- A richer command-risk *tiering* model beyond the two tiers we use (gated vs + `risk.py`-flagged-destructive); the existing `risk.py` heuristic is the line. + +## Architecture + +### New module: `aai_cli/agent_cascade/sandbox.py` + +The entire sandbox concern in one focused, independently-testable module. + +- **`class SandboxedShellBackend(LocalShellBackend)`** — inherits + `FilesystemBackend` file operations rooted at cwd (so + `read_file`/`write_file`/`edit_file`/`ls`/`glob`/`grep` behave exactly as + `--files` today) and **overrides `execute()`** so it never delegates to the + inherited host-shell `execute`. Implementing `SandboxBackendProtocol` (via + `LocalShellBackend`) is what makes deepagents auto-add the `execute` tool. + - `execute(command, *, timeout=None) -> ExecuteResponse`: resolve capability → + render the cwd-scoped policy → run the wrapped command through the injected + `Runner` with `cwd=<real cwd>` → return combined stdout + exit code. Bounded + by `timeout` (default + a hard max). + - **Invariant:** the override must never call `super().execute()` (the + unconfined host shell). Capability `none` → return a refusal, run nothing. + +- **The denylists (shared constants):** one read-deny tuple (credential stores + + `.env*` + `.claude/`) and one within-cwd write-deny tuple (`.git/hooks/`, shell + rc files), cribbed from srt's auto-protected set. Both renderers consume the + same constants so the platforms stay in lockstep (a parity test asserts it). + +- **Policy rendering (pure functions — the security core):** + - `render_seatbelt_profile(cwd, tmp, *, read_deny, write_deny) -> str` — SBPL + with **default-allow reads**: `(version 1)`, `(deny default)`, + `(allow process-exec*)`, `(allow file-read*)`, then + `(deny file-read* (subpath …)/(regex …))` per read-deny entry (Seatbelt glob + patterns handle `.env*`), `(allow file-write* (subpath "<cwd>") (subpath + "<tmp>"))`, then `(deny file-write* (subpath "<cwd>/.git/hooks") …)` per + write-deny entry (last-match-wins, so denies override). Network stays denied + by `(deny default)`. + - `build_bwrap_argv(cwd, tmp, command, *, read_deny, write_deny) -> list[str]` + — `bwrap --unshare-all --die-with-parent`, `--ro-bind / /` (whole FS + read-only = default-allow-reads), `--bind <cwd> <cwd>` (rw), `--bind <tmp> + <tmp>`, `--chdir <cwd>`, then `--tmpfs`/`--ro-bind /dev/null` masks over each + secret path and `--ro-bind` over `.git/hooks` to block writes; network + unshared. **Platform note:** bubblewrap is path-based, so in-cwd secret-file + protection (e.g. arbitrary `.env`) is coarser than Seatbelt's glob denies — + documented as a known asymmetry; the directory-level credential stores + (`~/.ssh`, …) are masked precisely on both. + - **Optional hardening:** wrap the inner command with `ulimit -t`/`ulimit -v` + (CPU/address-space caps) so a runaway can't peg the box inside the timeout. + Mark the literal caps `# pragma: no mutate` (tuning knobs). + +- **Capability detection (injectable):** resolve `"seatbelt" | "bwrap" | "none"` + from platform + a `which`-style probe. `"none"` → `execute` returns *"I can't + run code on this system."* and **never** shells out. This + refuse-don't-fall-back branch is the single most safety-critical line. + +- **Seams for hermetic tests:** `Runner = Callable[[list[str], str, int], + CompletedProcessLike]` (default wraps `subprocess.run` with combined output, + `cwd`, `timeout`, minimal env via `core/env.child_env`) and the capability + probe — both injectable so the suite asserts *what argv/profile we'd run* with + no real sandbox (CI reliably has neither binary). + +### Subagents (the `task` tool) + +One general-purpose subagent, passed to `create_deep_agent(subagents=[spec])` +under `--files`. (deepagents exports a built-in `GENERAL_PURPOSE_SUBAGENT`, but +we define our own spec to set `interrupt_on` and omit `model`.) The spec (a +deepagents `SubAgent` dict): + +- `name`: `"general-purpose"`; `description`: what `task()` is for (delegate a + focused multi-step subtask — research, gather context, or implement a + contained change — and get back a short summary). +- `system_prompt`: the cowork rules + "return a concise spoken-length summary." +- **`model`: omitted** — inherits the gateway-bound model. A test asserts the + spec carries no `model` key so the AssemblyAI-only invariant can't silently + regress to a `provider:model` string. +- **`tools`: omitted** in the full-tools path — inherits the main toolset + (`read_file`/`write_file`/`edit_file`/`ls`/`glob`/`grep`/`execute`) bound to + the same `SandboxedShellBackend`, so `execute` stays sandboxed in the subagent. +- **`interrupt_on`: `dict.fromkeys(_WRITE_TOOLS, True)`** — the subagent gets its + own `HumanInTheLoopMiddleware` so its `write_file`/`edit_file`/`execute` also + prompt (deepagents adds this when `interrupt_on` is set; it "Requires a + checkpointer", which the `--files` graph already has). + +**The verification gate (the one genuine unknown).** A subagent's HITL interrupt +is raised inside the subagent's sub-graph; our approval loop (`_stream_gated` → +`_pending_writes`) reads `graph.get_state(config).interrupts` at the *parent* +level. Whether a subagent interrupt surfaces there is **unverified**. +Implementation MUST prove it with a focused test/spike *before* shipping the +full-tools subagent. **If it does not surface, fall back to a read-only subagent +`tools` list** (`read_file`/`ls`/`glob`/`grep` + the keyless live tools, no +mutation/`execute`) — a researcher that can't bypass the gate. Shipping an +ungated mutating subagent is **not** acceptable; the read-only fallback is the +safety floor. + +### Spoken approval (hands-free gating) + +Today the `Approver` (`brain.Approver = Callable[[str, dict], bool]`) is answered +by `modals.ApprovalScreen`'s keypress. Spoken approval makes the *answer* source +multimodal without changing the gate's shape: + +- During an `ApprovalPause(active=True)` (the reply deadline is already + suspended for the human-think interval), the engine — whose STT stream is + already live — races the **next final transcript** against a keypress and + resolves the approver with whichever lands first. +- **Token grammar (fail-safe to reject):** approval requires an explicit + affirmative phrase (e.g. "yes, run it" / "approve" / "go ahead and run it") — + never a bare "yes" (STT confuses "no"/"go"/"yeah"). Negatives, low-confidence + or unrecognized utterances, silence, and the pause timeout all resolve to + **reject** (the existing `_DECLINED` path). +- **Destructive tier → keyboard only.** When `risk.risk_warning(name, args)` + fires (the destructive-shell heuristic), the spoken affirmative is ignored and + the prompt requires the keyboard, so an STT mishearing can never green-light an + `rm -rf`/`sudo`/disk-write. +- **Boundaries touched:** this is the larger lift. It extends the approver + protocol to a voice-aware variant the engine supplies (racing STT vs. keypress, + with the risk-tier branch), and threads the "next spoken token" from + `engine`'s STT leg into the approval window. The keyboard `ApprovalScreen` + stays as the fallback and the headless `_deny_writes` auto-reject is unchanged. + The STT/voice race is injected (a fake "spoken token" source) so it stays + hermetic — no mic, no sockets. + +### Edits to `brain.py` (the one shared file, minimal + additive) + +- `_build_fs_backend()` returns `SandboxedShellBackend(root_dir=str(Path.cwd()), + virtual_mode=True)` instead of `FilesystemBackend`. `--files`-off path + unchanged. +- `_WRITE_TOOLS` becomes `("write_file", "edit_file", "execute")` so `execute` + joins `interrupt_on` and the existing approval/resume loop + (`_stream_gated`/`_decide`). The `InMemorySaver` checkpointer is unchanged. +- `_graph_kwargs` (when `config.files`) also sets `memory=["./.deepagents/ + AGENTS.md"]` and `subagents=[<the general-purpose spec>]` on `create_deep_agent`. +- `_TOOL_LABELS["execute"] = "Running code"` and + `_TOOL_LABELS["task"] = "Working on a subtask"` — the live-UI affordances. +- The system-prompt capability phrasing advertises *"run code to solve problems + and operate on this project"* when `execute` is bound, and *"delegate a bigger + job to a helper"* when `task` is bound. + +## Boundary / housekeeping + +- `subprocess` is fenced by ruff `TID251`; `sandbox.py` gets a deliberate, + reviewable per-module allowlist entry. The child env is built minimally via + `core/env.child_env`. +- `risk.py`'s `execute` branch becomes **live** (shell-risk warning on the + prompt; also the destructive-tier signal for the keyboard fallback) — no longer + dormant, so its tests assert real behavior. +- Stale comments to fix: the "always-bound `execute` … inert" notes in + `brain.py`; the `--files` paragraph in `aai_cli/CLAUDE.md` (now: sandboxed + gated code execution + durable memory + delegation + voice approval); the + `--files` help string (regenerate the affected `--help` snapshot; never + hand-edit `.ambr`). +- Memory file lives at `./.deepagents/AGENTS.md` (deepagents convention). No new + env var / command ⇒ docs-consistency gate stays green; update REFERENCE.md/ + README only if their `--files` description needs it. + +## Error handling (cross-cutting) + +`execute` is best-effort and never raises into the graph: + +- capability `none` → *"I can't run code on this system."* +- sandbox launch failure (`Runner` raises) → a short apology string. +- timeout / non-zero exit → returned as combined output + `exit_code` for the + model to read aloud (a failed run is information, not an error path). +- user declines (keypress, spoken negative, ambiguity, or timeout) → the standard + `_DECLINED` message, same as a declined write today. + +This mirrors the never-raise contract every live tool follows. + +## Testing + +Targets the gate's 100% patch-coverage + diff-scoped mutation requirements: +assertions must *fail* if a changed line breaks. Hermetic via the injected +`Runner`, capability, and spoken-token seams — no real sandbox, mic, or sockets. + +- **Policy renderers:** `render_seatbelt_profile` asserts `(deny default)` + + `(allow file-read*)`, each read-deny path emits a `file-read*` deny, **cwd is a + `file-write*` subpath**, each write-deny path (incl. `.git/hooks`) emits a + `file-write*` deny, and no network allow exists; `build_bwrap_argv` asserts + `--unshare-all`, `--ro-bind / /`, the cwd rw bind, `--chdir <cwd>`, the secret + masks, and the `.git/hooks` read-only bind. A **parity test** asserts both + renderers cover the same denylist constants. Mutating any allow/deny token, or + dropping a denylist entry, must fail a test. +- **`execute()` happy path:** a fake `Runner` asserts the command is wrapped in + `sandbox-exec -p <profile>` / `bwrap …` with `cwd=<real cwd>`; timeout + passthrough; output/exit shaping into `ExecuteResponse`. +- **Capability `none`:** asserts the refusal string **and that the `Runner` is + never invoked** — kills the "fall back to host shell" mutant. +- **Failure modes:** `Runner` raising → apology; non-zero exit → output+exit. +- **brain wiring:** `_build_fs_backend()` returns a `SandboxBackendProtocol` + backend; `execute` **is** in the `--files` `interrupt_on` map and a declined + `execute` yields `_DECLINED`; `_tool_label("execute")` returns the new label; + the capability phrase appears when `execute` is bound; `memory=` is passed with + the per-project source when `--files` is on. +- **subagent wiring:** with `--files`, `create_deep_agent` gets a `subagents` + list; the spec **carries no `model` key**; in the full-tools path its + `interrupt_on` includes `execute`/`write_file`/`edit_file`; `_tool_label("task")` + returns the new label; the `task` capability phrase appears when bound. +- **subagent HITL surfacing (the verification spike):** drive a subagent + `write_file`/`execute` and assert it pauses through the parent approval loop + (interrupt visible to `_pending_writes`). **Go/no-go for the full-tools + subagent** — if it can't pass, the implementation switches to the read-only + `tools` list and the test instead asserts the subagent has no mutating tools. +- **spoken approval:** an explicit affirmative phrase approves; a bare "yes", + a negative, an unrecognized utterance, and a timeout each **reject**; a keypress + still approves; and a `risk.py`-flagged destructive command **ignores** the + spoken affirmative and requires the keypress. Drive via the injected spoken-token + seam; assert the resolved decision, not mere execution. +- **`risk.py`:** the now-live branch asserts the warning fires for a destructive + command and is `None` for a benign one (also exercised by the destructive-tier + spoken-approval test). + +## Milestones + +Each is its own dependency-free PR; later milestones build on earlier ones. + +- **M1 — Sandboxed `execute` + memory.** `sandbox.py`, the `brain.py` backend + swap, `execute` in the gate (keyboard approval, the existing path), the + `memory=` wiring, comment/help/doc updates, and their tests. The core value; + shippable alone. +- **M2 — Subagents (`task`).** The general-purpose subagent + the HITL-surfacing + spike that decides full-tools vs. read-only. Independent of M3. +- **M3 — Spoken approval.** The voice-aware approver, the engine STT-vs-keypress + race, the token grammar, and the destructive-tier keyboard fallback — the + largest lift, touching `engine`/`modals`. Makes M1/M2's gate hands-free. diff --git a/docs/superpowers/specs/2026-06-22-live-streaming-reply-pipeline-design.md b/docs/superpowers/specs/2026-06-22-live-streaming-reply-pipeline-design.md new file mode 100644 index 00000000..eb5d5c63 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-streaming-reply-pipeline-design.md @@ -0,0 +1,175 @@ +# `assembly live` — streaming reply pipeline (lower time-to-first-audio) + +**Date:** 2026-06-22 +**Status:** Approved, ready for implementation plan +**Area:** `aai_cli/agent_cascade/` (`engine.py`, `brain.py`, `text.py`), `aai_cli/tts/session.py` (consumed, not changed) + +## Problem + +Today a live cascade turn runs in series: the whole deepagents graph is driven to +completion (`engine._complete_within`), the finished reply is split into sentences +(`text.split_sentences`), and each sentence is synthesized with the **buffered** TTS +path (`tts_session.synthesize(...).pcm`) before any audio plays. So time-to-first-audio +is `full-LLM-generation + first-sentence-synthesis`, with no overlap. + +The system prompt caps replies at 1–2 sentences, so sentence-level pipelining alone +would not help the common single-sentence reply (you still can't speak a sentence until +it is fully generated). The win for the typical short reply requires overlapping work +*within* an utterance. + +## Goal + +Overlap the three stages — token generation, synthesis, and playback — so audio starts +as soon as the first clause is ready, even for a single-sentence reply. Approach chosen +(over sentence-only and token+sentence variants): **token streaming + clause-level +flush + streaming TTS frames**. + +Non-goals: changing the model, the 1–2 sentence prompt guidance, STT/`format_turns` +behavior, or the front-end protocols (`Renderer`/`Player`). + +## Design + +### 1. Brain: a reply event stream (`brain.py`) + +Replace `build_completer` (returns `str`) with a streaming producer: + +``` +build_streamer(api_key, config, *, graph=None) -> Callable[[messages], Iterator[ReplyEvent]] +``` + +`stream_reply(messages)` drops the prepended `system` message (as today), then iterates +`graph.stream(input, stream_mode="messages")` and yields two frozen event dataclasses +defined in `brain.py`: + +- `SpeechDelta(text: str)` — a top-level assistant-text token delta. Only + `AIMessageChunk.content` deltas are yielded. Subagent tokens are excluded automatically + because we do **not** pass `subgraphs=True`; tool-call AIMessage chunks carry no spoken + content and so contribute nothing here. +- `ToolNotice(label: str)` — emitted when a tool-call chunk lands, carrying the speakable + label from `_tool_label` (e.g. "Searching the web"). + +Graph failures wrap into `CLIError` exactly as `_run_graph` does today, raised out of the +iterator (the consumer surfaces it). Verbose `-v` flow logging (`_FLOW_LOG`) moves inside +this same streaming loop — logging tool calls/results/interim assistant text as chunks +arrive (strictly better than today's `stream_mode="values"` snapshot logging). + +`complete_reply`, `_complete_within`, `_run_graph`/`_drive_graph`'s invoke branch, and +`_reply_text` are removed; the always-stream path supersedes them. + +### 2. TTS: a frame sink instead of buffered bytes + +`CascadeDeps.synthesize` changes from `Callable[[str], bytes]` to +`Callable[[str, Callable[[bytes], None]], None]`, implemented over the existing streaming +primitive `tts_session.synthesize(api_key, spec, on_audio=...)` (`tts/session.py:234`, +already used by `assembly speak`). The engine's sink is `_feed(pcm)`, which enqueues to +the player **only when `_stop` is not set** — a barge-in therefore just drops the +remaining frames of the in-flight clause; no exception is threaded through the TTS module. +The greeting uses the same sink. + +### 3. Engine: streaming `_generate_reply` (`engine.py`) + +The graph stream runs on a **throwaway daemon producer thread** that pushes typed items +onto a `queue.Queue`; the reply worker thread consumes them. The producer thread preserves +today's wall-clock backstop: a stalled gateway can block inside a token read that the +worker cannot otherwise observe — the same reason `_complete_within` used a throwaway +thread. The consumer's `queue.get` uses a `time.monotonic` deadline so the total-turn +timeout and its "took longer than {n}s to respond" message are unchanged +(`_REPLY_TIMEOUT_SECONDS` stays 60s). On timeout the producer is abandoned (daemon, dies +with the process) and a `CLIError(error_type="agent_timeout")` is raised, as today. + +Producer items: `ToolNotice`/`SpeechDelta` (forwarded from the brain), plus engine +sentinels `Done` and `Error(exc)`. + +Consumer loop, per item: + +- `ToolNotice(label)` → `renderer.tool_call(label)` **and clear the pending clause + buffer** (the "drop unspoken preamble on a tool call" decision). Rendering lives on the + consumer thread, so the buffer clear is same-thread. +- `SpeechDelta(text)` → on the first delta of the turn, `_speaking.set()` then + `renderer.reply_started()`; append `text` to the buffer; flush any complete clauses to + TTS via `pop_clauses`, checking `_stop` between clauses. +- `Done` → flush the buffered tail as a final clause; join spoken clauses and append to + history (then `trim_history`); `_speaking.clear()`; `renderer.reply_done(interrupted= + self._stop.is_set())`. +- `Error(exc)` → if nothing has been spoken yet, the existing pre-speak path + (`reply_started` + `(error: {message})` transcript + `reply_done`); otherwise + `_record_error` and stop. + +`_speaking` is set only once the turn begins speaking (first `SpeechDelta`), preserving +the "Ctrl-C quits while thinking, interrupts while speaking" semantics in `_silence` / +`interrupt_reply`. Barge-in (`_barge_in`), the interrupt path, and the sliding-history +window are otherwise unchanged. + +### 4. Incremental clause splitter (`text.py`) + +Add a pure function: + +``` +pop_clauses(buffer: str, *, min_chars: int) -> tuple[list[str], str] +``` + +- **Hard boundaries** `.!?` flush a clause when the terminator is followed by whitespace + (reusing `split_sentences`' rule, so `$3.50` / `...` don't fragment). +- **Soft boundaries** `,;:` (followed by whitespace) flush only when the pending clause is + at least `min_chars` long, avoiding choppy two-word TTS fragments. +- The text after the last boundary is returned as `remainder` and kept buffered by the + engine; the stream-end tail is flushed on `Done`. + +`min_chars` is a module constant (~25), marked `# pragma: no mutate` (a ±1 shift is +behaviorally equivalent). `pop_clauses` is pure and table-tested. + +### Data flow + +``` +STT final turn + -> producer thread: graph.stream(messages) -> queue[ToolNotice|SpeechDelta|Done|Error] + -> reply worker: queue.get(deadline) + ToolNotice -> renderer.tool_call + clear buffer + SpeechDelta -> buffer += text; pop_clauses -> for clause: synthesize(clause, _feed) + _feed(pcm) -> player.enqueue (skipped once _stop set) + Done -> flush tail, record history, reply_done +``` + +## Error handling + +- LLM/graph/tool failure → `CLIError` from the iterator → `Error` item → pre-speak or + mid-speak handling above; first failure recorded in `session.error` and re-raised on the + main thread by `run_cascade` (unchanged). +- TTS failure during a clause → `CLIError` from `synthesize` → `_record_error` + stop + (mirrors today's per-sentence synth failure). +- Total-turn stall → `monotonic` deadline on `queue.get` → `agent_timeout` `CLIError`. + +## Testing + +- **Engine** (`tests/test_agent_cascade_engine.py`): inject a fake `stream_reply` yielding + scripted `SpeechDelta`/`ToolNotice`/raising `CLIError`, and a fake `synthesize` recording + its sink calls. Assert: clause boundaries trigger synth at the right points; a + `ToolNotice` clears the unspoken buffer; barge-in mid-stream stops further enqueue and + records only spoken text; the `monotonic` deadline raises the timeout error; the + pre-speak and mid-speak error paths render correctly. No graph, socket, mic, or speaker. +- **Brain** (`tests/test_agent_cascade_*`): inject a fake `graph` whose `.stream` yields + `(chunk, metadata)` tuples; assert top-level text deltas become `SpeechDelta`, tool-call + chunks become `ToolNotice`, subagent/tool chunks are filtered, and graph exceptions wrap + to `CLIError`. Verbose logging asserted via the `_FLOW_LOG` records. +- **`pop_clauses`** (`tests/test_agent_cascade_*` or the text-helper test): table tests for + hard/soft boundaries, the `min_chars` guard, `$3.50`/`...` non-fragmentation, and tail + handling. + +Coverage/mutation gates: the new branches (clause flush conditions, the buffer-clear, the +deadline expiry, `_speaking` first-delta gate) each need an assertion that fails if the +line breaks, not just coverage. + +## Risks / mitigations + +- **Gateway token streaming reliability** — confirmed: `assembly code` streams through the + gateway, and the streaming tool-call-id bug was fixed in `model.py` (PR #247). +- **Barge-in responsiveness** — improves vs today: frame-level enqueue drop + clause-level + `_stop` checks replace whole-sentence granularity. +- **Choppy TTS from over-eager flushing** — guarded by the `min_chars` soft-boundary + threshold. + +## Out of scope (possible follow-ups) + +- `--no-format-turns` fast mode (shaves the STT formatting round-trip). +- Routing the no-tools case to a plain completion instead of the full deepagents graph + (reduces per-request token overhead / time-to-first-token). diff --git a/docs/superpowers/specs/2026-06-22-live-tool-filler-design.md b/docs/superpowers/specs/2026-06-22-live-tool-filler-design.md new file mode 100644 index 00000000..2c5020c9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-tool-filler-design.md @@ -0,0 +1,182 @@ +# Live voice agent: speak a filler while a tool runs + +Date: 2026-06-22 +Branch: `live-tool-call-ux` +Status: design (awaiting review) + +## Problem + +`assembly live` (the `agent_cascade`) answers spoken turns with a deepagents +graph that can pause mid-turn to call a tool (`get_weather`, Firecrawl web +search, or an MCP tool). While that tool runs, the cascade emits a **visual** +affordance only — `renderer.tool_call("Searching the web")` mounts a dim note in +the TUI — but **says nothing audibly**. On a hands-free voice session the user +hears dead air for the whole tool round-trip and assumes the agent broke or +didn't hear them. + +This is the single highest-impact responsiveness fix borrowed from OpenClaw, +whose realtime voice agent speaks a brief "let me check" before delegating work +(`buildRealtimeVoiceAgentConsultWorkingResponse`). + +## Goal + +When the agent starts its first tool call of a turn, speak a short, spoken-style +filler ("Let me check the weather", "Let me look that up") through the existing +TTS leg, so the silent gap is filled and the user knows work is happening. + +Non-goals: + +- No change to the LLM prompt or to how tools are selected/called. +- No new CLI flag or config field (v1 ships always-on). +- No streaming-TTS-only restriction beyond what already gates the cascade. + +## Design decisions (from brainstorming) + +| Decision | Choice | +| --- | --- | +| Filler source | **Canned per-tool phrases**, keyed off the existing `_TOOL_LABELS` map. Deterministic, zero extra LLM latency, fully testable, and it says *why* the agent paused. | +| When to speak | **First tool call of a turn only.** Chained tool calls stay silent so a multi-tool turn doesn't get chatty. | +| Variety | **Rotate 2–3 phrases per tool deterministically** by a per-session counter (not RNG — so it's testable and survives the mutation gate). | +| Configurable? | **No.** Always-on in v1; a toggle can be added later if anyone wants silence. | + +## Where it lives + +The seam already exists. In `aai_cli/agent_cascade/engine.py`, `_consume` +already receives a `brain.ToolNotice` for each started tool call: + +```python +if isinstance(item, brain.ToolNotice): + self.renderer.tool_call(item.label) + buffer = "" # drop any unspoken preamble — the answer comes after the tool + continue +``` + +The filler hooks in right here: after showing the visual affordance, synthesize +a spoken filler through the same path a normal clause uses (`_speak` → +`synthesize` → `_feed` → `player`), so barge-in (`_stop`) and the draining-tail +logic already cover it for free. + +The phrase table lives in `aai_cli/agent_cascade/brain.py` next to +`_TOOL_LABELS`, because the filler is a property of the tool (same place we +already keep the human-readable label) and `ToolNotice` is the natural carrier. + +### Components + +1. **`brain.py` — filler phrases + carrier.** + - Add a `_TOOL_FILLERS: dict[str, tuple[str, ...]]` mapping each known tool + name to a small tuple of spoken variants, plus a generic fallback tuple + (e.g. `("One sec.", "Let me check.")`) for unknown/MCP tools. + - `WEB_SEARCH_TOOL_NAME` → e.g. `("Let me look that up.", "Searching now.", "One moment, checking the web.")` + - `weather_tool.WEATHER_TOOL_NAME` → e.g. `("Let me check the weather.", "Checking the forecast now.")` + - Carry the chosen filler on `ToolNotice`. Extend the dataclass with a + `fillers: tuple[str, ...]` field (the variants for that tool), set when the + notice is built in `_events_from_chunk` / `_surface_event` via a new + `_tool_fillers(name)` helper that mirrors `_tool_label(name)`. + - Keeping the *tuple* on the notice (not a pre-chosen single string) lets the + engine own rotation state, so two notices for the same tool in one session + rotate rather than repeat. The notice stays a pure value object. + +2. **`engine.py` — speak it once per turn, rotate across turns.** + - Add a per-session rotation counter to `CascadeSession` + (`_filler_index: int`, init `0`, `# pragma: no mutate` on the field if a + ±-equivalent default trips the gate). + - Add a per-turn `spoke_filler: bool` guard local to `_consume` so only the + **first** `ToolNotice` of a turn speaks. (Track it as a local, reset each + `_consume` call.) + - On the first `ToolNotice`: pick `fillers[self._filler_index % len(fillers)]`, + increment `_filler_index`, and synthesize it via the existing `_speak` + machinery so it respects `_stop` and feeds the player. The filler text is + **not** appended to `spoken`/history — it is conversational glue, not part + of the answer (history must stay a clean alternating record of the real + reply). This means routing the filler through `synthesize`/`_feed` + directly, or a thin `_speak_filler(text)` that mirrors `_speak` but skips + the `spoken.append`. + - `started`/`reply_started` handling: the filler counts as the start of + audible output, so set `_speaking`/call `reply_started()` before + synthesizing the filler if not already started (same as a normal clause), + so the voice bar shows "speaking" and a barge-in during the filler is + detected. + +### Data flow + +``` +graph stream → ToolNotice(label, fillers) (brain.py) + → engine._consume sees first ToolNotice of turn + → renderer.tool_call(label) # existing visual affordance + → _speak_filler(pick(fillers)) # NEW: spoken filler, not recorded + → synthesize(text, _feed) → player.enqueue # respects _stop + → buffer = "" # existing: drop preamble + → subsequent ToolNotices in same turn: visual only (no filler) + → real answer clauses stream in and are spoken + recorded as today +``` + +## Interruption / barge-in + +The filler rides the same `_stop` / `player.flush()` path as any clause: + +- A spoken barge-in (`on_turn` → `_barge_in`) sets `_stop` and flushes queued + audio, so a filler mid-playback is cut just like a reply clause. +- A UI interrupt (`interrupt_reply`) flushes the player; since the filler will + have set `_speaking`, the interrupt is detected (not swallowed as a no-op). +- `_feed` already drops frames once `_stop` is set, so a filler can't keep + playing after the user barges in. + +No new interruption logic is needed. (Echo-induced *false* barge-in — the mic +hearing the filler/agent audio — is a **separate** problem tracked in the echo +guard spec; this spec does not address it.) + +## Error handling + +- If `synthesize` raises `CLIError` on the filler, reuse `_speak`'s existing + contract: record the error and stop the turn (return as a cut). A filler that + can't be synthesized is the same failure mode as a clause that can't — + surfaced once, turn ends cleanly. The real-answer path is unaffected. +- An unknown tool name (no entry in `_TOOL_FILLERS`) falls back to the generic + filler tuple, exactly as `_tool_label` falls back to `"Using {name}"`. + +## Testing + +The cascade is unit-tested against fakes through `CascadeDeps` (no +sockets/mic/speaker). New coverage, all driving the fake `stream_reply`/ +`synthesize`: + +1. **Filler is spoken on first tool call.** Script a `stream_reply` that yields + `ToolNotice` then `SpeechDelta`s; assert the fake `synthesize` received a + filler string from the tool's tuple *before* the answer clauses. (Kills a + mutant that drops the filler call.) +2. **Only the first tool call speaks.** Yield two `ToolNotice`s in one turn; + assert exactly one filler was synthesized. (Kills a mutant that removes the + `spoke_filler` guard.) +3. **Rotation across turns.** Run two turns that each trigger the same tool; + assert the two fillers differ (index advanced). (Kills a mutant that pins the + index to 0.) +4. **Filler is not in history.** After a tool turn, assert `session.history`'s + assistant message is the real answer only — no filler text. (Kills a mutant + that appends the filler to `spoken`.) +5. **Barge-in cuts the filler.** Set `_stop` (or drive an interim turn) during + filler synthesis; assert no further frames are fed. (Reuses the existing + barge-in test harness.) +6. **Unknown/MCP tool uses the generic fallback.** A `ToolNotice` for a tool not + in `_TOOL_FILLERS` still speaks a generic filler. + +Per the repo gate, every new line needs an assertion that *fails* if the line +breaks (mutation gate), and the diff needs 100% patch coverage. + +## Risks / open questions + +- **Phrase wording** is a copy decision; the tuples above are placeholders to + refine. They must obey the spoken-style rule (short, no markdown) and read + naturally before the real answer. +- **Latency interaction:** the filler adds one extra TTS round-trip before the + answer. Because synthesis streams (playback starts on the first frame) and the + tool call is already the slow leg, the filler should overlap the tool + round-trip rather than serialize behind it — but verify the filler doesn't + noticeably delay the first answer clause in a real sandbox run. +- **MCP tools** get a generic filler; once MCP tools are common we may want + per-tool fillers derived from the tool description, but that is out of scope. + +## Out of scope + +- Echo / false-barge-in suppression (separate spec). +- Model-emitted acknowledgements via prompt. +- A config flag / `--no-tool-filler` toggle. diff --git a/docs/superpowers/specs/2026-06-22-live-weather-tool-design.md b/docs/superpowers/specs/2026-06-22-live-weather-tool-design.md new file mode 100644 index 00000000..2ef2a4d8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-live-weather-tool-design.md @@ -0,0 +1,135 @@ +# Live weather tool for `assembly live` + +**Date:** 2026-06-22 +**Status:** Approved design — ready for implementation plan + +## Goal + +Give the `assembly live` voice agent (the `agent-cascade` command) a keyless, +always-available weather tool backed by [Open-Meteo](https://open-meteo.com/). +It returns the current conditions plus a short forecast for a named place, in a +form short enough to read aloud — bringing the live agent closer to the +"talk to a multimodal assistant" experience without any API-key setup. + +## Context + +`assembly live` answers each spoken turn with a deepagents graph +(`aai_cli/agent_cascade/brain.py`). Its only built-in tool today is Firecrawl +web search, which is bound only when `FIRECRAWL_API_KEY` is set — so an unkeyed +session runs tool-free. Open-Meteo needs no key, so the weather tool is the +first built-in tool that is **always** present, giving every live session at +least one real capability. + +Tools are LangChain `BaseTool`s. The established pattern for a custom (non-vendor) +tool is `aai_cli/code_agent/fetch_tool.py`: pure, directly-testable helpers plus a +single thin network seam (a `Callable`) that is injected in tests so the suite +needs no sockets. + +## Scope + +- **Live-only.** The tool lives in `aai_cli/agent_cascade/` and is bound only in + the live voice agent. The coding agent's toolset is unchanged. +- **Data source: Open-Meteo (keyless).** Free, no signup, with a companion + geocoding endpoint to turn a place name into coordinates. +- **Coverage: current conditions + short forecast** (today + next two days). + +### Out of scope (YAGNI) + +- No units-configuration flag (temperatures are returned in **both** °C and °F; + the agent speaks whichever fits the conversation). +- No `--no-weather` opt-out flag (the tool is read-only and cheap; web search's + only "gate" is its key requirement, which does not apply here). +- No geocoding disambiguation UI — always take the top match. + +## Architecture + +A new module `aai_cli/agent_cascade/weather_tool.py`, beside `mcp_tools.py`. + +``` +get_weather(location) ──▶ _geocode(location) ──▶ Open-Meteo geocoding API + └──▶ _forecast(lat, lon) ──▶ Open-Meteo forecast API + └──▶ format_report(...) ──▶ speakable string +``` + +### Components + +- `WEATHER_TOOL_NAME = "get_weather"` — the registered tool name. `brain.py` + detects weather availability and labels the tool affordance by this name, so a + test pins it. +- `Fetcher = Callable[[str], object]` — GETs a URL and returns parsed JSON. The + default `_get_json` uses `httpx`; tests inject a fake mapping URLs → canned + JSON. **This is the only network seam.** +- `_geocode(name, *, fetch)` → resolved display name + latitude/longitude, or + `None` when there is no match. Endpoint: + `https://geocoding-api.open-meteo.com/v1/search?name=<name>&count=1`. +- `_forecast(lat, lon, *, fetch)` → the `current` and `daily` blocks. Endpoint: + `https://api.open-meteo.com/v1/forecast` with + `current=temperature_2m,weather_code`, + `daily=temperature_2m_max,temperature_2m_min,weather_code`, + `forecast_days=3`, temperatures in Celsius (°F derived in formatting). +- `describe_weather_code(code)` — pure WMO weather-code → human text + ("partly cloudy", "light rain", …) with a fallback for an unknown code. +- `format_report(name, current, daily)` — pure → a short speakable string, e.g. + *"In Paris it's 14°C (57°F) and partly cloudy. Tomorrow 9 to 17°C, light rain. + Then 11 to 19°C, clear."* Temperatures are given in both units; °F is computed + as `round(c * 9 / 5 + 32)`. +- `build_weather_tool(fetch=_get_json)` — the `@tool(WEATHER_TOOL_NAME)` wrapper + exposing `get_weather(location: str) -> str`. The `fetch` seam is injectable + for hermetic tests. Plus `WEATHER_TOOL_NAME`, these are the module's only + public names. + +### Data flow per call + +1. The model calls `get_weather` with a location string. +2. `_geocode` resolves it to coordinates + a clean display name (or `None`). +3. `_forecast` fetches current + 3-day daily data for those coordinates. +4. `format_report` renders a short, speakable summary in both units. + +## Wiring into `brain.py` + +- `build_live_tools()` appends the weather tool. Because Open-Meteo is keyless, + the weather tool is always present even when web search is absent — so the + live session always has at least one tool. +- `_tool_capabilities()` detects `WEATHER_TOOL_NAME` and adds the phrase + *"tell you the current weather and short forecast for a place"* to the spoken + capability clause. +- `_TOOL_LABELS[WEATHER_TOOL_NAME] = "Checking the weather"` so the live UI shows + a meaningful affordance while the tool runs (matching `"Searching the web"`). + +The existing `_NO_TOOLS_GUIDANCE` path still works: it is reached only when +`build_system_prompt` is handed an explicitly empty toolset (which tests do), +not in a normal live session. + +## Error handling + +The tool is best-effort and **never raises** out to the graph — a weather +outage must not trip `brain`'s "the agent couldn't complete the turn" path or +sink a live demo. `get_weather` catches its own failures and returns a short +speakable string instead: + +- No geocoding match → *"I couldn't find a place called '<location>'."* +- Network/HTTP error (the `fetch` seam raises) → *"I couldn't get the weather + right now."* + +## Testing + +Targets the gate's 100% patch-coverage + diff-scoped mutation requirements: +assertions must *fail* if a changed line breaks, not merely execute it. + +- Pure helpers tested directly: + - URL building for `_geocode` and `_forecast` (params present and correct). + - `describe_weather_code` for several known codes **and** the unknown-code + fallback. + - `format_report` — both-unit rendering, the current line, and the + forecast-day lines. +- The tool driven end-to-end with a fake `fetch`: + - Happy path: canned geocode + forecast JSON → expected speakable string. + - Not-found path: empty geocoding results → the "couldn't find" message. + - Network-error path: `fetch` raises → the "couldn't get the weather" message. +- `brain` wiring: + - `build_live_tools()` includes a tool named `WEATHER_TOOL_NAME`. + - `_tool_capabilities()` (or `build_system_prompt`) advertises weather. + - `_TOOL_LABELS` / `_tool_label` returns "Checking the weather". + +All tests are hermetic — no real network — via the injected `fetch` seam, in +keeping with the rest of the cascade's STT/LLM/TTS fakes. diff --git a/docs/superpowers/specs/2026-06-22-paused-voicebar-flat-meter-design.md b/docs/superpowers/specs/2026-06-22-paused-voicebar-flat-meter-design.md new file mode 100644 index 00000000..ec53e85e --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-paused-voicebar-flat-meter-design.md @@ -0,0 +1,55 @@ +# Paused voice-bar: flat (non-animating) meter + +**Date:** 2026-06-22 +**Status:** Approved (design) +**Scope:** The shared voice-bar helper used by the `assembly live` and `assembly code` TUIs. +**Depends on:** the in-flight push-to-talk work (the `"paused"` phase), which is uncommitted in the main checkout — not on `origin/main`, so this is **not** part of PR #258. + +## Problem + +The push-to-talk work added a `"paused"` voice phase (`tui_status.py:30`) for a muted mic. But the bar's meter keeps **animating** while paused: `_render_voicebar` (`agent_cascade/tui.py:271`) always passes `next(self._voice_frames)`, so the 3-cell block pulse (`▁▃▅`→`▃▅▇`→…) cycles every 0.3s even though nothing is being heard. A paused session should read as at-rest, not active. + +## Design + +Flatten the meter inside the shared pure helper `tui_status.voicebar_markup`, so the `"paused"` phase renders a static at-rest meter regardless of the frame it is handed: + +```python +# at-rest meter for the paused phase (same width/alphabet as VOICE_FRAMES) +VOICE_FLAT = "▁▁▁" + +def voicebar_markup(phase: str, frame: str, *, hint: str = "") -> str: + label, color = _VOICE_PHASES[phase] + if phase == "paused": + frame = VOICE_FLAT # a muted mic shows a flat meter, not the animated pulse + return f"[{color}]{frame}[/] {escape(label)}{hint}" +``` + +**Why in the helper (not the caller):** +- `voicebar_markup` is a pure function (no Textual), so the behavior unit-tests directly with no app/timer. +- The helper is shared by both the `live` and `code` TUIs, so both surfaces get the flat paused meter from one change. +- `_render_voicebar` keeps calling `next(self._voice_frames)` unchanged — the animation cycle advances invisibly while paused, so there is no timer or iterator state to manage. The displayed meter is simply static. + +Rejected alternatives: a conditional in each TUI's `_render_voicebar` (caller-side, duplicated across both TUIs); stopping/restarting the 0.3s animation timer on pause (manages timer lifecycle state for no visible benefit over a static frame). + +## Components touched + +- `aai_cli/code_agent/tui_status.py` — add `VOICE_FLAT`; add the paused-frame override in `voicebar_markup`. + +No change to `agent_cascade/tui.py`, `code_agent/tui.py`, `_VOICE_PHASES`, or `VOICE_FRAMES`. + +## Testing + +Update the existing `tests/test_code_tui_status.py::test_voicebar_markup_per_phase_carries_label_meter_accent_and_hint`: +- Assert the paused render contains the **literal** `"▁▁▁"` (not `tui_status.VOICE_FLAT`, which would mutate in lockstep and survive — per the file's existing comment at lines 45–46) and does **not** contain the animated frame it was passed (`▁▃▅`) — this kills the mutant on the new `if phase == "paused"` branch and on the `VOICE_FLAT` literal. +- The existing non-paused assertions already prove the frame passes through verbatim for `listening`/`thinking`/`speaking` (e.g. `"▁▃▅" in listening`), guarding against a mutant that flattens every phase. + +No visual snapshot exists for the paused state, so there is nothing to regenerate. + +## Landing + +Implement in the main checkout, alongside the in-flight push-to-talk work (where the `"paused"` phase lives). Run only the targeted `test_code_tui_status.py` tests — the full gate is not run here because the main checkout's working tree carries unrelated, half-finished in-flight work. Do **not** commit or modify the rest of the in-flight changes; this edit joins that work for the user to gate and PR as a unit. + +## Out of scope + +- Stopping the animation timer while paused (the meter only needs to *look* static). +- Any change to the `assembly code` voice chrome beyond what the shared helper provides for free. diff --git a/docs/superpowers/specs/2026-06-22-remove-assembly-code-design.md b/docs/superpowers/specs/2026-06-22-remove-assembly-code-design.md new file mode 100644 index 00000000..5373012b --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-remove-assembly-code-design.md @@ -0,0 +1,131 @@ +# Remove `assembly code`, preserve `assembly live` + +**Date:** 2026-06-22 +**Status:** Approved design + +## Goal + +Remove the `assembly code` command and everything used **only** by it, while +keeping `assembly live` (`agent_cascade/`) fully working. + +`assembly code` is the `commands/code/` command plus the `code_agent/` feature +slice (24 modules). `assembly live` (`agent_cascade/`) currently borrows 8 +modules from `code_agent/`. Per the brainstorm decisions, we **relocate those +shared modules into the `agent_cascade/` slice** (its sole consumer now), then +delete `code_agent/` entirely so no orphaned "code" package survives. + +`code_gen/` — the `--show-code` SDK-script generator on +`transcribe`/`stream`/`agent`/`live` — is unrelated and stays untouched. + +## Dependency map (why removal isn't a clean `rm`) + +`assembly live` (`agent_cascade/`) reaches into `code_agent/` for: + +- Standalone, no intra-slice deps: `model.py`, `firecrawl_search.py`, + `banner.py`, `tui_status.py` +- `messages.py` → `summarize.py` +- `modals.py` (`ApprovalScreen`) → `banner`, `risk`, `summarize`, and + `voice_ui` (TYPE_CHECKING only) +- `risk.py` → one constant (`FETCH_TOOL_NAME`) from `fetch_tool.py` +- `agent.py` → only the `CompiledAgent` Protocol type (live's `brain.py` builds + its own deepagents graph via `create_deep_agent`; it does not use + `code_agent.agent`'s orchestration) + +Confirmed orphaned dependency: `langgraph-checkpoint-sqlite` is used **only** by +`code_agent/store.py` (`SqliteSaver`); live uses `InMemorySaver` from langgraph +core. `langchain-mcp-adapters`, `deepagents`, `langgraph`, `langchain-firecrawl`, +`langchain-openai` all remain in use by live and stay. + +The `CODE` help panel (`help_panels.CODE`) has `assembly code` as its only +member; `assembly live` lives under the `TRANSCRIPTION` panel. + +## Plan + +### 1. Relocate the live-shared modules into `agent_cascade/` + +Move these 8 files `code_agent/` → `agent_cascade/`: +`model.py`, `firecrawl_search.py`, `banner.py`, `tui_status.py`, `messages.py`, +`summarize.py`, `modals.py`, `risk.py`. + +Surgeries so the moved set is self-contained: + +- **`CompiledAgent` Protocol** — extract from the deleted `agent.py` into the + relocated `model.py` (or a small `agent_cascade/types.py`); `brain.py` imports + it from the new location. +- **`risk.py`** — inline the `FETCH_TOOL_NAME` literal instead of importing it + from the deleted `fetch_tool.py`. +- **`modals.py`** — drop the `TYPE_CHECKING`-only reference to + `voice_ui._VoiceIO` (deleted). + +Update the live consumers to import from `aai_cli.agent_cascade.*`: +`agent_cascade/brain.py`, `agent_cascade/tui.py`, +`commands/agent_cascade/_exec.py`. Fix the stale `code_agent.fetch_tool` comment +in `agent_cascade/weather_tool.py`. + +### 2. Delete the code-only surface + +- Command: `aai_cli/commands/code/` (`__init__.py`, `_exec.py`). +- `code_agent/` remainder (after the 8 modules move out): `_config_root`, + `agent`, `ask_tool`, `cli_tool`, `docs_mcp`, `events`, `fetch_tool`, `memory`, + `prompt`, `render`, `session`, `skills`, `store`, `tui`, `voice`, `voice_ui`, + `__init__`. The `code_agent/` package directory is removed entirely. + +### 3. Tests + +- **Relocate & re-point** the tests covering surviving (moved) modules — rename + to `test_live_*` / `test_agent_cascade_*` and fix imports: + `test_code_messages`, `test_code_modals`, `test_code_model`, `test_code_risk`, + `test_code_summarize`, `test_code_tui_status`. These keep the moved modules + above the 90% project + 90% Textual-TUI coverage floors. +- **Delete** the code-only tests: `test_code_agent`, `test_code_command`, + `test_code_session_stream`, `test_code_tui`, `test_code_tui_voice`, + `test_code_tui_voice_switch`, `test_code_voice`. +- **Keep untouched:** all `test_code_gen*`, `test_agent_cascade_show_code`, + `test_code_gen_agent_cascade` (these are `--show-code`, unrelated). +- **Snapshots:** regenerate the root `--help` golden with `--snapshot-update` + (the `CODE` panel disappears); delete the code `--help` golden and any + code-TUI visual-regression snapshots. + +### 4. Config, panel, contracts + +- **`help_panels.py`:** remove the `CODE` constant and drop it from + `PANEL_ORDER`. +- **`pyproject.toml`:** remove the `langgraph-checkpoint-sqlite` dependency and + run `uv lock`. Update the mypy module-override list (drop + `code_agent.agent/skills/memory/store`; re-point `code_agent.model` → + `agent_cascade.model`), the ruff per-file-ignores (drop `docs_mcp`/`session`/ + `tui`/`cli_tool`; re-point the `CompiledAgent` `A002` ignore to its new + location), and the stale `assembly code` comments. +- **`.importlinter`:** remove `aai_cli.code_agent` from the feature-slice + independence contract and update the comment. `agent_cascade` is already a + slice, so the moved modules are covered. + +### 5. Docs + +- **`README.md`:** delete the `assembly code` table row (the `assembly setup` + row stays). +- **`aai_cli/AGENTS.md`:** rewrite the `code_agent/` subsystem bullet — fold the + surviving chrome into the `agent_cascade/` bullet; scrub `code_agent` + mentions. +- Scrub `assembly code` references in the bundled + `aai_cli/skills/aai-cli/SKILL.md`. +- Leave historical `docs/superpowers/specs/*` design docs as-is (a record). + +## Verification + +The full `./scripts/check.sh` must end green — especially: + +- `vulture` — prune any now-dead exports inside the moved modules. +- `deptry` — no orphaned dependencies remain. +- `lint-imports` — the import-linter contracts hold after the slice removal. +- docs-consistency gate — no doc references `assembly code` anymore. +- diff-scoped mutation + 100% patch-coverage gates on every changed line. + +## Out of scope / notes + +- The `.claude/worktrees/live-tool-call-impl/` worktree is a concurrent + session's copy — untouched. +- The working tree is on branch `live-tool-call-ux` with uncommitted + `agent_cascade` edits. Since this removal heavily edits `agent_cascade`, the + uncommitted edits should be committed/stashed first so the removal lands on a + clean base. diff --git a/pyproject.toml b/pyproject.toml index dc36d775..47f0a511 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ dependencies = [ "typer>=0.26.7", # >=0.13 vendors its own click (typer._click); we no longer import click - "assemblyai>=0.64.4", + "assemblyai>=0.64.21", "rich>=15.0.0", "keyring>=25.7.0", # httpx2 is Pydantic's maintained fork of httpx (github.com/pydantic/httpx2, @@ -72,15 +72,18 @@ dependencies = [ # (webpage.py, imported lazily). Pure-Python, permissively licensed, ships a # universal wheel, so it adds no source-compile step to Homebrew bottling. "pypdf>=5.1.0", - # `assembly code` coding agent (deepagents on the LLM Gateway). Heavy trees, - # intentionally added on this WIP branch; see aai_cli/code_agent/. + # `assembly live` voice agent (deepagents on the LLM Gateway). Heavy trees, + # intentionally added on this WIP branch; see aai_cli/agent_cascade/. "deepagents>=0.6.10", + # The live brain imports langchain's public `langchain.agents.middleware` API directly + # (agent_cascade/brain.py), so the meta package is declared rather than relied on as a + # transitive edge of deepagents — you depend on what you import. + "langchain>=1.3.9", "langchain-openai>=1.3.2", "langgraph>=1.2.2", "langchain-core>=1.4.7", "langchain-mcp-adapters>=0.3.0", "textual>=8.2.7", - "langgraph-checkpoint-sqlite>=3.1.0", "pyperclip>=1.11.0", "langchain-text-splitters>=1.0.0", "langchain-firecrawl>=0.1.0", @@ -117,7 +120,7 @@ dev = [ # failure instead of a wedged session (not in addopts — opt-in per run). "pytest-timeout>=2.3.1", "time-machine>=3.1.0", - # Visual-regression snapshots for the Textual TUIs (`assembly code` / `live`): the + # Visual-regression snapshots for the Textual TUI (`assembly live`): the # `snap_compare` fixture renders an app to SVG and diffs it against a committed golden, # catching CSS/layout/docking regressions the behavioral pilot tests can't see. Stores # SVGs under tests/__snapshots__/<module>/ (regenerate with --snapshot-update like the @@ -263,16 +266,11 @@ disable_error_code = ["annotation-unchecked"] # keep our own signatures precise and silence only the boundary-assignment codes for # these two wiring modules (the orchestration is covered by the real-graph tests). module = [ - "aai_cli.code_agent.agent", - "aai_cli.code_agent.skills", - "aai_cli.code_agent.memory", - "aai_cli.code_agent.store", - "aai_cli.code_agent.model", - "aai_cli.commands.code._exec", + "aai_cli.agent_cascade.model", "aai_cli.agent_cascade.brain", ] disallow_any_generics = false -disable_error_code = ["return-value", "arg-type", "type-arg", "call-arg"] +disable_error_code = ["return-value", "arg-type", "type-arg", "call-arg", "list-item"] [tool.pyright] # Second type checker alongside mypy: pyright catches a different class of @@ -286,12 +284,12 @@ include = ["aai_cli"] # (aai_cli.init.templates.<name>.api.*), the same bar as the rest of the package; only # generated/hidden dirs are skipped. exclude = ["**/node_modules", "**/__pycache__", "**/.*"] -# The coding-agent slice wires the deeply-generic, only-partially-typed -# deepagents/langchain/langgraph boundary, where pyright-strict floods on +# agent_cascade/brain.py and model.py wire the deeply-generic, only-partially-typed +# deepagents/langchain/ChatOpenAI boundary, where pyright-strict floods on # Unknown*/invariance diagnostics our precise signatures can't satisfy. mypy still # type-checks these modules (with the targeted overrides above) as the safety net, so # we suppress pyright diagnostics here rather than littering per-line `# pyright: ignore`. -ignore = ["aai_cli/code_agent", "aai_cli/commands/code", "aai_cli/agent_cascade/brain.py"] +ignore = ["aai_cli/agent_cascade/brain.py", "aai_cli/agent_cascade/model.py"] pythonVersion = "3.12" typeCheckingMode = "strict" # Third-party deps (assemblyai, sounddevice) ship no type stubs. @@ -449,21 +447,24 @@ max-statements = 40 # ENV_CLIENT_TOKEN holds an env-var *name*; the shipped token constant is empty in # source (release builds inject the write-only client token). "aai_cli/core/telemetry.py" = ["S105"] -# BLE001: connecting to the docs MCP server is best-effort — any failure (blocked host, -# offline, transport error) degrades to "no docs tools", so a broad except is the shape. -"aai_cli/code_agent/docs_mcp.py" = ["BLE001"] # BLE001: launching each live-agent MCP server is best-effort — any failure (npx/uvx # missing, offline host, transport error) skips just that server so one broken tool # can't sink a live session, so a broad per-server except is the right shape. "aai_cli/agent_cascade/mcp_tools.py" = ["BLE001"] -# BLE001: a turn must never crash the TUI/REPL — any agent/gateway failure is caught and -# surfaced as an ErrorText event so the user can simply retry. -"aai_cli/code_agent/session.py" = ["BLE001"] +# BLE001: a weather fetch failure (network error, bad response, timeout) must never +# bubble into brain's "couldn't complete the turn" path — speak a short apology instead +# so an Open-Meteo outage can't sink a live session turn. +"aai_cli/agent_cascade/weather_tool.py" = ["BLE001"] +# BLE001: a page/PDF fetch failure (network error, bad URL, no readable text) must never +# bubble into brain's "couldn't complete the turn" path — speak a short apology instead so +# a fetch outage can't sink a live session turn (mirrors weather_tool). +"aai_cli/agent_cascade/webpage_tool.py" = ["BLE001"] # A002: the CompiledAgent protocol must mirror langgraph's `invoke(input, ...)` parameter # name so the real compiled graph structurally satisfies it. -"aai_cli/code_agent/agent.py" = ["A002"] -# FBT001: a Textual push_screen result callback receives the bool decision positionally. -"aai_cli/code_agent/tui.py" = ["FBT001"] +"aai_cli/agent_cascade/brain.py" = ["A002"] +# A002: a test fake mirrors CompiledAgent.invoke's `input` parameter name so pyright accepts +# it as the protocol (the param is never used; renaming it breaks the structural match). +"tests/test_agent_cascade_files.py" = ["A002"] # TID251 banned-api allowlist (see [tool.ruff.lint.flake8-tidy-imports.banned-api]). # Two OS boundaries are fenced; each is owned by a chokepoint so the allowlist stays @@ -478,8 +479,6 @@ max-statements = 40 # of `subprocess`, so they stay individually allowlisted (claude/npx/ffmpeg/yt-dlp/ # tunnels/vercel/the macOS Swift helper, etc.): "aai_cli/core/procs.py" = ["TID251"] -# Runs the AssemblyAI CLI itself (python -m aai_cli) as a tool the coding agent calls. -"aai_cli/code_agent/cli_tool.py" = ["TID251"] "aai_cli/app/coding_agent.py" = ["TID251"] "aai_cli/app/mediafile.py" = ["TID251"] "aai_cli/app/setup_exec.py" = ["TID251"] @@ -490,14 +489,17 @@ max-statements = 40 "aai_cli/init/tunnel.py" = ["TID251"] "aai_cli/streaming/macos.py" = ["TID251"] "aai_cli/streaming/sources.py" = ["TID251"] +# Sandbox shell-out: launches the OS sandbox binary (sandbox-exec / bwrap) with controlled +# argv; the whole module exists to confine that one subprocess call. +"aai_cli/agent_cascade/sandbox.py" = ["TID251"] [tool.vulture] paths = ["aai_cli", "tests"] exclude = ["aai_cli/_version.py"] min_confidence = 90 ignore_decorators = ["@app.command", "@app.callback"] -ignore_names = ["app", "capture_output", "download", "healthy", "ist", "lpath", "memory_keyring", - "org", "preserve_logging_state", "refresh", "rpath"] +ignore_names = ["app", "capture_output", "download", "healthy", "input", "ist", "lpath", + "memory_keyring", "org", "preserve_logging_state", "refresh", "rpath"] [tool.codespell] # Spell-check code, comments, and docs (Kubernetes' verify-spelling, generalized). Run via @@ -510,7 +512,7 @@ skip = "./.venv,./dist,./docs,./node_modules,./.git,uv.lock,*.ambr,./tests/fixtu ignore-words-list = "unparseable,ist,expresso,notin,ans" [tool.deptry] -exclude = ["docs", "dist", ".venv", "aai_cli/init/templates"] +exclude = ["docs", "dist", ".venv", ".claude", "aai_cli/init/templates"] [tool.deptry.package_module_name_map] audioop-lts = "audioop" diff --git a/pyrightconfig.tests.json b/pyrightconfig.tests.json index 93980abd..56a9ae58 100644 --- a/pyrightconfig.tests.json +++ b/pyrightconfig.tests.json @@ -1,12 +1,11 @@ { "include": ["tests"], "ignore": [ - "tests/test_code_agent.py", - "tests/test_code_model.py", - "tests/test_code_command.py", - "tests/test_code_tui.py", - "tests/test_code_tui_voice.py", - "tests/test_agent_cascade_brain.py" + "tests/test_live_model.py", + "tests/test_agent_cascade_brain.py", + "tests/test_agent_cascade_brain_approval.py", + "tests/test_agent_cascade_prompt.py", + "tests/test_agent_cascade_subagents.py" ], "pythonVersion": "3.12", "typeCheckingMode": "standard", diff --git a/scripts/check.sh b/scripts/check.sh index 6ebab819..dbccf6dd 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -334,8 +334,11 @@ if git rev-parse --verify --quiet origin/main >/dev/null; then exit 1 fi - base_cast_count="$(hatch_base 'cast\(' aai_cli tests)" - work_cast_count="$(hatch_work 'cast\(' aai_cli tests)" + # \b so a function whose name merely ends in "cast" (e.g. weather_tool._forecast(), + # broadcast()) isn't miscounted as a typing.cast() escape hatch — the bare cast\( substring + # false-matched _forecast( and failed this gate on a branch that added no real cast. + base_cast_count="$(hatch_base '\bcast\(' aai_cli tests)" + work_cast_count="$(hatch_work '\bcast\(' aai_cli tests)" if (( work_cast_count > base_cast_count )); then echo "New cast() usage found: ${work_cast_count} current vs ${base_cast_count} at the merge-base with origin/main." exit 1 diff --git a/tests/__snapshots__/test_snapshots_help_root.ambr b/tests/__snapshots__/test_snapshots_help_root.ambr index 2bb0f987..527511f5 100644 --- a/tests/__snapshots__/test_snapshots_help_root.ambr +++ b/tests/__snapshots__/test_snapshots_help_root.ambr @@ -34,10 +34,6 @@ ╭─ Quick Start ────────────────────────────────────────────────────────────────╮ │ onboard Guided setup: sign in and run your first transcription │ ╰──────────────────────────────────────────────────────────────────────────────╯ - ╭─ Coding Agent ───────────────────────────────────────────────────────────────╮ - │ code Run a terminal coding agent backed by the AssemblyAI LLM │ - │ Gateway │ - ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Build an App ───────────────────────────────────────────────────────────────╮ │ init Scaffold a new app from a template and launch it │ │ dev Run the dev server for the app in the current directory │ diff --git a/tests/__snapshots__/test_snapshots_help_run.ambr b/tests/__snapshots__/test_snapshots_help_run.ambr index 96cca873..bd1cfba2 100644 --- a/tests/__snapshots__/test_snapshots_help_run.ambr +++ b/tests/__snapshots__/test_snapshots_help_run.ambr @@ -262,76 +262,6 @@ - ''' -# --- -# name: test_command_help_matches_snapshot[code] - ''' - - Usage: assembly code [OPTIONS] [PROMPT] - - Run a terminal coding agent backed by the AssemblyAI LLM Gateway - - An autonomous coding agent (built on the deepagents SDK) that reads, writes, - and edits files, runs shell commands, searches the AssemblyAI docs, and can - invoke the 'assembly' CLI itself — all in the working directory. It talks - only to the AssemblyAI LLM Gateway. Mutating actions ask for approval unless - you pass --auto. - - In an interactive terminal it defaults to voice: speak your request (mic -> - streaming STT) and the agent's replies are read back aloud (sandbox only). - Pass --no-voice for the keyboard TUI, or pipe input for the headless loop. - - ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ - │ prompt [PROMPT] Initial task for the agent. Omit to just open the │ - │ session │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --model TEXT LLM Gateway model │ - │ [default: gpt-5.1] │ - │ --dir -C DIRECTORY Working directory the agent's file │ - │ and shell tools operate in │ - │ [default: .] │ - │ --auto -y Skip approval prompts and run every │ - │ tool automatically │ - │ --docs --no-docs Connect to the AssemblyAI docs MCP │ - │ server for reference │ - │ [default: docs] │ - │ --skills --no-skills Load installed agent skills (e.g. │ - │ the assemblyai skill) │ - │ [default: skills] │ - │ --web --no-web Enable Firecrawl web search when │ - │ FIRECRAWL_API_KEY is set │ - │ [default: web] │ - │ --memory --no-memory Load and persist the agent's │ - │ long-term memory │ - │ [default: memory] │ - │ --session TEXT Resume a named session. Default: a │ - │ new unique session each run │ - │ --persist --fresh Persist the session to disk │ - │ (--fresh: ephemeral) │ - │ [default: persist] │ - │ --tui --no-tui Use the full-screen TUI (off: a │ - │ plain read-eval loop) │ - │ [default: tui] │ - │ --voice --no-voice Speak to the agent and hear replies │ - │ read back (readback needs the │ - │ sandbox) │ - │ [default: voice] │ - │ --help Show this message and exit. │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - - Examples - Start a coding session in the current directory - $ assembly code - Kick off with an initial task - $ assembly code "add a --verbose flag" - Run without approval prompts - $ assembly code --auto "fix the failing test" - Point at another project - $ assembly code --dir ../service - - - ''' # --- # name: test_command_help_matches_snapshot[dictate] @@ -636,8 +566,11 @@ │ persona) │ │ [default: You are a friendly, │ │ concise voice assistant. Keep │ - │ replies short and conversational. │ - │ Your reply is read aloud by a │ + │ replies as short as possible — │ + │ usually a single sentence, never │ + │ more than two. Answer directly │ + │ without preamble or filler. Your │ + │ reply is read aloud by a │ │ text-to-speech engine, so write │ │ plain spoken prose — no markdown, │ │ emoji, bullet lists, or code.] │ @@ -668,8 +601,7 @@ ╭─ Language model ─────────────────────────────────────────────────────────────╮ │ --model TEXT LLM Gateway model that powers the │ │ agent's replies │ - │ [default: │ - │ claude-haiku-4-5-20251001] │ + │ [default: kimi-k2.5] │ │ --max-tokens INTEGER RANGE [x>=1] Max tokens per reply │ │ [default: 8192] │ │ --llm-config TEXT Set any LLM Gateway request field │ @@ -679,7 +611,7 @@ │ --speech-model TEXT Streaming speech │ │ model │ │ [default: │ - │ u3-rt-pro] │ + │ universal-3-5-p… │ │ --format-turns --no-format-turns Format │ │ (punctuate) │ │ finalized turns │ @@ -700,6 +632,9 @@ ╭─ Tools ──────────────────────────────────────────────────────────────────────╮ │ --mcp-config FILE MCP servers config JSON ({"mcpServers": {…}}) to │ │ add (repeatable; none load by default) │ + │ --files Let the agent read, write, and run code in the │ + │ current directory, sandboxed (writes and runs need │ + │ confirmation) │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -711,6 +646,8 @@ $ assembly --sandbox live --system-prompt "You are a terse pirate." Add your own MCP servers (none load by default) $ assembly --sandbox live --mcp-config ~/.config/mcp/servers.json + Let the agent read and write files in the current directory + $ assembly --sandbox live --files See available voices $ assembly --sandbox live --list-voices Print equivalent Python instead of running @@ -918,10 +855,11 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Model & Language ───────────────────────────────────────────────────────────╮ │ --speech-model [universal-streaming-m Streaming speech model │ - │ ultilingual|universal- [default: u3-rt-pro] │ - │ streaming-english|u3-r │ - │ t-pro|whisper-rt|u3-pr │ - │ o] │ + │ ultilingual|universal- [default: │ + │ streaming-english|u3-r universal-3-5-pro] │ + │ t-pro|u3-rt-pro-beta-1 │ + │ |whisper-rt|universal- │ + │ 3-5-pro|u3-pro] │ │ --encoding [pcm_s16le|pcm_mulaw] Audio encoding │ │ --language-detection Auto-detect the spoken │ │ language │ diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal.raw b/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal.raw deleted file mode 100644 index aa9e5695..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal.raw +++ /dev/null @@ -1,182 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-2084666923-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-2084666923-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-2084666923-r1 { fill: #e0e0e0 } -.terminal-2084666923-r2 { fill: #c5c8c6 } -.terminal-2084666923-r3 { fill: #614fd2;font-weight: bold } -.terminal-2084666923-r4 { fill: #939393 } -.terminal-2084666923-r5 { fill: #614fd2 } -.terminal-2084666923-r6 { fill: #f59e0b } -.terminal-2084666923-r7 { fill: #f04438;font-weight: bold } -.terminal-2084666923-r8 { fill: #e0e0e0;font-weight: bold } -.terminal-2084666923-r9 { fill: #22c55e;font-weight: bold } -.terminal-2084666923-r10 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-2084666923-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-2084666923-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2084666923-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-2084666923-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-2084666923-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="587.1" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="611.5" width="610" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="646.6" y="611.5" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="635.9" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="146.4" y="635.9" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="231.8" y="635.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="268.4" y="635.9" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="427" y="635.9" width="768.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="183" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="660.3" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="390.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="536.8" y="660.3" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="622.2" y="660.3" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-2084666923-matrix"> - <text class="terminal-2084666923-r2" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-2084666923-line-0)"> -</text><text class="terminal-2084666923-r3" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-2084666923-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-2084666923-r2" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-2084666923-line-1)"> -</text><text class="terminal-2084666923-r3" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-2084666923-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-2084666923-r2" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-2)"> -</text><text class="terminal-2084666923-r3" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-2084666923-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-2084666923-r2" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-2084666923-line-3)"> -</text><text class="terminal-2084666923-r3" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-2084666923-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-2084666923-r2" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-2084666923-line-4)"> -</text><text class="terminal-2084666923-r3" x="24.4" y="142" textLength="915" clip-path="url(#terminal-2084666923-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-2084666923-r2" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-2084666923-line-5)"> -</text><text class="terminal-2084666923-r3" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-2084666923-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-2084666923-r2" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-2084666923-line-6)"> -</text><text class="terminal-2084666923-r4" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-2084666923-line-7)">v9.9.9</text><text class="terminal-2084666923-r2" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-7)"> -</text><text class="terminal-2084666923-r2" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-2084666923-line-8)"> -</text><text class="terminal-2084666923-r4" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-2084666923-line-9)">Thread: default</text><text class="terminal-2084666923-r2" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-2084666923-line-9)"> -</text><text class="terminal-2084666923-r2" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-2084666923-line-10)"> -</text><text class="terminal-2084666923-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-2084666923-line-11)">Ready to code! What would you like to build?</text><text class="terminal-2084666923-r2" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-2084666923-line-11)"> -</text><text class="terminal-2084666923-r4" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-2084666923-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-2084666923-r2" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-12)"> -</text><text class="terminal-2084666923-r2" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-2084666923-line-13)"> -</text><text class="terminal-2084666923-r2" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-2084666923-line-14)"> -</text><text class="terminal-2084666923-r2" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-2084666923-line-15)"> -</text><text class="terminal-2084666923-r2" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-2084666923-line-16)"> -</text><text class="terminal-2084666923-r2" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-17)"> -</text><text class="terminal-2084666923-r2" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-2084666923-line-18)"> -</text><text class="terminal-2084666923-r2" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-2084666923-line-19)"> -</text><text class="terminal-2084666923-r2" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-2084666923-line-20)"> -</text><text class="terminal-2084666923-r2" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-2084666923-line-21)"> -</text><text class="terminal-2084666923-r2" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-22)"> -</text><text class="terminal-2084666923-r2" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-2084666923-line-23)"> -</text><text class="terminal-2084666923-r6" x="12.2" y="605.6" textLength="1195.6" clip-path="url(#terminal-2084666923-line-24)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-2084666923-r2" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-2084666923-line-24)"> -</text><text class="terminal-2084666923-r6" x="12.2" y="630" textLength="12.2" clip-path="url(#terminal-2084666923-line-25)">│</text><text class="terminal-2084666923-r7" x="36.6" y="630" textLength="610" clip-path="url(#terminal-2084666923-line-25)">⚠ This command deletes files recursively/forcibly.</text><text class="terminal-2084666923-r6" x="1195.6" y="630" textLength="12.2" clip-path="url(#terminal-2084666923-line-25)">│</text><text class="terminal-2084666923-r2" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-2084666923-line-25)"> -</text><text class="terminal-2084666923-r6" x="12.2" y="654.4" textLength="12.2" clip-path="url(#terminal-2084666923-line-26)">│</text><text class="terminal-2084666923-r1" x="36.6" y="654.4" textLength="109.8" clip-path="url(#terminal-2084666923-line-26)">Run tool </text><text class="terminal-2084666923-r8" x="146.4" y="654.4" textLength="85.4" clip-path="url(#terminal-2084666923-line-26)">execute</text><text class="terminal-2084666923-r1" x="231.8" y="654.4" textLength="36.6" clip-path="url(#terminal-2084666923-line-26)">?  </text><text class="terminal-2084666923-r4" x="268.4" y="654.4" textLength="158.6" clip-path="url(#terminal-2084666923-line-26)">rm -rf build/</text><text class="terminal-2084666923-r6" x="1195.6" y="654.4" textLength="12.2" clip-path="url(#terminal-2084666923-line-26)">│</text><text class="terminal-2084666923-r2" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-2084666923-line-26)"> -</text><text class="terminal-2084666923-r6" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-27)">│</text><text class="terminal-2084666923-r9" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-27)">y</text><text class="terminal-2084666923-r1" x="48.8" y="678.8" textLength="134.2" clip-path="url(#terminal-2084666923-line-27)"> approve   </text><text class="terminal-2084666923-r3" x="183" y="678.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-27)">a</text><text class="terminal-2084666923-r1" x="195.2" y="678.8" textLength="195.2" clip-path="url(#terminal-2084666923-line-27)"> auto-approve   </text><text class="terminal-2084666923-r7" x="390.4" y="678.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-27)">n</text><text class="terminal-2084666923-r1" x="402.6" y="678.8" textLength="122" clip-path="url(#terminal-2084666923-line-27)"> reject   </text><text class="terminal-2084666923-r8" x="524.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-27)">e</text><text class="terminal-2084666923-r1" x="536.8" y="678.8" textLength="85.4" clip-path="url(#terminal-2084666923-line-27)"> expand</text><text class="terminal-2084666923-r6" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-27)">│</text><text class="terminal-2084666923-r2" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-2084666923-line-27)"> -</text><text class="terminal-2084666923-r6" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-2084666923-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-2084666923-r2" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-2084666923-line-28)"> -</text><text class="terminal-2084666923-r10" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-2084666923-line-29)">^Y</text><text class="terminal-2084666923-r4" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-2084666923-line-29)"> copy · </text><text class="terminal-2084666923-r10" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-2084666923-line-29)">^O</text><text class="terminal-2084666923-r4" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-2084666923-line-29)"> expand · </text><text class="terminal-2084666923-r10" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-2084666923-line-29)">esc</text><text class="terminal-2084666923-r4" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-2084666923-line-29)"> interrupt · </text><text class="terminal-2084666923-r10" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-2084666923-line-29)">^C</text><text class="terminal-2084666923-r4" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-2084666923-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal_benign.raw b/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal_benign.raw deleted file mode 100644 index 9ebc31b5..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal_benign.raw +++ /dev/null @@ -1,182 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-1417549561-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-1417549561-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-1417549561-r1 { fill: #e0e0e0 } -.terminal-1417549561-r2 { fill: #c5c8c6 } -.terminal-1417549561-r3 { fill: #614fd2;font-weight: bold } -.terminal-1417549561-r4 { fill: #939393 } -.terminal-1417549561-r5 { fill: #614fd2 } -.terminal-1417549561-r6 { fill: #f59e0b } -.terminal-1417549561-r7 { fill: #e0e0e0;font-weight: bold } -.terminal-1417549561-r8 { fill: #22c55e;font-weight: bold } -.terminal-1417549561-r9 { fill: #f04438;font-weight: bold } -.terminal-1417549561-r10 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-1417549561-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-1417549561-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1417549561-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-1417549561-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-1417549561-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="611.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="635.9" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="146.4" y="635.9" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="231.8" y="635.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="268.4" y="635.9" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="341.6" y="635.9" width="854" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="183" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="660.3" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="390.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="536.8" y="660.3" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="622.2" y="660.3" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-1417549561-matrix"> - <text class="terminal-1417549561-r2" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-1417549561-line-0)"> -</text><text class="terminal-1417549561-r3" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-1417549561-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-1417549561-r2" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-1417549561-line-1)"> -</text><text class="terminal-1417549561-r3" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-1417549561-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-1417549561-r2" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-2)"> -</text><text class="terminal-1417549561-r3" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-1417549561-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-1417549561-r2" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-1417549561-line-3)"> -</text><text class="terminal-1417549561-r3" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-1417549561-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-1417549561-r2" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-1417549561-line-4)"> -</text><text class="terminal-1417549561-r3" x="24.4" y="142" textLength="915" clip-path="url(#terminal-1417549561-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-1417549561-r2" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-1417549561-line-5)"> -</text><text class="terminal-1417549561-r3" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-1417549561-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-1417549561-r2" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-1417549561-line-6)"> -</text><text class="terminal-1417549561-r4" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-1417549561-line-7)">v9.9.9</text><text class="terminal-1417549561-r2" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-7)"> -</text><text class="terminal-1417549561-r2" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-1417549561-line-8)"> -</text><text class="terminal-1417549561-r4" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-1417549561-line-9)">Thread: default</text><text class="terminal-1417549561-r2" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-1417549561-line-9)"> -</text><text class="terminal-1417549561-r2" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-1417549561-line-10)"> -</text><text class="terminal-1417549561-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-1417549561-line-11)">Ready to code! What would you like to build?</text><text class="terminal-1417549561-r2" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-1417549561-line-11)"> -</text><text class="terminal-1417549561-r4" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-1417549561-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-1417549561-r2" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-12)"> -</text><text class="terminal-1417549561-r2" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-1417549561-line-13)"> -</text><text class="terminal-1417549561-r2" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-1417549561-line-14)"> -</text><text class="terminal-1417549561-r2" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-1417549561-line-15)"> -</text><text class="terminal-1417549561-r2" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-1417549561-line-16)"> -</text><text class="terminal-1417549561-r2" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-17)"> -</text><text class="terminal-1417549561-r2" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-1417549561-line-18)"> -</text><text class="terminal-1417549561-r2" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-1417549561-line-19)"> -</text><text class="terminal-1417549561-r2" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-1417549561-line-20)"> -</text><text class="terminal-1417549561-r2" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-1417549561-line-21)"> -</text><text class="terminal-1417549561-r2" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-22)"> -</text><text class="terminal-1417549561-r2" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-1417549561-line-23)"> -</text><text class="terminal-1417549561-r2" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-1417549561-line-24)"> -</text><text class="terminal-1417549561-r6" x="12.2" y="630" textLength="1195.6" clip-path="url(#terminal-1417549561-line-25)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-1417549561-r2" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-1417549561-line-25)"> -</text><text class="terminal-1417549561-r6" x="12.2" y="654.4" textLength="12.2" clip-path="url(#terminal-1417549561-line-26)">│</text><text class="terminal-1417549561-r1" x="36.6" y="654.4" textLength="109.8" clip-path="url(#terminal-1417549561-line-26)">Run tool </text><text class="terminal-1417549561-r7" x="146.4" y="654.4" textLength="85.4" clip-path="url(#terminal-1417549561-line-26)">execute</text><text class="terminal-1417549561-r1" x="231.8" y="654.4" textLength="36.6" clip-path="url(#terminal-1417549561-line-26)">?  </text><text class="terminal-1417549561-r4" x="268.4" y="654.4" textLength="73.2" clip-path="url(#terminal-1417549561-line-26)">ls -la</text><text class="terminal-1417549561-r6" x="1195.6" y="654.4" textLength="12.2" clip-path="url(#terminal-1417549561-line-26)">│</text><text class="terminal-1417549561-r2" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-1417549561-line-26)"> -</text><text class="terminal-1417549561-r6" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-27)">│</text><text class="terminal-1417549561-r8" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-27)">y</text><text class="terminal-1417549561-r1" x="48.8" y="678.8" textLength="134.2" clip-path="url(#terminal-1417549561-line-27)"> approve   </text><text class="terminal-1417549561-r3" x="183" y="678.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-27)">a</text><text class="terminal-1417549561-r1" x="195.2" y="678.8" textLength="195.2" clip-path="url(#terminal-1417549561-line-27)"> auto-approve   </text><text class="terminal-1417549561-r9" x="390.4" y="678.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-27)">n</text><text class="terminal-1417549561-r1" x="402.6" y="678.8" textLength="122" clip-path="url(#terminal-1417549561-line-27)"> reject   </text><text class="terminal-1417549561-r7" x="524.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-27)">e</text><text class="terminal-1417549561-r1" x="536.8" y="678.8" textLength="85.4" clip-path="url(#terminal-1417549561-line-27)"> expand</text><text class="terminal-1417549561-r6" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-27)">│</text><text class="terminal-1417549561-r2" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-1417549561-line-27)"> -</text><text class="terminal-1417549561-r6" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-1417549561-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-1417549561-r2" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-1417549561-line-28)"> -</text><text class="terminal-1417549561-r10" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-1417549561-line-29)">^Y</text><text class="terminal-1417549561-r4" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-1417549561-line-29)"> copy · </text><text class="terminal-1417549561-r10" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-1417549561-line-29)">^O</text><text class="terminal-1417549561-r4" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-1417549561-line-29)"> expand · </text><text class="terminal-1417549561-r10" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-1417549561-line-29)">esc</text><text class="terminal-1417549561-r4" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-1417549561-line-29)"> interrupt · </text><text class="terminal-1417549561-r10" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-1417549561-line-29)">^C</text><text class="terminal-1417549561-r4" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-1417549561-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal_expanded.raw b/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal_expanded.raw deleted file mode 100644 index 1754ee98..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_approval_modal_expanded.raw +++ /dev/null @@ -1,182 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-3998338575-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-3998338575-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-3998338575-r1 { fill: #e0e0e0 } -.terminal-3998338575-r2 { fill: #c5c8c6 } -.terminal-3998338575-r3 { fill: #614fd2;font-weight: bold } -.terminal-3998338575-r4 { fill: #939393 } -.terminal-3998338575-r5 { fill: #614fd2 } -.terminal-3998338575-r6 { fill: #f59e0b } -.terminal-3998338575-r7 { fill: #e0e0e0;font-weight: bold } -.terminal-3998338575-r8 { fill: #22c55e;font-weight: bold } -.terminal-3998338575-r9 { fill: #f04438;font-weight: bold } -.terminal-3998338575-r10 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-3998338575-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-3998338575-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3998338575-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-3998338575-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-3998338575-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="562.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="587.1" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="146.4" y="587.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="268.4" y="587.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="305" y="587.1" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="587.1" width="695.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="611.5" width="231.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="268.4" y="611.5" width="927.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="635.9" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="146.4" y="635.9" width="1049.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="183" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="660.3" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="390.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="536.8" y="660.3" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="622.2" y="660.3" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-3998338575-matrix"> - <text class="terminal-3998338575-r2" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-3998338575-line-0)"> -</text><text class="terminal-3998338575-r3" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-3998338575-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-3998338575-r2" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-3998338575-line-1)"> -</text><text class="terminal-3998338575-r3" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-3998338575-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-3998338575-r2" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-2)"> -</text><text class="terminal-3998338575-r3" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-3998338575-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-3998338575-r2" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-3998338575-line-3)"> -</text><text class="terminal-3998338575-r3" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-3998338575-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-3998338575-r2" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-3998338575-line-4)"> -</text><text class="terminal-3998338575-r3" x="24.4" y="142" textLength="915" clip-path="url(#terminal-3998338575-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-3998338575-r2" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-3998338575-line-5)"> -</text><text class="terminal-3998338575-r3" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-3998338575-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-3998338575-r2" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-3998338575-line-6)"> -</text><text class="terminal-3998338575-r4" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-3998338575-line-7)">v9.9.9</text><text class="terminal-3998338575-r2" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-7)"> -</text><text class="terminal-3998338575-r2" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-3998338575-line-8)"> -</text><text class="terminal-3998338575-r4" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-3998338575-line-9)">Thread: default</text><text class="terminal-3998338575-r2" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-3998338575-line-9)"> -</text><text class="terminal-3998338575-r2" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-3998338575-line-10)"> -</text><text class="terminal-3998338575-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-3998338575-line-11)">Ready to code! What would you like to build?</text><text class="terminal-3998338575-r2" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-3998338575-line-11)"> -</text><text class="terminal-3998338575-r4" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-3998338575-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-3998338575-r2" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-12)"> -</text><text class="terminal-3998338575-r2" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-3998338575-line-13)"> -</text><text class="terminal-3998338575-r2" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-3998338575-line-14)"> -</text><text class="terminal-3998338575-r2" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-3998338575-line-15)"> -</text><text class="terminal-3998338575-r2" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-3998338575-line-16)"> -</text><text class="terminal-3998338575-r2" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-17)"> -</text><text class="terminal-3998338575-r2" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-3998338575-line-18)"> -</text><text class="terminal-3998338575-r2" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-3998338575-line-19)"> -</text><text class="terminal-3998338575-r2" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-3998338575-line-20)"> -</text><text class="terminal-3998338575-r2" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-3998338575-line-21)"> -</text><text class="terminal-3998338575-r2" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-22)"> -</text><text class="terminal-3998338575-r6" x="12.2" y="581.2" textLength="1195.6" clip-path="url(#terminal-3998338575-line-23)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-3998338575-r2" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-3998338575-line-23)"> -</text><text class="terminal-3998338575-r6" x="12.2" y="605.6" textLength="12.2" clip-path="url(#terminal-3998338575-line-24)">│</text><text class="terminal-3998338575-r1" x="36.6" y="605.6" textLength="109.8" clip-path="url(#terminal-3998338575-line-24)">Run tool </text><text class="terminal-3998338575-r7" x="146.4" y="605.6" textLength="122" clip-path="url(#terminal-3998338575-line-24)">write_file</text><text class="terminal-3998338575-r1" x="268.4" y="605.6" textLength="36.6" clip-path="url(#terminal-3998338575-line-24)">?  </text><text class="terminal-3998338575-r4" x="305" y="605.6" textLength="195.2" clip-path="url(#terminal-3998338575-line-24)">file_path=app.py</text><text class="terminal-3998338575-r6" x="1195.6" y="605.6" textLength="12.2" clip-path="url(#terminal-3998338575-line-24)">│</text><text class="terminal-3998338575-r2" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-3998338575-line-24)"> -</text><text class="terminal-3998338575-r6" x="12.2" y="630" textLength="12.2" clip-path="url(#terminal-3998338575-line-25)">│</text><text class="terminal-3998338575-r4" x="36.6" y="630" textLength="231.8" clip-path="url(#terminal-3998338575-line-25)">content=PORT = 8080</text><text class="terminal-3998338575-r6" x="1195.6" y="630" textLength="12.2" clip-path="url(#terminal-3998338575-line-25)">│</text><text class="terminal-3998338575-r2" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-3998338575-line-25)"> -</text><text class="terminal-3998338575-r6" x="12.2" y="654.4" textLength="12.2" clip-path="url(#terminal-3998338575-line-26)">│</text><text class="terminal-3998338575-r4" x="36.6" y="654.4" textLength="109.8" clip-path="url(#terminal-3998338575-line-26)">DEBUG = 1</text><text class="terminal-3998338575-r6" x="1195.6" y="654.4" textLength="12.2" clip-path="url(#terminal-3998338575-line-26)">│</text><text class="terminal-3998338575-r2" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-3998338575-line-26)"> -</text><text class="terminal-3998338575-r6" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-27)">│</text><text class="terminal-3998338575-r8" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-27)">y</text><text class="terminal-3998338575-r1" x="48.8" y="678.8" textLength="134.2" clip-path="url(#terminal-3998338575-line-27)"> approve   </text><text class="terminal-3998338575-r3" x="183" y="678.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-27)">a</text><text class="terminal-3998338575-r1" x="195.2" y="678.8" textLength="195.2" clip-path="url(#terminal-3998338575-line-27)"> auto-approve   </text><text class="terminal-3998338575-r9" x="390.4" y="678.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-27)">n</text><text class="terminal-3998338575-r1" x="402.6" y="678.8" textLength="122" clip-path="url(#terminal-3998338575-line-27)"> reject   </text><text class="terminal-3998338575-r7" x="524.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-27)">e</text><text class="terminal-3998338575-r1" x="536.8" y="678.8" textLength="85.4" clip-path="url(#terminal-3998338575-line-27)"> expand</text><text class="terminal-3998338575-r6" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-27)">│</text><text class="terminal-3998338575-r2" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-3998338575-line-27)"> -</text><text class="terminal-3998338575-r6" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-3998338575-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-3998338575-r2" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-3998338575-line-28)"> -</text><text class="terminal-3998338575-r10" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-3998338575-line-29)">^Y</text><text class="terminal-3998338575-r4" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-3998338575-line-29)"> copy · </text><text class="terminal-3998338575-r10" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-3998338575-line-29)">^O</text><text class="terminal-3998338575-r4" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-3998338575-line-29)"> expand · </text><text class="terminal-3998338575-r10" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-3998338575-line-29)">esc</text><text class="terminal-3998338575-r4" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-3998338575-line-29)"> interrupt · </text><text class="terminal-3998338575-r10" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-3998338575-line-29)">^C</text><text class="terminal-3998338575-r4" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-3998338575-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_ask_modal.raw b/tests/__snapshots__/test_tui_snapshots/test_code_ask_modal.raw deleted file mode 100644 index 3eed1acc..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_ask_modal.raw +++ /dev/null @@ -1,184 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-4063177019-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-4063177019-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-4063177019-r1 { fill: #e0e0e0 } -.terminal-4063177019-r2 { fill: #c5c8c6 } -.terminal-4063177019-r3 { fill: #614fd2;font-weight: bold } -.terminal-4063177019-r4 { fill: #939393 } -.terminal-4063177019-r5 { fill: #614fd2 } -.terminal-4063177019-r6 { fill: #3a3f55 } -.terminal-4063177019-r7 { fill: #e0e0e0;font-weight: bold } -.terminal-4063177019-r8 { fill: #000000 } -.terminal-4063177019-r9 { fill: #0178d4 } -.terminal-4063177019-r10 { fill: #121212 } -.terminal-4063177019-r11 { fill: #797979 } -.terminal-4063177019-r12 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-4063177019-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-4063177019-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-4063177019-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-4063177019-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-4063177019-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="562.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="587.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="219.6" y="587.1" width="463.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="683.2" y="587.1" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="36.6" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272727" x="48.8" y="611.5" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1171.2" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1183.4" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="36.6" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272727" x="48.8" y="635.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="73.2" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272727" x="85.4" y="635.9" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#272727" x="475.8" y="635.9" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#272727" x="1146.8" y="635.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1171.2" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1183.4" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272727" x="48.8" y="660.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1171.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1183.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-4063177019-matrix"> - <text class="terminal-4063177019-r2" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-4063177019-line-0)"> -</text><text class="terminal-4063177019-r3" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-4063177019-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-4063177019-r2" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-1)"> -</text><text class="terminal-4063177019-r3" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-4063177019-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-4063177019-r2" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-2)"> -</text><text class="terminal-4063177019-r3" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-4063177019-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-4063177019-r2" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-4063177019-line-3)"> -</text><text class="terminal-4063177019-r3" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-4063177019-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-4063177019-r2" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-4063177019-line-4)"> -</text><text class="terminal-4063177019-r3" x="24.4" y="142" textLength="915" clip-path="url(#terminal-4063177019-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-4063177019-r2" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-4063177019-line-5)"> -</text><text class="terminal-4063177019-r3" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-4063177019-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-4063177019-r2" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-6)"> -</text><text class="terminal-4063177019-r4" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-4063177019-line-7)">v9.9.9</text><text class="terminal-4063177019-r2" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-7)"> -</text><text class="terminal-4063177019-r2" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-4063177019-line-8)"> -</text><text class="terminal-4063177019-r4" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-4063177019-line-9)">Thread: default</text><text class="terminal-4063177019-r2" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-4063177019-line-9)"> -</text><text class="terminal-4063177019-r2" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-4063177019-line-10)"> -</text><text class="terminal-4063177019-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-4063177019-line-11)">Ready to code! What would you like to build?</text><text class="terminal-4063177019-r2" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-11)"> -</text><text class="terminal-4063177019-r4" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-4063177019-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-4063177019-r2" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-12)"> -</text><text class="terminal-4063177019-r2" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-4063177019-line-13)"> -</text><text class="terminal-4063177019-r2" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-4063177019-line-14)"> -</text><text class="terminal-4063177019-r2" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-4063177019-line-15)"> -</text><text class="terminal-4063177019-r2" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-16)"> -</text><text class="terminal-4063177019-r2" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-17)"> -</text><text class="terminal-4063177019-r2" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-4063177019-line-18)"> -</text><text class="terminal-4063177019-r2" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-4063177019-line-19)"> -</text><text class="terminal-4063177019-r2" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-4063177019-line-20)"> -</text><text class="terminal-4063177019-r2" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-21)"> -</text><text class="terminal-4063177019-r2" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-22)"> -</text><text class="terminal-4063177019-r6" x="12.2" y="581.2" textLength="1195.6" clip-path="url(#terminal-4063177019-line-23)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-4063177019-r2" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-4063177019-line-23)"> -</text><text class="terminal-4063177019-r6" x="12.2" y="605.6" textLength="12.2" clip-path="url(#terminal-4063177019-line-24)">│</text><text class="terminal-4063177019-r7" x="36.6" y="605.6" textLength="183" clip-path="url(#terminal-4063177019-line-24)">The agent asks:</text><text class="terminal-4063177019-r1" x="219.6" y="605.6" textLength="463.6" clip-path="url(#terminal-4063177019-line-24)"> Which port should the dev server use?</text><text class="terminal-4063177019-r6" x="1195.6" y="605.6" textLength="12.2" clip-path="url(#terminal-4063177019-line-24)">│</text><text class="terminal-4063177019-r2" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-4063177019-line-24)"> -</text><text class="terminal-4063177019-r6" x="12.2" y="630" textLength="12.2" clip-path="url(#terminal-4063177019-line-25)">│</text><text class="terminal-4063177019-r8" x="36.6" y="630" textLength="12.2" clip-path="url(#terminal-4063177019-line-25)">▊</text><text class="terminal-4063177019-r9" x="48.8" y="630" textLength="1122.4" clip-path="url(#terminal-4063177019-line-25)">▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔</text><text class="terminal-4063177019-r9" x="1171.2" y="630" textLength="12.2" clip-path="url(#terminal-4063177019-line-25)">▎</text><text class="terminal-4063177019-r6" x="1195.6" y="630" textLength="12.2" clip-path="url(#terminal-4063177019-line-25)">│</text><text class="terminal-4063177019-r2" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-4063177019-line-25)"> -</text><text class="terminal-4063177019-r6" x="12.2" y="654.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-26)">│</text><text class="terminal-4063177019-r8" x="36.6" y="654.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-26)">▊</text><text class="terminal-4063177019-r10" x="73.2" y="654.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-26)">T</text><text class="terminal-4063177019-r11" x="85.4" y="654.4" textLength="390.4" clip-path="url(#terminal-4063177019-line-26)">ype your answer and press Enter…</text><text class="terminal-4063177019-r9" x="1171.2" y="654.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-26)">▎</text><text class="terminal-4063177019-r6" x="1195.6" y="654.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-26)">│</text><text class="terminal-4063177019-r2" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-4063177019-line-26)"> -</text><text class="terminal-4063177019-r6" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-27)">│</text><text class="terminal-4063177019-r8" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-27)">▊</text><text class="terminal-4063177019-r9" x="48.8" y="678.8" textLength="1122.4" clip-path="url(#terminal-4063177019-line-27)">▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁</text><text class="terminal-4063177019-r9" x="1171.2" y="678.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-27)">▎</text><text class="terminal-4063177019-r6" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-27)">│</text><text class="terminal-4063177019-r2" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-4063177019-line-27)"> -</text><text class="terminal-4063177019-r6" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-4063177019-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-4063177019-r2" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-4063177019-line-28)"> -</text><text class="terminal-4063177019-r12" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-4063177019-line-29)">^Y</text><text class="terminal-4063177019-r4" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-4063177019-line-29)"> copy · </text><text class="terminal-4063177019-r12" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-4063177019-line-29)">^O</text><text class="terminal-4063177019-r4" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-4063177019-line-29)"> expand · </text><text class="terminal-4063177019-r12" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-4063177019-line-29)">esc</text><text class="terminal-4063177019-r4" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-4063177019-line-29)"> interrupt · </text><text class="terminal-4063177019-r12" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-4063177019-line-29)">^C</text><text class="terminal-4063177019-r4" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-4063177019-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_error.raw b/tests/__snapshots__/test_tui_snapshots/test_code_error.raw deleted file mode 100644 index 5a40a2d3..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_error.raw +++ /dev/null @@ -1,184 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-2239750680-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-2239750680-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-2239750680-r1 { fill: #c5c8c6 } -.terminal-2239750680-r2 { fill: #614fd2;font-weight: bold } -.terminal-2239750680-r3 { fill: #939393 } -.terminal-2239750680-r4 { fill: #e0e0e0 } -.terminal-2239750680-r5 { fill: #614fd2 } -.terminal-2239750680-r6 { fill: #38bdf8;font-weight: bold } -.terminal-2239750680-r7 { fill: #f04438 } -.terminal-2239750680-r8 { fill: #3a3f55 } -.terminal-2239750680-r9 { fill: #121212 } -.terminal-2239750680-r10 { fill: #676767 } -.terminal-2239750680-r11 { fill: #000000 } -.terminal-2239750680-r12 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-2239750680-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-2239750680-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2239750680-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-2239750680-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-2239750680-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="219.6" y="343.1" width="1000.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="367.5" width="695.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="61" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="73.2" y="660.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="463.6" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="109.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="122" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="684.7" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-2239750680-matrix"> - <text class="terminal-2239750680-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-2239750680-line-0)"> -</text><text class="terminal-2239750680-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-2239750680-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-2239750680-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-2239750680-line-1)"> -</text><text class="terminal-2239750680-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-2239750680-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-2239750680-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-2)"> -</text><text class="terminal-2239750680-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-2239750680-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-2239750680-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-2239750680-line-3)"> -</text><text class="terminal-2239750680-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-2239750680-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-2239750680-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-2239750680-line-4)"> -</text><text class="terminal-2239750680-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-2239750680-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-2239750680-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-2239750680-line-5)"> -</text><text class="terminal-2239750680-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-2239750680-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-2239750680-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-2239750680-line-6)"> -</text><text class="terminal-2239750680-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-2239750680-line-7)">v9.9.9</text><text class="terminal-2239750680-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-7)"> -</text><text class="terminal-2239750680-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-2239750680-line-8)"> -</text><text class="terminal-2239750680-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-2239750680-line-9)">Thread: default</text><text class="terminal-2239750680-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-2239750680-line-9)"> -</text><text class="terminal-2239750680-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-2239750680-line-10)"> -</text><text class="terminal-2239750680-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-2239750680-line-11)">Ready to code! What would you like to build?</text><text class="terminal-2239750680-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-2239750680-line-11)"> -</text><text class="terminal-2239750680-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-2239750680-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-2239750680-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-12)"> -</text><text class="terminal-2239750680-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-2239750680-line-13)"> -</text><text class="terminal-2239750680-r6" x="24.4" y="361.6" textLength="195.2" clip-path="url(#terminal-2239750680-line-14)">» deploy to prod</text><text class="terminal-2239750680-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-2239750680-line-14)"> -</text><text class="terminal-2239750680-r7" x="24.4" y="386" textLength="500.2" clip-path="url(#terminal-2239750680-line-15)">✗ gateway unreachable: connection refused</text><text class="terminal-2239750680-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-2239750680-line-15)"> -</text><text class="terminal-2239750680-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-2239750680-line-16)"> -</text><text class="terminal-2239750680-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-17)"> -</text><text class="terminal-2239750680-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-2239750680-line-18)"> -</text><text class="terminal-2239750680-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-2239750680-line-19)"> -</text><text class="terminal-2239750680-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-2239750680-line-20)"> -</text><text class="terminal-2239750680-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-2239750680-line-21)"> -</text><text class="terminal-2239750680-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-22)"> -</text><text class="terminal-2239750680-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-2239750680-line-23)"> -</text><text class="terminal-2239750680-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-2239750680-line-24)"> -</text><text class="terminal-2239750680-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-2239750680-line-25)"> -</text><text class="terminal-2239750680-r8" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-2239750680-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-2239750680-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-2239750680-line-26)"> -</text><text class="terminal-2239750680-r8" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-27)">│</text><text class="terminal-2239750680-r5" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-27)">></text><text class="terminal-2239750680-r9" x="61" y="678.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-27)">A</text><text class="terminal-2239750680-r10" x="73.2" y="678.8" textLength="390.4" clip-path="url(#terminal-2239750680-line-27)">sk the agent to build something…</text><text class="terminal-2239750680-r8" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-27)">│</text><text class="terminal-2239750680-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-2239750680-line-27)"> -</text><text class="terminal-2239750680-r11" x="12.2" y="703.2" textLength="97.6" clip-path="url(#terminal-2239750680-line-28)"> manual </text><text class="terminal-2239750680-r3" x="122" y="703.2" textLength="73.2" clip-path="url(#terminal-2239750680-line-28)">~/demo</text><text class="terminal-2239750680-r3" x="207.4" y="703.2" textLength="73.2" clip-path="url(#terminal-2239750680-line-28)">↗ main</text><text class="terminal-2239750680-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-2239750680-line-28)"> -</text><text class="terminal-2239750680-r12" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-2239750680-line-29)">^Y</text><text class="terminal-2239750680-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-2239750680-line-29)"> copy · </text><text class="terminal-2239750680-r12" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-2239750680-line-29)">^O</text><text class="terminal-2239750680-r3" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-2239750680-line-29)"> expand · </text><text class="terminal-2239750680-r12" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-2239750680-line-29)">esc</text><text class="terminal-2239750680-r3" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-2239750680-line-29)"> interrupt · </text><text class="terminal-2239750680-r12" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-2239750680-line-29)">^C</text><text class="terminal-2239750680-r3" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-2239750680-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_splash.raw b/tests/__snapshots__/test_tui_snapshots/test_code_splash.raw deleted file mode 100644 index b9553b2a..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_splash.raw +++ /dev/null @@ -1,182 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-189530595-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-189530595-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-189530595-r1 { fill: #c5c8c6 } -.terminal-189530595-r2 { fill: #614fd2;font-weight: bold } -.terminal-189530595-r3 { fill: #939393 } -.terminal-189530595-r4 { fill: #e0e0e0 } -.terminal-189530595-r5 { fill: #614fd2 } -.terminal-189530595-r6 { fill: #3a3f55 } -.terminal-189530595-r7 { fill: #121212 } -.terminal-189530595-r8 { fill: #676767 } -.terminal-189530595-r9 { fill: #000000 } -.terminal-189530595-r10 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-189530595-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-189530595-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-189530595-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-189530595-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-189530595-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="61" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="73.2" y="660.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="463.6" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="109.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="122" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="684.7" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-189530595-matrix"> - <text class="terminal-189530595-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-189530595-line-0)"> -</text><text class="terminal-189530595-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-189530595-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-189530595-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-189530595-line-1)"> -</text><text class="terminal-189530595-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-189530595-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-189530595-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-189530595-line-2)"> -</text><text class="terminal-189530595-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-189530595-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-189530595-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-189530595-line-3)"> -</text><text class="terminal-189530595-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-189530595-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-189530595-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-189530595-line-4)"> -</text><text class="terminal-189530595-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-189530595-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-189530595-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-189530595-line-5)"> -</text><text class="terminal-189530595-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-189530595-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-189530595-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-189530595-line-6)"> -</text><text class="terminal-189530595-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-189530595-line-7)">v9.9.9</text><text class="terminal-189530595-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-189530595-line-7)"> -</text><text class="terminal-189530595-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-189530595-line-8)"> -</text><text class="terminal-189530595-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-189530595-line-9)">Thread: default</text><text class="terminal-189530595-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-189530595-line-9)"> -</text><text class="terminal-189530595-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-189530595-line-10)"> -</text><text class="terminal-189530595-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-189530595-line-11)">Ready to code! What would you like to build?</text><text class="terminal-189530595-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-189530595-line-11)"> -</text><text class="terminal-189530595-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-189530595-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-189530595-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-189530595-line-12)"> -</text><text class="terminal-189530595-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-189530595-line-13)"> -</text><text class="terminal-189530595-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-189530595-line-14)"> -</text><text class="terminal-189530595-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-189530595-line-15)"> -</text><text class="terminal-189530595-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-189530595-line-16)"> -</text><text class="terminal-189530595-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-189530595-line-17)"> -</text><text class="terminal-189530595-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-189530595-line-18)"> -</text><text class="terminal-189530595-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-189530595-line-19)"> -</text><text class="terminal-189530595-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-189530595-line-20)"> -</text><text class="terminal-189530595-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-189530595-line-21)"> -</text><text class="terminal-189530595-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-189530595-line-22)"> -</text><text class="terminal-189530595-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-189530595-line-23)"> -</text><text class="terminal-189530595-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-189530595-line-24)"> -</text><text class="terminal-189530595-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-189530595-line-25)"> -</text><text class="terminal-189530595-r6" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-189530595-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-189530595-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-189530595-line-26)"> -</text><text class="terminal-189530595-r6" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-189530595-line-27)">│</text><text class="terminal-189530595-r5" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-189530595-line-27)">></text><text class="terminal-189530595-r7" x="61" y="678.8" textLength="12.2" clip-path="url(#terminal-189530595-line-27)">A</text><text class="terminal-189530595-r8" x="73.2" y="678.8" textLength="390.4" clip-path="url(#terminal-189530595-line-27)">sk the agent to build something…</text><text class="terminal-189530595-r6" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-189530595-line-27)">│</text><text class="terminal-189530595-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-189530595-line-27)"> -</text><text class="terminal-189530595-r9" x="12.2" y="703.2" textLength="97.6" clip-path="url(#terminal-189530595-line-28)"> manual </text><text class="terminal-189530595-r3" x="122" y="703.2" textLength="73.2" clip-path="url(#terminal-189530595-line-28)">~/demo</text><text class="terminal-189530595-r3" x="207.4" y="703.2" textLength="73.2" clip-path="url(#terminal-189530595-line-28)">↗ main</text><text class="terminal-189530595-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-189530595-line-28)"> -</text><text class="terminal-189530595-r10" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-189530595-line-29)">^Y</text><text class="terminal-189530595-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-189530595-line-29)"> copy · </text><text class="terminal-189530595-r10" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-189530595-line-29)">^O</text><text class="terminal-189530595-r3" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-189530595-line-29)"> expand · </text><text class="terminal-189530595-r10" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-189530595-line-29)">esc</text><text class="terminal-189530595-r3" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-189530595-line-29)"> interrupt · </text><text class="terminal-189530595-r10" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-189530595-line-29)">^C</text><text class="terminal-189530595-r3" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-189530595-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_status_auto_approve.raw b/tests/__snapshots__/test_tui_snapshots/test_code_status_auto_approve.raw deleted file mode 100644 index 39329055..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_status_auto_approve.raw +++ /dev/null @@ -1,182 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-3300524382-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-3300524382-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-3300524382-r1 { fill: #c5c8c6 } -.terminal-3300524382-r2 { fill: #614fd2;font-weight: bold } -.terminal-3300524382-r3 { fill: #939393 } -.terminal-3300524382-r4 { fill: #e0e0e0 } -.terminal-3300524382-r5 { fill: #614fd2 } -.terminal-3300524382-r6 { fill: #3a3f55 } -.terminal-3300524382-r7 { fill: #121212 } -.terminal-3300524382-r8 { fill: #676767 } -.terminal-3300524382-r9 { fill: #000000 } -.terminal-3300524382-r10 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-3300524382-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-3300524382-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3300524382-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-3300524382-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-3300524382-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="61" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="73.2" y="660.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="463.6" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="85.4" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="170.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="183" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="256.2" y="684.7" width="963.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-3300524382-matrix"> - <text class="terminal-3300524382-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-3300524382-line-0)"> -</text><text class="terminal-3300524382-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-3300524382-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-3300524382-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-3300524382-line-1)"> -</text><text class="terminal-3300524382-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-3300524382-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-3300524382-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-2)"> -</text><text class="terminal-3300524382-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-3300524382-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-3300524382-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-3300524382-line-3)"> -</text><text class="terminal-3300524382-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-3300524382-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-3300524382-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-3300524382-line-4)"> -</text><text class="terminal-3300524382-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-3300524382-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-3300524382-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-3300524382-line-5)"> -</text><text class="terminal-3300524382-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-3300524382-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-3300524382-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-3300524382-line-6)"> -</text><text class="terminal-3300524382-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-3300524382-line-7)">v9.9.9</text><text class="terminal-3300524382-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-7)"> -</text><text class="terminal-3300524382-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-3300524382-line-8)"> -</text><text class="terminal-3300524382-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-3300524382-line-9)">Thread: default</text><text class="terminal-3300524382-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-3300524382-line-9)"> -</text><text class="terminal-3300524382-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-3300524382-line-10)"> -</text><text class="terminal-3300524382-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-3300524382-line-11)">Ready to code! What would you like to build?</text><text class="terminal-3300524382-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-3300524382-line-11)"> -</text><text class="terminal-3300524382-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-3300524382-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-3300524382-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-12)"> -</text><text class="terminal-3300524382-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-3300524382-line-13)"> -</text><text class="terminal-3300524382-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-3300524382-line-14)"> -</text><text class="terminal-3300524382-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-3300524382-line-15)"> -</text><text class="terminal-3300524382-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-3300524382-line-16)"> -</text><text class="terminal-3300524382-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-17)"> -</text><text class="terminal-3300524382-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-3300524382-line-18)"> -</text><text class="terminal-3300524382-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-3300524382-line-19)"> -</text><text class="terminal-3300524382-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-3300524382-line-20)"> -</text><text class="terminal-3300524382-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-3300524382-line-21)"> -</text><text class="terminal-3300524382-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-22)"> -</text><text class="terminal-3300524382-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-3300524382-line-23)"> -</text><text class="terminal-3300524382-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-3300524382-line-24)"> -</text><text class="terminal-3300524382-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-3300524382-line-25)"> -</text><text class="terminal-3300524382-r6" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-3300524382-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-3300524382-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-3300524382-line-26)"> -</text><text class="terminal-3300524382-r6" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-27)">│</text><text class="terminal-3300524382-r5" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-27)">></text><text class="terminal-3300524382-r7" x="61" y="678.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-27)">A</text><text class="terminal-3300524382-r8" x="73.2" y="678.8" textLength="390.4" clip-path="url(#terminal-3300524382-line-27)">sk the agent to build something…</text><text class="terminal-3300524382-r6" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-27)">│</text><text class="terminal-3300524382-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-3300524382-line-27)"> -</text><text class="terminal-3300524382-r9" x="12.2" y="703.2" textLength="73.2" clip-path="url(#terminal-3300524382-line-28)"> auto </text><text class="terminal-3300524382-r3" x="97.6" y="703.2" textLength="73.2" clip-path="url(#terminal-3300524382-line-28)">~/demo</text><text class="terminal-3300524382-r3" x="183" y="703.2" textLength="73.2" clip-path="url(#terminal-3300524382-line-28)">↗ main</text><text class="terminal-3300524382-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-3300524382-line-28)"> -</text><text class="terminal-3300524382-r10" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-3300524382-line-29)">^Y</text><text class="terminal-3300524382-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-3300524382-line-29)"> copy · </text><text class="terminal-3300524382-r10" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-3300524382-line-29)">^O</text><text class="terminal-3300524382-r3" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-3300524382-line-29)"> expand · </text><text class="terminal-3300524382-r10" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-3300524382-line-29)">esc</text><text class="terminal-3300524382-r3" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-3300524382-line-29)"> interrupt · </text><text class="terminal-3300524382-r10" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-3300524382-line-29)">^C</text><text class="terminal-3300524382-r3" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-3300524382-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_streaming_reply.raw b/tests/__snapshots__/test_tui_snapshots/test_code_streaming_reply.raw deleted file mode 100644 index b3ae8b11..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_streaming_reply.raw +++ /dev/null @@ -1,183 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-730392279-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-730392279-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-730392279-r1 { fill: #c5c8c6 } -.terminal-730392279-r2 { fill: #614fd2;font-weight: bold } -.terminal-730392279-r3 { fill: #939393 } -.terminal-730392279-r4 { fill: #e0e0e0 } -.terminal-730392279-r5 { fill: #614fd2 } -.terminal-730392279-r6 { fill: #38bdf8;font-weight: bold } -.terminal-730392279-r7 { fill: #3a3f55 } -.terminal-730392279-r8 { fill: #121212 } -.terminal-730392279-r9 { fill: #676767 } -.terminal-730392279-r10 { fill: #000000 } -.terminal-730392279-r11 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-730392279-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-730392279-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-730392279-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-730392279-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-730392279-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="219.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="244" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="902.8" y="367.5" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="61" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="73.2" y="660.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="463.6" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="109.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="122" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="684.7" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-730392279-matrix"> - <text class="terminal-730392279-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-730392279-line-0)"> -</text><text class="terminal-730392279-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-730392279-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-730392279-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-730392279-line-1)"> -</text><text class="terminal-730392279-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-730392279-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-730392279-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-730392279-line-2)"> -</text><text class="terminal-730392279-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-730392279-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-730392279-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-730392279-line-3)"> -</text><text class="terminal-730392279-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-730392279-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-730392279-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-730392279-line-4)"> -</text><text class="terminal-730392279-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-730392279-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-730392279-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-730392279-line-5)"> -</text><text class="terminal-730392279-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-730392279-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-730392279-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-730392279-line-6)"> -</text><text class="terminal-730392279-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-730392279-line-7)">v9.9.9</text><text class="terminal-730392279-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-730392279-line-7)"> -</text><text class="terminal-730392279-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-730392279-line-8)"> -</text><text class="terminal-730392279-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-730392279-line-9)">Thread: default</text><text class="terminal-730392279-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-730392279-line-9)"> -</text><text class="terminal-730392279-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-730392279-line-10)"> -</text><text class="terminal-730392279-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-730392279-line-11)">Ready to code! What would you like to build?</text><text class="terminal-730392279-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-730392279-line-11)"> -</text><text class="terminal-730392279-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-730392279-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-730392279-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-730392279-line-12)"> -</text><text class="terminal-730392279-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-730392279-line-13)"> -</text><text class="terminal-730392279-r6" x="24.4" y="361.6" textLength="219.6" clip-path="url(#terminal-730392279-line-14)">» explain the plan</text><text class="terminal-730392279-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-730392279-line-14)"> -</text><text class="terminal-730392279-r4" x="24.4" y="386" textLength="878.4" clip-path="url(#terminal-730392279-line-15)">Here's the plan. First **scaffold** the project, then wire up the tests.</text><text class="terminal-730392279-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-730392279-line-15)"> -</text><text class="terminal-730392279-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-730392279-line-16)"> -</text><text class="terminal-730392279-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-730392279-line-17)"> -</text><text class="terminal-730392279-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-730392279-line-18)"> -</text><text class="terminal-730392279-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-730392279-line-19)"> -</text><text class="terminal-730392279-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-730392279-line-20)"> -</text><text class="terminal-730392279-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-730392279-line-21)"> -</text><text class="terminal-730392279-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-730392279-line-22)"> -</text><text class="terminal-730392279-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-730392279-line-23)"> -</text><text class="terminal-730392279-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-730392279-line-24)"> -</text><text class="terminal-730392279-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-730392279-line-25)"> -</text><text class="terminal-730392279-r7" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-730392279-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-730392279-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-730392279-line-26)"> -</text><text class="terminal-730392279-r7" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-730392279-line-27)">│</text><text class="terminal-730392279-r5" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-730392279-line-27)">></text><text class="terminal-730392279-r8" x="61" y="678.8" textLength="12.2" clip-path="url(#terminal-730392279-line-27)">A</text><text class="terminal-730392279-r9" x="73.2" y="678.8" textLength="390.4" clip-path="url(#terminal-730392279-line-27)">sk the agent to build something…</text><text class="terminal-730392279-r7" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-730392279-line-27)">│</text><text class="terminal-730392279-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-730392279-line-27)"> -</text><text class="terminal-730392279-r10" x="12.2" y="703.2" textLength="97.6" clip-path="url(#terminal-730392279-line-28)"> manual </text><text class="terminal-730392279-r3" x="122" y="703.2" textLength="73.2" clip-path="url(#terminal-730392279-line-28)">~/demo</text><text class="terminal-730392279-r3" x="207.4" y="703.2" textLength="73.2" clip-path="url(#terminal-730392279-line-28)">↗ main</text><text class="terminal-730392279-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-730392279-line-28)"> -</text><text class="terminal-730392279-r11" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-730392279-line-29)">^Y</text><text class="terminal-730392279-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-730392279-line-29)"> copy · </text><text class="terminal-730392279-r11" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-730392279-line-29)">^O</text><text class="terminal-730392279-r3" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-730392279-line-29)"> expand · </text><text class="terminal-730392279-r11" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-730392279-line-29)">esc</text><text class="terminal-730392279-r3" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-730392279-line-29)"> interrupt · </text><text class="terminal-730392279-r11" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-730392279-line-29)">^C</text><text class="terminal-730392279-r3" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-730392279-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_tool_output_collapsed.raw b/tests/__snapshots__/test_tui_snapshots/test_code_tool_output_collapsed.raw deleted file mode 100644 index 62998d04..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_tool_output_collapsed.raw +++ /dev/null @@ -1,185 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-246752052-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-246752052-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-246752052-r1 { fill: #c5c8c6 } -.terminal-246752052-r2 { fill: #614fd2;font-weight: bold } -.terminal-246752052-r3 { fill: #939393 } -.terminal-246752052-r4 { fill: #e0e0e0 } -.terminal-246752052-r5 { fill: #614fd2 } -.terminal-246752052-r6 { fill: #38bdf8;font-weight: bold } -.terminal-246752052-r7 { fill: #8a8f98 } -.terminal-246752052-r8 { fill: #8a8f98;font-style: italic; } -.terminal-246752052-r9 { fill: #3a3f55 } -.terminal-246752052-r10 { fill: #121212 } -.terminal-246752052-r11 { fill: #676767 } -.terminal-246752052-r12 { fill: #000000 } -.terminal-246752052-r13 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-246752052-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-246752052-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-246752052-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-246752052-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-246752052-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="343.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="244" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="268.4" y="367.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="391.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="391.9" width="402.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="391.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="416.3" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="416.3" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="440.7" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="440.7" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="465.1" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="658.8" y="465.1" width="231.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="465.1" width="329.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="61" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="73.2" y="660.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="463.6" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="109.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="122" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="684.7" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-246752052-matrix"> - <text class="terminal-246752052-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-246752052-line-0)"> -</text><text class="terminal-246752052-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-246752052-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-246752052-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-246752052-line-1)"> -</text><text class="terminal-246752052-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-246752052-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-246752052-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-246752052-line-2)"> -</text><text class="terminal-246752052-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-246752052-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-246752052-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-246752052-line-3)"> -</text><text class="terminal-246752052-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-246752052-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-246752052-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-246752052-line-4)"> -</text><text class="terminal-246752052-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-246752052-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-246752052-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-246752052-line-5)"> -</text><text class="terminal-246752052-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-246752052-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-246752052-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-246752052-line-6)"> -</text><text class="terminal-246752052-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-246752052-line-7)">v9.9.9</text><text class="terminal-246752052-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-246752052-line-7)"> -</text><text class="terminal-246752052-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-246752052-line-8)"> -</text><text class="terminal-246752052-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-246752052-line-9)">Thread: default</text><text class="terminal-246752052-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-246752052-line-9)"> -</text><text class="terminal-246752052-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-246752052-line-10)"> -</text><text class="terminal-246752052-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-246752052-line-11)">Ready to code! What would you like to build?</text><text class="terminal-246752052-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-246752052-line-11)"> -</text><text class="terminal-246752052-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-246752052-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-246752052-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-246752052-line-12)"> -</text><text class="terminal-246752052-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-246752052-line-13)"> -</text><text class="terminal-246752052-r6" x="24.4" y="361.6" textLength="183" clip-path="url(#terminal-246752052-line-14)">» run the tests</text><text class="terminal-246752052-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-246752052-line-14)"> -</text><text class="terminal-246752052-r7" x="24.4" y="386" textLength="244" clip-path="url(#terminal-246752052-line-15)">→ execute(pytest -q)</text><text class="terminal-246752052-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-246752052-line-15)"> -</text><text class="terminal-246752052-r7" x="24.4" y="410.4" textLength="134.2" clip-path="url(#terminal-246752052-line-16)">  execute: </text><text class="terminal-246752052-r7" x="158.6" y="410.4" textLength="402.6" clip-path="url(#terminal-246752052-line-16)">tests/test_module_0.py .... [ 0%]</text><text class="terminal-246752052-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-246752052-line-16)"> -</text><text class="terminal-246752052-r7" x="24.4" y="434.8" textLength="414.8" clip-path="url(#terminal-246752052-line-17)">tests/test_module_1.py .... [ 10%]</text><text class="terminal-246752052-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-246752052-line-17)"> -</text><text class="terminal-246752052-r7" x="24.4" y="459.2" textLength="414.8" clip-path="url(#terminal-246752052-line-18)">tests/test_module_2.py .... [ 20%]</text><text class="terminal-246752052-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-246752052-line-18)"> -</text><text class="terminal-246752052-r7" x="24.4" y="483.6" textLength="634.4" clip-path="url(#terminal-246752052-line-19)">tests/test_module_3.py .... [ 30%] … (+4 more lines)</text><text class="terminal-246752052-r8" x="658.8" y="483.6" textLength="231.8" clip-path="url(#terminal-246752052-line-19)"> (Ctrl+O to expand)</text><text class="terminal-246752052-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-246752052-line-19)"> -</text><text class="terminal-246752052-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-246752052-line-20)"> -</text><text class="terminal-246752052-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-246752052-line-21)"> -</text><text class="terminal-246752052-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-246752052-line-22)"> -</text><text class="terminal-246752052-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-246752052-line-23)"> -</text><text class="terminal-246752052-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-246752052-line-24)"> -</text><text class="terminal-246752052-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-246752052-line-25)"> -</text><text class="terminal-246752052-r9" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-246752052-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-246752052-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-246752052-line-26)"> -</text><text class="terminal-246752052-r9" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-246752052-line-27)">│</text><text class="terminal-246752052-r5" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-246752052-line-27)">></text><text class="terminal-246752052-r10" x="61" y="678.8" textLength="12.2" clip-path="url(#terminal-246752052-line-27)">A</text><text class="terminal-246752052-r11" x="73.2" y="678.8" textLength="390.4" clip-path="url(#terminal-246752052-line-27)">sk the agent to build something…</text><text class="terminal-246752052-r9" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-246752052-line-27)">│</text><text class="terminal-246752052-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-246752052-line-27)"> -</text><text class="terminal-246752052-r12" x="12.2" y="703.2" textLength="97.6" clip-path="url(#terminal-246752052-line-28)"> manual </text><text class="terminal-246752052-r3" x="122" y="703.2" textLength="73.2" clip-path="url(#terminal-246752052-line-28)">~/demo</text><text class="terminal-246752052-r3" x="207.4" y="703.2" textLength="73.2" clip-path="url(#terminal-246752052-line-28)">↗ main</text><text class="terminal-246752052-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-246752052-line-28)"> -</text><text class="terminal-246752052-r13" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-246752052-line-29)">^Y</text><text class="terminal-246752052-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-246752052-line-29)"> copy · </text><text class="terminal-246752052-r13" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-246752052-line-29)">^O</text><text class="terminal-246752052-r3" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-246752052-line-29)"> expand · </text><text class="terminal-246752052-r13" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-246752052-line-29)">esc</text><text class="terminal-246752052-r3" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-246752052-line-29)"> interrupt · </text><text class="terminal-246752052-r13" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-246752052-line-29)">^C</text><text class="terminal-246752052-r3" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-246752052-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_tool_output_expanded.raw b/tests/__snapshots__/test_tui_snapshots/test_code_tool_output_expanded.raw deleted file mode 100644 index 1e11c720..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_tool_output_expanded.raw +++ /dev/null @@ -1,185 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-2792346064-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-2792346064-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-2792346064-r1 { fill: #c5c8c6 } -.terminal-2792346064-r2 { fill: #614fd2;font-weight: bold } -.terminal-2792346064-r3 { fill: #939393 } -.terminal-2792346064-r4 { fill: #e0e0e0 } -.terminal-2792346064-r5 { fill: #614fd2 } -.terminal-2792346064-r6 { fill: #38bdf8;font-weight: bold } -.terminal-2792346064-r7 { fill: #8a8f98 } -.terminal-2792346064-r8 { fill: #8a8f98;font-style: italic; } -.terminal-2792346064-r9 { fill: #3a3f55 } -.terminal-2792346064-r10 { fill: #121212 } -.terminal-2792346064-r11 { fill: #676767 } -.terminal-2792346064-r12 { fill: #000000 } -.terminal-2792346064-r13 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-2792346064-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-2792346064-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-2792346064-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-2792346064-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-2792346064-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="343.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="244" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="268.4" y="367.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="391.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="391.9" width="402.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="391.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="416.3" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="416.3" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="440.7" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="440.7" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="465.1" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="465.1" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="489.5" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="489.5" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="513.9" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="513.9" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="538.3" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="538.3" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="562.7" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="562.7" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="695.4" y="562.7" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="61" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="73.2" y="660.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="463.6" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="109.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="122" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="684.7" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-2792346064-matrix"> - <text class="terminal-2792346064-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-2792346064-line-0)"> -</text><text class="terminal-2792346064-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-2792346064-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-2792346064-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-2792346064-line-1)"> -</text><text class="terminal-2792346064-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-2792346064-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-2792346064-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-2)"> -</text><text class="terminal-2792346064-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-2792346064-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-2792346064-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-2792346064-line-3)"> -</text><text class="terminal-2792346064-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-2792346064-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-2792346064-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-2792346064-line-4)"> -</text><text class="terminal-2792346064-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-2792346064-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-2792346064-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-2792346064-line-5)"> -</text><text class="terminal-2792346064-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-2792346064-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-2792346064-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-2792346064-line-6)"> -</text><text class="terminal-2792346064-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-2792346064-line-7)">v9.9.9</text><text class="terminal-2792346064-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-7)"> -</text><text class="terminal-2792346064-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-2792346064-line-8)"> -</text><text class="terminal-2792346064-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-2792346064-line-9)">Thread: default</text><text class="terminal-2792346064-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-2792346064-line-9)"> -</text><text class="terminal-2792346064-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-2792346064-line-10)"> -</text><text class="terminal-2792346064-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-2792346064-line-11)">Ready to code! What would you like to build?</text><text class="terminal-2792346064-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-2792346064-line-11)"> -</text><text class="terminal-2792346064-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-2792346064-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-2792346064-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-12)"> -</text><text class="terminal-2792346064-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-2792346064-line-13)"> -</text><text class="terminal-2792346064-r6" x="24.4" y="361.6" textLength="183" clip-path="url(#terminal-2792346064-line-14)">» run the tests</text><text class="terminal-2792346064-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-2792346064-line-14)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="386" textLength="244" clip-path="url(#terminal-2792346064-line-15)">→ execute(pytest -q)</text><text class="terminal-2792346064-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-2792346064-line-15)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="410.4" textLength="134.2" clip-path="url(#terminal-2792346064-line-16)">  execute: </text><text class="terminal-2792346064-r7" x="158.6" y="410.4" textLength="402.6" clip-path="url(#terminal-2792346064-line-16)">tests/test_module_0.py .... [ 0%]</text><text class="terminal-2792346064-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-2792346064-line-16)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="434.8" textLength="414.8" clip-path="url(#terminal-2792346064-line-17)">tests/test_module_1.py .... [ 10%]</text><text class="terminal-2792346064-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-17)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="459.2" textLength="414.8" clip-path="url(#terminal-2792346064-line-18)">tests/test_module_2.py .... [ 20%]</text><text class="terminal-2792346064-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-2792346064-line-18)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="483.6" textLength="414.8" clip-path="url(#terminal-2792346064-line-19)">tests/test_module_3.py .... [ 30%]</text><text class="terminal-2792346064-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-2792346064-line-19)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="508" textLength="414.8" clip-path="url(#terminal-2792346064-line-20)">tests/test_module_4.py .... [ 40%]</text><text class="terminal-2792346064-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-2792346064-line-20)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="532.4" textLength="414.8" clip-path="url(#terminal-2792346064-line-21)">tests/test_module_5.py .... [ 50%]</text><text class="terminal-2792346064-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-2792346064-line-21)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="556.8" textLength="414.8" clip-path="url(#terminal-2792346064-line-22)">tests/test_module_6.py .... [ 60%]</text><text class="terminal-2792346064-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-22)"> -</text><text class="terminal-2792346064-r7" x="24.4" y="581.2" textLength="414.8" clip-path="url(#terminal-2792346064-line-23)">tests/test_module_7.py .... [ 70%]</text><text class="terminal-2792346064-r8" x="439.2" y="581.2" textLength="256.2" clip-path="url(#terminal-2792346064-line-23)"> (Ctrl+O to collapse)</text><text class="terminal-2792346064-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-2792346064-line-23)"> -</text><text class="terminal-2792346064-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-2792346064-line-24)"> -</text><text class="terminal-2792346064-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-2792346064-line-25)"> -</text><text class="terminal-2792346064-r9" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-2792346064-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-2792346064-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-2792346064-line-26)"> -</text><text class="terminal-2792346064-r9" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-27)">│</text><text class="terminal-2792346064-r5" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-27)">></text><text class="terminal-2792346064-r10" x="61" y="678.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-27)">A</text><text class="terminal-2792346064-r11" x="73.2" y="678.8" textLength="390.4" clip-path="url(#terminal-2792346064-line-27)">sk the agent to build something…</text><text class="terminal-2792346064-r9" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-27)">│</text><text class="terminal-2792346064-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-2792346064-line-27)"> -</text><text class="terminal-2792346064-r12" x="12.2" y="703.2" textLength="97.6" clip-path="url(#terminal-2792346064-line-28)"> manual </text><text class="terminal-2792346064-r3" x="122" y="703.2" textLength="73.2" clip-path="url(#terminal-2792346064-line-28)">~/demo</text><text class="terminal-2792346064-r3" x="207.4" y="703.2" textLength="73.2" clip-path="url(#terminal-2792346064-line-28)">↗ main</text><text class="terminal-2792346064-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-2792346064-line-28)"> -</text><text class="terminal-2792346064-r13" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-2792346064-line-29)">^Y</text><text class="terminal-2792346064-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-2792346064-line-29)"> copy · </text><text class="terminal-2792346064-r13" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-2792346064-line-29)">^O</text><text class="terminal-2792346064-r3" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-2792346064-line-29)"> expand · </text><text class="terminal-2792346064-r13" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-2792346064-line-29)">esc</text><text class="terminal-2792346064-r3" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-2792346064-line-29)"> interrupt · </text><text class="terminal-2792346064-r13" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-2792346064-line-29)">^C</text><text class="terminal-2792346064-r3" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-2792346064-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_transcript.raw b/tests/__snapshots__/test_tui_snapshots/test_code_transcript.raw deleted file mode 100644 index fb3fe8d0..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_transcript.raw +++ /dev/null @@ -1,186 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-1120851722-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-1120851722-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-1120851722-r1 { fill: #c5c8c6 } -.terminal-1120851722-r2 { fill: #614fd2;font-weight: bold } -.terminal-1120851722-r3 { fill: #939393 } -.terminal-1120851722-r4 { fill: #e0e0e0 } -.terminal-1120851722-r5 { fill: #614fd2 } -.terminal-1120851722-r6 { fill: #38bdf8;font-weight: bold } -.terminal-1120851722-r7 { fill: #e0e0e0;font-weight: bold } -.terminal-1120851722-r8 { fill: #58d1eb } -.terminal-1120851722-r9 { fill: #8a8f98 } -.terminal-1120851722-r10 { fill: #3a3f55 } -.terminal-1120851722-r11 { fill: #121212 } -.terminal-1120851722-r12 { fill: #676767 } -.terminal-1120851722-r13 { fill: #000000 } -.terminal-1120851722-r14 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-1120851722-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-1120851722-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-1120851722-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-1120851722-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-1120851722-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="343.1" width="902.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="367.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="367.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="416.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="61" y="416.3" width="1134.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="440.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="61" y="440.7" width="1134.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="440.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="465.1" width="244" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="268.4" y="465.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="489.5" width="170.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="489.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="489.5" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="61" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="73.2" y="660.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="463.6" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="109.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="122" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="684.7" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-1120851722-matrix"> - <text class="terminal-1120851722-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-1120851722-line-0)"> -</text><text class="terminal-1120851722-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-1120851722-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-1120851722-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-1120851722-line-1)"> -</text><text class="terminal-1120851722-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-1120851722-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-1120851722-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-2)"> -</text><text class="terminal-1120851722-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-1120851722-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-1120851722-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-1120851722-line-3)"> -</text><text class="terminal-1120851722-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-1120851722-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-1120851722-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-1120851722-line-4)"> -</text><text class="terminal-1120851722-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-1120851722-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-1120851722-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-1120851722-line-5)"> -</text><text class="terminal-1120851722-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-1120851722-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-1120851722-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-1120851722-line-6)"> -</text><text class="terminal-1120851722-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-1120851722-line-7)">v9.9.9</text><text class="terminal-1120851722-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-7)"> -</text><text class="terminal-1120851722-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-1120851722-line-8)"> -</text><text class="terminal-1120851722-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-1120851722-line-9)">Thread: default</text><text class="terminal-1120851722-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-1120851722-line-9)"> -</text><text class="terminal-1120851722-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-1120851722-line-10)"> -</text><text class="terminal-1120851722-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-1120851722-line-11)">Ready to code! What would you like to build?</text><text class="terminal-1120851722-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-1120851722-line-11)"> -</text><text class="terminal-1120851722-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-1120851722-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-1120851722-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-12)"> -</text><text class="terminal-1120851722-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-1120851722-line-13)"> -</text><text class="terminal-1120851722-r6" x="24.4" y="361.6" textLength="292.8" clip-path="url(#terminal-1120851722-line-14)">» add a /health endpoint</text><text class="terminal-1120851722-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-1120851722-line-14)"> -</text><text class="terminal-1120851722-r4" x="24.4" y="386" textLength="109.8" clip-path="url(#terminal-1120851722-line-15)">Adding a </text><text class="terminal-1120851722-r7" x="134.2" y="386" textLength="146.4" clip-path="url(#terminal-1120851722-line-15)">health check</text><text class="terminal-1120851722-r4" x="280.6" y="386" textLength="915" clip-path="url(#terminal-1120851722-line-15)">:                                                                          </text><text class="terminal-1120851722-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-1120851722-line-15)"> -</text><text class="terminal-1120851722-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-1120851722-line-16)"> -</text><text class="terminal-1120851722-r8" x="24.4" y="434.8" textLength="36.6" clip-path="url(#terminal-1120851722-line-17)"> 1 </text><text class="terminal-1120851722-r4" x="61" y="434.8" textLength="1134.6" clip-path="url(#terminal-1120851722-line-17)">New route                                                                                    </text><text class="terminal-1120851722-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-17)"> -</text><text class="terminal-1120851722-r8" x="24.4" y="459.2" textLength="36.6" clip-path="url(#terminal-1120851722-line-18)"> 2 </text><text class="terminal-1120851722-r4" x="61" y="459.2" textLength="1134.6" clip-path="url(#terminal-1120851722-line-18)">A test                                                                                       </text><text class="terminal-1120851722-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-1120851722-line-18)"> -</text><text class="terminal-1120851722-r9" x="24.4" y="483.6" textLength="244" clip-path="url(#terminal-1120851722-line-19)">→ write_file(app.py)</text><text class="terminal-1120851722-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-1120851722-line-19)"> -</text><text class="terminal-1120851722-r9" x="24.4" y="508" textLength="170.8" clip-path="url(#terminal-1120851722-line-20)">  write_file: </text><text class="terminal-1120851722-r9" x="195.2" y="508" textLength="280.6" clip-path="url(#terminal-1120851722-line-20)">wrote 8 lines to app.py</text><text class="terminal-1120851722-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-1120851722-line-20)"> -</text><text class="terminal-1120851722-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-1120851722-line-21)"> -</text><text class="terminal-1120851722-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-22)"> -</text><text class="terminal-1120851722-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-1120851722-line-23)"> -</text><text class="terminal-1120851722-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-1120851722-line-24)"> -</text><text class="terminal-1120851722-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-1120851722-line-25)"> -</text><text class="terminal-1120851722-r10" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-1120851722-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-1120851722-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-1120851722-line-26)"> -</text><text class="terminal-1120851722-r10" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-27)">│</text><text class="terminal-1120851722-r5" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-27)">></text><text class="terminal-1120851722-r11" x="61" y="678.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-27)">A</text><text class="terminal-1120851722-r12" x="73.2" y="678.8" textLength="390.4" clip-path="url(#terminal-1120851722-line-27)">sk the agent to build something…</text><text class="terminal-1120851722-r10" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-27)">│</text><text class="terminal-1120851722-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-1120851722-line-27)"> -</text><text class="terminal-1120851722-r13" x="12.2" y="703.2" textLength="97.6" clip-path="url(#terminal-1120851722-line-28)"> manual </text><text class="terminal-1120851722-r3" x="122" y="703.2" textLength="73.2" clip-path="url(#terminal-1120851722-line-28)">~/demo</text><text class="terminal-1120851722-r3" x="207.4" y="703.2" textLength="73.2" clip-path="url(#terminal-1120851722-line-28)">↗ main</text><text class="terminal-1120851722-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-1120851722-line-28)"> -</text><text class="terminal-1120851722-r14" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-1120851722-line-29)">^Y</text><text class="terminal-1120851722-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-1120851722-line-29)"> copy · </text><text class="terminal-1120851722-r14" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-1120851722-line-29)">^O</text><text class="terminal-1120851722-r3" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-1120851722-line-29)"> expand · </text><text class="terminal-1120851722-r14" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-1120851722-line-29)">esc</text><text class="terminal-1120851722-r3" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-1120851722-line-29)"> interrupt · </text><text class="terminal-1120851722-r14" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-1120851722-line-29)">^C</text><text class="terminal-1120851722-r3" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-1120851722-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_voice_listening.raw b/tests/__snapshots__/test_tui_snapshots/test_code_voice_listening.raw deleted file mode 100644 index 7b0288b7..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_voice_listening.raw +++ /dev/null @@ -1,180 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-3917210796-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-3917210796-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-3917210796-r1 { fill: #c5c8c6 } -.terminal-3917210796-r2 { fill: #614fd2;font-weight: bold } -.terminal-3917210796-r3 { fill: #939393 } -.terminal-3917210796-r4 { fill: #e0e0e0 } -.terminal-3917210796-r5 { fill: #614fd2 } -.terminal-3917210796-r6 { fill: #000000 } -.terminal-3917210796-r7 { fill: #22c55e } -.terminal-3917210796-r8 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-3917210796-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-3917210796-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3917210796-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-3917210796-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-3917210796-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="660.3" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="732" y="660.3" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="927.2" y="660.3" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="109.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="122" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="292.8" y="684.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="414.8" y="684.7" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="268.4" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="292.8" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="414.8" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="451.4" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="610" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="634.4" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="695.4" y="709.1" width="524.6" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-3917210796-matrix"> - <text class="terminal-3917210796-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-3917210796-line-0)"> -</text><text class="terminal-3917210796-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-3917210796-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-3917210796-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-3917210796-line-1)"> -</text><text class="terminal-3917210796-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-3917210796-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-3917210796-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-3917210796-line-2)"> -</text><text class="terminal-3917210796-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-3917210796-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-3917210796-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-3917210796-line-3)"> -</text><text class="terminal-3917210796-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-3917210796-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-3917210796-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-3917210796-line-4)"> -</text><text class="terminal-3917210796-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-3917210796-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-3917210796-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-3917210796-line-5)"> -</text><text class="terminal-3917210796-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-3917210796-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-3917210796-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-3917210796-line-6)"> -</text><text class="terminal-3917210796-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-3917210796-line-7)">v9.9.9</text><text class="terminal-3917210796-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-3917210796-line-7)"> -</text><text class="terminal-3917210796-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-3917210796-line-8)"> -</text><text class="terminal-3917210796-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-3917210796-line-9)">Thread: default</text><text class="terminal-3917210796-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-3917210796-line-9)"> -</text><text class="terminal-3917210796-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-3917210796-line-10)"> -</text><text class="terminal-3917210796-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-3917210796-line-11)">Ready to code! What would you like to build?</text><text class="terminal-3917210796-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-3917210796-line-11)"> -</text><text class="terminal-3917210796-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-3917210796-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-3917210796-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-3917210796-line-12)"> -</text><text class="terminal-3917210796-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-3917210796-line-13)"> -</text><text class="terminal-3917210796-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-3917210796-line-14)"> -</text><text class="terminal-3917210796-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-3917210796-line-15)"> -</text><text class="terminal-3917210796-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-3917210796-line-16)"> -</text><text class="terminal-3917210796-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-3917210796-line-17)"> -</text><text class="terminal-3917210796-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-3917210796-line-18)"> -</text><text class="terminal-3917210796-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-3917210796-line-19)"> -</text><text class="terminal-3917210796-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-3917210796-line-20)"> -</text><text class="terminal-3917210796-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-3917210796-line-21)"> -</text><text class="terminal-3917210796-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-3917210796-line-22)"> -</text><text class="terminal-3917210796-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-3917210796-line-23)"> -</text><text class="terminal-3917210796-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-3917210796-line-24)"> -</text><text class="terminal-3917210796-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-3917210796-line-25)"> -</text><text class="terminal-3917210796-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-3917210796-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-3917210796-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-3917210796-line-26)"> -</text><text class="terminal-3917210796-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-3917210796-line-27)">│</text><text class="terminal-3917210796-r5" x="280.6" y="678.8" textLength="36.6" clip-path="url(#terminal-3917210796-line-27)">▁▃▅</text><text class="terminal-3917210796-r4" x="317.2" y="678.8" textLength="414.8" clip-path="url(#terminal-3917210796-line-27)"> Listening — speak your request   </text><text class="terminal-3917210796-r3" x="732" y="678.8" textLength="195.2" clip-path="url(#terminal-3917210796-line-27)">(Ctrl-V to type)</text><text class="terminal-3917210796-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3917210796-line-27)">│</text><text class="terminal-3917210796-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-3917210796-line-27)"> -</text><text class="terminal-3917210796-r6" x="12.2" y="703.2" textLength="97.6" clip-path="url(#terminal-3917210796-line-28)"> manual </text><text class="terminal-3917210796-r3" x="122" y="703.2" textLength="73.2" clip-path="url(#terminal-3917210796-line-28)">~/demo</text><text class="terminal-3917210796-r3" x="207.4" y="703.2" textLength="73.2" clip-path="url(#terminal-3917210796-line-28)">↗ main</text><text class="terminal-3917210796-r7" x="292.8" y="703.2" textLength="122" clip-path="url(#terminal-3917210796-line-28)">● voice on</text><text class="terminal-3917210796-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-3917210796-line-28)"> -</text><text class="terminal-3917210796-r8" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-3917210796-line-29)">^Y</text><text class="terminal-3917210796-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-3917210796-line-29)"> copy · </text><text class="terminal-3917210796-r8" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-3917210796-line-29)">^V</text><text class="terminal-3917210796-r3" x="158.6" y="727.6" textLength="109.8" clip-path="url(#terminal-3917210796-line-29)"> voice · </text><text class="terminal-3917210796-r8" x="268.4" y="727.6" textLength="24.4" clip-path="url(#terminal-3917210796-line-29)">^O</text><text class="terminal-3917210796-r3" x="292.8" y="727.6" textLength="122" clip-path="url(#terminal-3917210796-line-29)"> expand · </text><text class="terminal-3917210796-r8" x="414.8" y="727.6" textLength="36.6" clip-path="url(#terminal-3917210796-line-29)">esc</text><text class="terminal-3917210796-r3" x="451.4" y="727.6" textLength="158.6" clip-path="url(#terminal-3917210796-line-29)"> interrupt · </text><text class="terminal-3917210796-r8" x="610" y="727.6" textLength="24.4" clip-path="url(#terminal-3917210796-line-29)">^C</text><text class="terminal-3917210796-r3" x="634.4" y="727.6" textLength="61" clip-path="url(#terminal-3917210796-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_code_working_spinner.raw b/tests/__snapshots__/test_tui_snapshots/test_code_working_spinner.raw deleted file mode 100644 index 6bcc995d..00000000 --- a/tests/__snapshots__/test_tui_snapshots/test_code_working_spinner.raw +++ /dev/null @@ -1,183 +0,0 @@ -<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> - <!-- Generated with Rich https://www.textualize.io --> - <style> - - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Regular"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "Fira Code"; - src: local("FiraCode-Bold"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), - url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); - font-style: bold; - font-weight: 700; - } - - .terminal-3227114883-matrix { - font-family: Fira Code, monospace; - font-size: 20px; - line-height: 24.4px; - font-variant-east-asian: full-width; - } - - .terminal-3227114883-title { - font-size: 18px; - font-weight: bold; - font-family: arial; - } - - .terminal-3227114883-r1 { fill: #c5c8c6 } -.terminal-3227114883-r2 { fill: #614fd2;font-weight: bold } -.terminal-3227114883-r3 { fill: #939393 } -.terminal-3227114883-r4 { fill: #e0e0e0 } -.terminal-3227114883-r5 { fill: #614fd2 } -.terminal-3227114883-r6 { fill: #38bdf8;font-weight: bold } -.terminal-3227114883-r7 { fill: #3a3f55 } -.terminal-3227114883-r8 { fill: #121212 } -.terminal-3227114883-r9 { fill: #676767 } -.terminal-3227114883-r10 { fill: #000000 } -.terminal-3227114883-r11 { fill: #939393;font-weight: bold } - </style> - - <defs> - <clipPath id="terminal-3227114883-clip-terminal"> - <rect x="0" y="0" width="1219.0" height="731.0" /> - </clipPath> - <clipPath id="terminal-3227114883-line-0"> - <rect x="0" y="1.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-1"> - <rect x="0" y="25.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-2"> - <rect x="0" y="50.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-3"> - <rect x="0" y="74.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-4"> - <rect x="0" y="99.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-5"> - <rect x="0" y="123.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-6"> - <rect x="0" y="147.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-7"> - <rect x="0" y="172.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-8"> - <rect x="0" y="196.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-9"> - <rect x="0" y="221.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-10"> - <rect x="0" y="245.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-11"> - <rect x="0" y="269.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-12"> - <rect x="0" y="294.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-13"> - <rect x="0" y="318.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-14"> - <rect x="0" y="343.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-15"> - <rect x="0" y="367.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-16"> - <rect x="0" y="391.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-17"> - <rect x="0" y="416.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-18"> - <rect x="0" y="440.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-19"> - <rect x="0" y="465.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-20"> - <rect x="0" y="489.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-21"> - <rect x="0" y="513.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-22"> - <rect x="0" y="538.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-23"> - <rect x="0" y="562.7" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-24"> - <rect x="0" y="587.1" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-25"> - <rect x="0" y="611.5" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-26"> - <rect x="0" y="635.9" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-27"> - <rect x="0" y="660.3" width="1220" height="24.65"/> - </clipPath> -<clipPath id="terminal-3227114883-line-28"> - <rect x="0" y="684.7" width="1220" height="24.65"/> - </clipPath> - </defs> - - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-3227114883-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Code</text> - <g transform="translate(26,22)"> - <circle cx="0" cy="0" r="7" fill="#ff5f57"/> - <circle cx="22" cy="0" r="7" fill="#febc2e"/> - <circle cx="44" cy="0" r="7" fill="#28c840"/> - </g> - - <g transform="translate(9, 41)" clip-path="url(#terminal-3227114883-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="221.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="269.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="841.8" y="294.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="343.1" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="587.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="587.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="48.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="61" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="73.2" y="660.3" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#0b0b0b" x="463.6" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#f59e0b" x="12.2" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="109.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="122" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="195.2" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="207.4" y="684.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="684.7" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="36.6" y="709.1" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="134.2" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="158.6" y="709.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="280.6" y="709.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="317.2" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="475.8" y="709.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="709.1" width="658.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-3227114883-matrix"> - <text class="terminal-3227114883-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-3227114883-line-0)"> -</text><text class="terminal-3227114883-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-3227114883-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-3227114883-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-3227114883-line-1)"> -</text><text class="terminal-3227114883-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-3227114883-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-3227114883-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-2)"> -</text><text class="terminal-3227114883-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-3227114883-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-3227114883-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-3227114883-line-3)"> -</text><text class="terminal-3227114883-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-3227114883-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-3227114883-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-3227114883-line-4)"> -</text><text class="terminal-3227114883-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-3227114883-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-3227114883-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-3227114883-line-5)"> -</text><text class="terminal-3227114883-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-3227114883-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-3227114883-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-3227114883-line-6)"> -</text><text class="terminal-3227114883-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-3227114883-line-7)">v9.9.9</text><text class="terminal-3227114883-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-7)"> -</text><text class="terminal-3227114883-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-3227114883-line-8)"> -</text><text class="terminal-3227114883-r3" x="24.4" y="239.6" textLength="183" clip-path="url(#terminal-3227114883-line-9)">Thread: default</text><text class="terminal-3227114883-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-3227114883-line-9)"> -</text><text class="terminal-3227114883-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-3227114883-line-10)"> -</text><text class="terminal-3227114883-r5" x="24.4" y="288.4" textLength="536.8" clip-path="url(#terminal-3227114883-line-11)">Ready to code! What would you like to build?</text><text class="terminal-3227114883-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-3227114883-line-11)"> -</text><text class="terminal-3227114883-r3" x="24.4" y="312.8" textLength="817.4" clip-path="url(#terminal-3227114883-line-12)">Tip: approve tools as they run, or pass --auto to skip the prompts.</text><text class="terminal-3227114883-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-12)"> -</text><text class="terminal-3227114883-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-3227114883-line-13)"> -</text><text class="terminal-3227114883-r6" x="24.4" y="361.6" textLength="256.2" clip-path="url(#terminal-3227114883-line-14)">» build a web scraper</text><text class="terminal-3227114883-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-3227114883-line-14)"> -</text><text class="terminal-3227114883-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-3227114883-line-15)"> -</text><text class="terminal-3227114883-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-3227114883-line-16)"> -</text><text class="terminal-3227114883-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-17)"> -</text><text class="terminal-3227114883-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-3227114883-line-18)"> -</text><text class="terminal-3227114883-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-3227114883-line-19)"> -</text><text class="terminal-3227114883-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-3227114883-line-20)"> -</text><text class="terminal-3227114883-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-3227114883-line-21)"> -</text><text class="terminal-3227114883-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-22)"> -</text><text class="terminal-3227114883-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-3227114883-line-23)"> -</text><text class="terminal-3227114883-r5" x="24.4" y="605.6" textLength="183" clip-path="url(#terminal-3227114883-line-24)">✶ Working… (7s)</text><text class="terminal-3227114883-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-3227114883-line-24)"> -</text><text class="terminal-3227114883-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-3227114883-line-25)"> -</text><text class="terminal-3227114883-r7" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-3227114883-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-3227114883-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-3227114883-line-26)"> -</text><text class="terminal-3227114883-r7" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-27)">│</text><text class="terminal-3227114883-r5" x="36.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-27)">></text><text class="terminal-3227114883-r8" x="61" y="678.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-27)">A</text><text class="terminal-3227114883-r9" x="73.2" y="678.8" textLength="390.4" clip-path="url(#terminal-3227114883-line-27)">sk the agent to build something…</text><text class="terminal-3227114883-r7" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-27)">│</text><text class="terminal-3227114883-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-3227114883-line-27)"> -</text><text class="terminal-3227114883-r10" x="12.2" y="703.2" textLength="97.6" clip-path="url(#terminal-3227114883-line-28)"> manual </text><text class="terminal-3227114883-r3" x="122" y="703.2" textLength="73.2" clip-path="url(#terminal-3227114883-line-28)">~/demo</text><text class="terminal-3227114883-r3" x="207.4" y="703.2" textLength="73.2" clip-path="url(#terminal-3227114883-line-28)">↗ main</text><text class="terminal-3227114883-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-3227114883-line-28)"> -</text><text class="terminal-3227114883-r11" x="12.2" y="727.6" textLength="24.4" clip-path="url(#terminal-3227114883-line-29)">^Y</text><text class="terminal-3227114883-r3" x="36.6" y="727.6" textLength="97.6" clip-path="url(#terminal-3227114883-line-29)"> copy · </text><text class="terminal-3227114883-r11" x="134.2" y="727.6" textLength="24.4" clip-path="url(#terminal-3227114883-line-29)">^O</text><text class="terminal-3227114883-r3" x="158.6" y="727.6" textLength="122" clip-path="url(#terminal-3227114883-line-29)"> expand · </text><text class="terminal-3227114883-r11" x="280.6" y="727.6" textLength="36.6" clip-path="url(#terminal-3227114883-line-29)">esc</text><text class="terminal-3227114883-r3" x="317.2" y="727.6" textLength="158.6" clip-path="url(#terminal-3227114883-line-29)"> interrupt · </text><text class="terminal-3227114883-r11" x="475.8" y="727.6" textLength="24.4" clip-path="url(#terminal-3227114883-line-29)">^C</text><text class="terminal-3227114883-r3" x="500.2" y="727.6" textLength="61" clip-path="url(#terminal-3227114883-line-29)"> quit</text> - </g> - </g> -</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_conversation.raw b/tests/__snapshots__/test_tui_snapshots/test_live_conversation.raw index ac1e4ddf..76bc4f51 100644 --- a/tests/__snapshots__/test_tui_snapshots/test_live_conversation.raw +++ b/tests/__snapshots__/test_tui_snapshots/test_live_conversation.raw @@ -19,161 +19,161 @@ font-weight: 700; } - .terminal-3002676011-matrix { + .terminal-1677736963-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3002676011-title { + .terminal-1677736963-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3002676011-r1 { fill: #c5c8c6 } -.terminal-3002676011-r2 { fill: #614fd2;font-weight: bold } -.terminal-3002676011-r3 { fill: #939393 } -.terminal-3002676011-r4 { fill: #e0e0e0 } -.terminal-3002676011-r5 { fill: #614fd2 } -.terminal-3002676011-r6 { fill: #38bdf8;font-weight: bold } -.terminal-3002676011-r7 { fill: #22c55e } + .terminal-1677736963-r1 { fill: #c5c8c6 } +.terminal-1677736963-r2 { fill: #614fd2;font-weight: bold } +.terminal-1677736963-r3 { fill: #939393 } +.terminal-1677736963-r4 { fill: #e0e0e0 } +.terminal-1677736963-r5 { fill: #614fd2 } +.terminal-1677736963-r6 { fill: #38bdf8;font-weight: bold } +.terminal-1677736963-r7 { fill: #22c55e } </style> <defs> - <clipPath id="terminal-3002676011-clip-terminal"> + <clipPath id="terminal-1677736963-clip-terminal"> <rect x="0" y="0" width="1219.0" height="731.0" /> </clipPath> - <clipPath id="terminal-3002676011-line-0"> + <clipPath id="terminal-1677736963-line-0"> <rect x="0" y="1.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-1"> +<clipPath id="terminal-1677736963-line-1"> <rect x="0" y="25.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-2"> +<clipPath id="terminal-1677736963-line-2"> <rect x="0" y="50.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-3"> +<clipPath id="terminal-1677736963-line-3"> <rect x="0" y="74.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-4"> +<clipPath id="terminal-1677736963-line-4"> <rect x="0" y="99.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-5"> +<clipPath id="terminal-1677736963-line-5"> <rect x="0" y="123.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-6"> +<clipPath id="terminal-1677736963-line-6"> <rect x="0" y="147.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-7"> +<clipPath id="terminal-1677736963-line-7"> <rect x="0" y="172.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-8"> +<clipPath id="terminal-1677736963-line-8"> <rect x="0" y="196.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-9"> +<clipPath id="terminal-1677736963-line-9"> <rect x="0" y="221.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-10"> +<clipPath id="terminal-1677736963-line-10"> <rect x="0" y="245.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-11"> +<clipPath id="terminal-1677736963-line-11"> <rect x="0" y="269.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-12"> +<clipPath id="terminal-1677736963-line-12"> <rect x="0" y="294.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-13"> +<clipPath id="terminal-1677736963-line-13"> <rect x="0" y="318.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-14"> +<clipPath id="terminal-1677736963-line-14"> <rect x="0" y="343.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-15"> +<clipPath id="terminal-1677736963-line-15"> <rect x="0" y="367.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-16"> +<clipPath id="terminal-1677736963-line-16"> <rect x="0" y="391.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-17"> +<clipPath id="terminal-1677736963-line-17"> <rect x="0" y="416.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-18"> +<clipPath id="terminal-1677736963-line-18"> <rect x="0" y="440.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-19"> +<clipPath id="terminal-1677736963-line-19"> <rect x="0" y="465.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-20"> +<clipPath id="terminal-1677736963-line-20"> <rect x="0" y="489.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-21"> +<clipPath id="terminal-1677736963-line-21"> <rect x="0" y="513.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-22"> +<clipPath id="terminal-1677736963-line-22"> <rect x="0" y="538.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-23"> +<clipPath id="terminal-1677736963-line-23"> <rect x="0" y="562.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-24"> +<clipPath id="terminal-1677736963-line-24"> <rect x="0" y="587.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-25"> +<clipPath id="terminal-1677736963-line-25"> <rect x="0" y="611.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-26"> +<clipPath id="terminal-1677736963-line-26"> <rect x="0" y="635.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-27"> +<clipPath id="terminal-1677736963-line-27"> <rect x="0" y="660.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-3002676011-line-28"> +<clipPath id="terminal-1677736963-line-28"> <rect x="0" y="684.7" width="1220" height="24.65"/> </clipPath> </defs> - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-3002676011-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-1677736963-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> <g transform="translate(26,22)"> <circle cx="0" cy="0" r="7" fill="#ff5f57"/> <circle cx="22" cy="0" r="7" fill="#febc2e"/> <circle cx="44" cy="0" r="7" fill="#28c840"/> </g> - <g transform="translate(9, 41)" clip-path="url(#terminal-3002676011-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="463.6" y="294.3" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="585.6" y="343.1" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="683.2" y="660.3" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="719.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-3002676011-matrix"> - <text class="terminal-3002676011-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-3002676011-line-0)"> -</text><text class="terminal-3002676011-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-3002676011-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-3002676011-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-3002676011-line-1)"> -</text><text class="terminal-3002676011-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-3002676011-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-3002676011-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-3002676011-line-2)"> -</text><text class="terminal-3002676011-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-3002676011-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-3002676011-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-3002676011-line-3)"> -</text><text class="terminal-3002676011-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-3002676011-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-3002676011-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-3002676011-line-4)"> -</text><text class="terminal-3002676011-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-3002676011-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-3002676011-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-3002676011-line-5)"> -</text><text class="terminal-3002676011-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-3002676011-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-3002676011-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-3002676011-line-6)"> -</text><text class="terminal-3002676011-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-3002676011-line-7)">v9.9.9</text><text class="terminal-3002676011-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-3002676011-line-7)"> -</text><text class="terminal-3002676011-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-3002676011-line-8)"> -</text><text class="terminal-3002676011-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-3002676011-line-9)">Listening… start talking when you're ready.</text><text class="terminal-3002676011-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-3002676011-line-9)"> -</text><text class="terminal-3002676011-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-3002676011-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-3002676011-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-3002676011-line-10)"> -</text><text class="terminal-3002676011-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-3002676011-line-11)"> -</text><text class="terminal-3002676011-r6" x="24.4" y="312.8" textLength="439.2" clip-path="url(#terminal-3002676011-line-12)">» what's the weather like in Boston?</text><text class="terminal-3002676011-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-3002676011-line-12)"> -</text><text class="terminal-3002676011-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-3002676011-line-13)"> -</text><text class="terminal-3002676011-r4" x="24.4" y="361.6" textLength="561.2" clip-path="url(#terminal-3002676011-line-14)">It's sunny and about sixty degrees right now. </text><text class="terminal-3002676011-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-3002676011-line-14)"> -</text><text class="terminal-3002676011-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-3002676011-line-15)"> -</text><text class="terminal-3002676011-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-3002676011-line-16)"> -</text><text class="terminal-3002676011-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-3002676011-line-17)"> -</text><text class="terminal-3002676011-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-3002676011-line-18)"> -</text><text class="terminal-3002676011-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-3002676011-line-19)"> -</text><text class="terminal-3002676011-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-3002676011-line-20)"> -</text><text class="terminal-3002676011-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-3002676011-line-21)"> -</text><text class="terminal-3002676011-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-3002676011-line-22)"> -</text><text class="terminal-3002676011-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-3002676011-line-23)"> -</text><text class="terminal-3002676011-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-3002676011-line-24)"> -</text><text class="terminal-3002676011-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-3002676011-line-25)"> -</text><text class="terminal-3002676011-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-3002676011-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-3002676011-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-3002676011-line-26)"> -</text><text class="terminal-3002676011-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-3002676011-line-27)">│</text><text class="terminal-3002676011-r7" x="524.6" y="678.8" textLength="36.6" clip-path="url(#terminal-3002676011-line-27)">▅▇▆</text><text class="terminal-3002676011-r4" x="561.2" y="678.8" textLength="122" clip-path="url(#terminal-3002676011-line-27)"> Speaking…</text><text class="terminal-3002676011-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3002676011-line-27)">│</text><text class="terminal-3002676011-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-3002676011-line-27)"> -</text><text class="terminal-3002676011-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-3002676011-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-3002676011-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-3002676011-line-28)"> -</text><text class="terminal-3002676011-r3" x="12.2" y="727.6" textLength="488" clip-path="url(#terminal-3002676011-line-29)">Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + <g transform="translate(9, 41)" clip-path="url(#terminal-1677736963-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="463.6" y="294.3" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="585.6" y="343.1" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="683.2" y="660.3" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-1677736963-matrix"> + <text class="terminal-1677736963-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-1677736963-line-0)"> +</text><text class="terminal-1677736963-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-1677736963-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-1677736963-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-1677736963-line-1)"> +</text><text class="terminal-1677736963-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-1677736963-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-1677736963-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-1677736963-line-2)"> +</text><text class="terminal-1677736963-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-1677736963-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-1677736963-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-1677736963-line-3)"> +</text><text class="terminal-1677736963-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-1677736963-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-1677736963-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-1677736963-line-4)"> +</text><text class="terminal-1677736963-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-1677736963-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-1677736963-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-1677736963-line-5)"> +</text><text class="terminal-1677736963-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-1677736963-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-1677736963-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-1677736963-line-6)"> +</text><text class="terminal-1677736963-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-1677736963-line-7)">v9.9.9</text><text class="terminal-1677736963-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-1677736963-line-7)"> +</text><text class="terminal-1677736963-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-1677736963-line-8)"> +</text><text class="terminal-1677736963-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-1677736963-line-9)">Listening… start talking when you're ready.</text><text class="terminal-1677736963-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-1677736963-line-9)"> +</text><text class="terminal-1677736963-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-1677736963-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-1677736963-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-1677736963-line-10)"> +</text><text class="terminal-1677736963-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-1677736963-line-11)"> +</text><text class="terminal-1677736963-r6" x="24.4" y="312.8" textLength="439.2" clip-path="url(#terminal-1677736963-line-12)">» what's the weather like in Boston?</text><text class="terminal-1677736963-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-1677736963-line-12)"> +</text><text class="terminal-1677736963-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-1677736963-line-13)"> +</text><text class="terminal-1677736963-r4" x="24.4" y="361.6" textLength="561.2" clip-path="url(#terminal-1677736963-line-14)">It's sunny and about sixty degrees right now. </text><text class="terminal-1677736963-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-1677736963-line-14)"> +</text><text class="terminal-1677736963-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-1677736963-line-15)"> +</text><text class="terminal-1677736963-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-1677736963-line-16)"> +</text><text class="terminal-1677736963-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-1677736963-line-17)"> +</text><text class="terminal-1677736963-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-1677736963-line-18)"> +</text><text class="terminal-1677736963-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-1677736963-line-19)"> +</text><text class="terminal-1677736963-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-1677736963-line-20)"> +</text><text class="terminal-1677736963-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-1677736963-line-21)"> +</text><text class="terminal-1677736963-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-1677736963-line-22)"> +</text><text class="terminal-1677736963-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-1677736963-line-23)"> +</text><text class="terminal-1677736963-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-1677736963-line-24)"> +</text><text class="terminal-1677736963-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-1677736963-line-25)"> +</text><text class="terminal-1677736963-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-1677736963-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-1677736963-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-1677736963-line-26)"> +</text><text class="terminal-1677736963-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-1677736963-line-27)">│</text><text class="terminal-1677736963-r7" x="524.6" y="678.8" textLength="36.6" clip-path="url(#terminal-1677736963-line-27)">▅▇▆</text><text class="terminal-1677736963-r4" x="561.2" y="678.8" textLength="122" clip-path="url(#terminal-1677736963-line-27)"> Speaking…</text><text class="terminal-1677736963-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1677736963-line-27)">│</text><text class="terminal-1677736963-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-1677736963-line-27)"> +</text><text class="terminal-1677736963-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-1677736963-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-1677736963-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-1677736963-line-28)"> +</text><text class="terminal-1677736963-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-1677736963-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> </g> </g> </svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_error.raw b/tests/__snapshots__/test_tui_snapshots/test_live_error.raw index 5dca5df8..61067672 100644 --- a/tests/__snapshots__/test_tui_snapshots/test_live_error.raw +++ b/tests/__snapshots__/test_tui_snapshots/test_live_error.raw @@ -19,160 +19,160 @@ font-weight: 700; } - .terminal-561758967-matrix { + .terminal-1649331151-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-561758967-title { + .terminal-1649331151-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-561758967-r1 { fill: #c5c8c6 } -.terminal-561758967-r2 { fill: #614fd2;font-weight: bold } -.terminal-561758967-r3 { fill: #939393 } -.terminal-561758967-r4 { fill: #e0e0e0 } -.terminal-561758967-r5 { fill: #614fd2 } -.terminal-561758967-r6 { fill: #f04438 } + .terminal-1649331151-r1 { fill: #c5c8c6 } +.terminal-1649331151-r2 { fill: #614fd2;font-weight: bold } +.terminal-1649331151-r3 { fill: #939393 } +.terminal-1649331151-r4 { fill: #e0e0e0 } +.terminal-1649331151-r5 { fill: #614fd2 } +.terminal-1649331151-r6 { fill: #f04438 } </style> <defs> - <clipPath id="terminal-561758967-clip-terminal"> + <clipPath id="terminal-1649331151-clip-terminal"> <rect x="0" y="0" width="1219.0" height="731.0" /> </clipPath> - <clipPath id="terminal-561758967-line-0"> + <clipPath id="terminal-1649331151-line-0"> <rect x="0" y="1.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-1"> +<clipPath id="terminal-1649331151-line-1"> <rect x="0" y="25.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-2"> +<clipPath id="terminal-1649331151-line-2"> <rect x="0" y="50.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-3"> +<clipPath id="terminal-1649331151-line-3"> <rect x="0" y="74.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-4"> +<clipPath id="terminal-1649331151-line-4"> <rect x="0" y="99.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-5"> +<clipPath id="terminal-1649331151-line-5"> <rect x="0" y="123.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-6"> +<clipPath id="terminal-1649331151-line-6"> <rect x="0" y="147.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-7"> +<clipPath id="terminal-1649331151-line-7"> <rect x="0" y="172.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-8"> +<clipPath id="terminal-1649331151-line-8"> <rect x="0" y="196.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-9"> +<clipPath id="terminal-1649331151-line-9"> <rect x="0" y="221.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-10"> +<clipPath id="terminal-1649331151-line-10"> <rect x="0" y="245.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-11"> +<clipPath id="terminal-1649331151-line-11"> <rect x="0" y="269.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-12"> +<clipPath id="terminal-1649331151-line-12"> <rect x="0" y="294.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-13"> +<clipPath id="terminal-1649331151-line-13"> <rect x="0" y="318.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-14"> +<clipPath id="terminal-1649331151-line-14"> <rect x="0" y="343.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-15"> +<clipPath id="terminal-1649331151-line-15"> <rect x="0" y="367.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-16"> +<clipPath id="terminal-1649331151-line-16"> <rect x="0" y="391.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-17"> +<clipPath id="terminal-1649331151-line-17"> <rect x="0" y="416.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-18"> +<clipPath id="terminal-1649331151-line-18"> <rect x="0" y="440.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-19"> +<clipPath id="terminal-1649331151-line-19"> <rect x="0" y="465.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-20"> +<clipPath id="terminal-1649331151-line-20"> <rect x="0" y="489.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-21"> +<clipPath id="terminal-1649331151-line-21"> <rect x="0" y="513.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-22"> +<clipPath id="terminal-1649331151-line-22"> <rect x="0" y="538.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-23"> +<clipPath id="terminal-1649331151-line-23"> <rect x="0" y="562.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-24"> +<clipPath id="terminal-1649331151-line-24"> <rect x="0" y="587.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-25"> +<clipPath id="terminal-1649331151-line-25"> <rect x="0" y="611.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-26"> +<clipPath id="terminal-1649331151-line-26"> <rect x="0" y="635.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-27"> +<clipPath id="terminal-1649331151-line-27"> <rect x="0" y="660.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-561758967-line-28"> +<clipPath id="terminal-1649331151-line-28"> <rect x="0" y="684.7" width="1220" height="24.65"/> </clipPath> </defs> - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-561758967-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-1649331151-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> <g transform="translate(26,22)"> <circle cx="0" cy="0" r="7" fill="#ff5f57"/> <circle cx="22" cy="0" r="7" fill="#febc2e"/> <circle cx="44" cy="0" r="7" fill="#28c840"/> </g> - <g transform="translate(9, 41)" clip-path="url(#terminal-561758967-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="269.9" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="817.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="719.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-561758967-matrix"> - <text class="terminal-561758967-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-561758967-line-0)"> -</text><text class="terminal-561758967-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-561758967-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-561758967-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-561758967-line-1)"> -</text><text class="terminal-561758967-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-561758967-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-561758967-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-561758967-line-2)"> -</text><text class="terminal-561758967-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-561758967-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-561758967-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-561758967-line-3)"> -</text><text class="terminal-561758967-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-561758967-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-561758967-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-561758967-line-4)"> -</text><text class="terminal-561758967-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-561758967-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-561758967-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-561758967-line-5)"> -</text><text class="terminal-561758967-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-561758967-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-561758967-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-561758967-line-6)"> -</text><text class="terminal-561758967-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-561758967-line-7)">v9.9.9</text><text class="terminal-561758967-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-561758967-line-7)"> -</text><text class="terminal-561758967-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-561758967-line-8)"> -</text><text class="terminal-561758967-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-561758967-line-9)">Listening… start talking when you're ready.</text><text class="terminal-561758967-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-561758967-line-9)"> -</text><text class="terminal-561758967-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-561758967-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-561758967-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-561758967-line-10)"> -</text><text class="terminal-561758967-r6" x="24.4" y="288.4" textLength="378.2" clip-path="url(#terminal-561758967-line-11)">✗ Streaming STT connection lost</text><text class="terminal-561758967-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-561758967-line-11)"> -</text><text class="terminal-561758967-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-561758967-line-12)"> -</text><text class="terminal-561758967-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-561758967-line-13)"> -</text><text class="terminal-561758967-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-561758967-line-14)"> -</text><text class="terminal-561758967-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-561758967-line-15)"> -</text><text class="terminal-561758967-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-561758967-line-16)"> -</text><text class="terminal-561758967-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-561758967-line-17)"> -</text><text class="terminal-561758967-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-561758967-line-18)"> -</text><text class="terminal-561758967-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-561758967-line-19)"> -</text><text class="terminal-561758967-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-561758967-line-20)"> -</text><text class="terminal-561758967-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-561758967-line-21)"> -</text><text class="terminal-561758967-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-561758967-line-22)"> -</text><text class="terminal-561758967-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-561758967-line-23)"> -</text><text class="terminal-561758967-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-561758967-line-24)"> -</text><text class="terminal-561758967-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-561758967-line-25)"> -</text><text class="terminal-561758967-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-561758967-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-561758967-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-561758967-line-26)"> -</text><text class="terminal-561758967-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-561758967-line-27)">│</text><text class="terminal-561758967-r5" x="402.6" y="678.8" textLength="36.6" clip-path="url(#terminal-561758967-line-27)">▁▃▅</text><text class="terminal-561758967-r4" x="439.2" y="678.8" textLength="378.2" clip-path="url(#terminal-561758967-line-27)"> Listening — speak your request</text><text class="terminal-561758967-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-561758967-line-27)">│</text><text class="terminal-561758967-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-561758967-line-27)"> -</text><text class="terminal-561758967-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-561758967-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-561758967-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-561758967-line-28)"> -</text><text class="terminal-561758967-r3" x="12.2" y="727.6" textLength="488" clip-path="url(#terminal-561758967-line-29)">Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + <g transform="translate(9, 41)" clip-path="url(#terminal-1649331151-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="269.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="269.9" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="817.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-1649331151-matrix"> + <text class="terminal-1649331151-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-1649331151-line-0)"> +</text><text class="terminal-1649331151-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-1649331151-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-1649331151-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-1649331151-line-1)"> +</text><text class="terminal-1649331151-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-1649331151-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-1649331151-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-1649331151-line-2)"> +</text><text class="terminal-1649331151-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-1649331151-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-1649331151-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-1649331151-line-3)"> +</text><text class="terminal-1649331151-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-1649331151-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-1649331151-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-1649331151-line-4)"> +</text><text class="terminal-1649331151-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-1649331151-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-1649331151-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-1649331151-line-5)"> +</text><text class="terminal-1649331151-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-1649331151-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-1649331151-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-1649331151-line-6)"> +</text><text class="terminal-1649331151-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-1649331151-line-7)">v9.9.9</text><text class="terminal-1649331151-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-1649331151-line-7)"> +</text><text class="terminal-1649331151-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-1649331151-line-8)"> +</text><text class="terminal-1649331151-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-1649331151-line-9)">Listening… start talking when you're ready.</text><text class="terminal-1649331151-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-1649331151-line-9)"> +</text><text class="terminal-1649331151-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-1649331151-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-1649331151-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-1649331151-line-10)"> +</text><text class="terminal-1649331151-r6" x="24.4" y="288.4" textLength="378.2" clip-path="url(#terminal-1649331151-line-11)">✗ Streaming STT connection lost</text><text class="terminal-1649331151-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-1649331151-line-11)"> +</text><text class="terminal-1649331151-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-1649331151-line-12)"> +</text><text class="terminal-1649331151-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-1649331151-line-13)"> +</text><text class="terminal-1649331151-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-1649331151-line-14)"> +</text><text class="terminal-1649331151-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-1649331151-line-15)"> +</text><text class="terminal-1649331151-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-1649331151-line-16)"> +</text><text class="terminal-1649331151-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-1649331151-line-17)"> +</text><text class="terminal-1649331151-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-1649331151-line-18)"> +</text><text class="terminal-1649331151-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-1649331151-line-19)"> +</text><text class="terminal-1649331151-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-1649331151-line-20)"> +</text><text class="terminal-1649331151-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-1649331151-line-21)"> +</text><text class="terminal-1649331151-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-1649331151-line-22)"> +</text><text class="terminal-1649331151-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-1649331151-line-23)"> +</text><text class="terminal-1649331151-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-1649331151-line-24)"> +</text><text class="terminal-1649331151-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-1649331151-line-25)"> +</text><text class="terminal-1649331151-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-1649331151-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-1649331151-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-1649331151-line-26)"> +</text><text class="terminal-1649331151-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-1649331151-line-27)">│</text><text class="terminal-1649331151-r5" x="402.6" y="678.8" textLength="36.6" clip-path="url(#terminal-1649331151-line-27)">▁▃▅</text><text class="terminal-1649331151-r4" x="439.2" y="678.8" textLength="378.2" clip-path="url(#terminal-1649331151-line-27)"> Listening — speak your request</text><text class="terminal-1649331151-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1649331151-line-27)">│</text><text class="terminal-1649331151-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-1649331151-line-27)"> +</text><text class="terminal-1649331151-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-1649331151-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-1649331151-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-1649331151-line-28)"> +</text><text class="terminal-1649331151-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-1649331151-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> </g> </g> </svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_interrupted.raw b/tests/__snapshots__/test_tui_snapshots/test_live_interrupted.raw index 0506984e..a0c51701 100644 --- a/tests/__snapshots__/test_tui_snapshots/test_live_interrupted.raw +++ b/tests/__snapshots__/test_tui_snapshots/test_live_interrupted.raw @@ -19,161 +19,161 @@ font-weight: 700; } - .terminal-2228657010-matrix { + .terminal-2653201482-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2228657010-title { + .terminal-2653201482-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2228657010-r1 { fill: #c5c8c6 } -.terminal-2228657010-r2 { fill: #614fd2;font-weight: bold } -.terminal-2228657010-r3 { fill: #939393 } -.terminal-2228657010-r4 { fill: #e0e0e0 } -.terminal-2228657010-r5 { fill: #614fd2 } -.terminal-2228657010-r6 { fill: #38bdf8;font-weight: bold } -.terminal-2228657010-r7 { fill: #8a8f98 } + .terminal-2653201482-r1 { fill: #c5c8c6 } +.terminal-2653201482-r2 { fill: #614fd2;font-weight: bold } +.terminal-2653201482-r3 { fill: #939393 } +.terminal-2653201482-r4 { fill: #e0e0e0 } +.terminal-2653201482-r5 { fill: #614fd2 } +.terminal-2653201482-r6 { fill: #38bdf8;font-weight: bold } +.terminal-2653201482-r7 { fill: #8a8f98 } </style> <defs> - <clipPath id="terminal-2228657010-clip-terminal"> + <clipPath id="terminal-2653201482-clip-terminal"> <rect x="0" y="0" width="1219.0" height="731.0" /> </clipPath> - <clipPath id="terminal-2228657010-line-0"> + <clipPath id="terminal-2653201482-line-0"> <rect x="0" y="1.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-1"> +<clipPath id="terminal-2653201482-line-1"> <rect x="0" y="25.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-2"> +<clipPath id="terminal-2653201482-line-2"> <rect x="0" y="50.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-3"> +<clipPath id="terminal-2653201482-line-3"> <rect x="0" y="74.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-4"> +<clipPath id="terminal-2653201482-line-4"> <rect x="0" y="99.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-5"> +<clipPath id="terminal-2653201482-line-5"> <rect x="0" y="123.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-6"> +<clipPath id="terminal-2653201482-line-6"> <rect x="0" y="147.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-7"> +<clipPath id="terminal-2653201482-line-7"> <rect x="0" y="172.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-8"> +<clipPath id="terminal-2653201482-line-8"> <rect x="0" y="196.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-9"> +<clipPath id="terminal-2653201482-line-9"> <rect x="0" y="221.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-10"> +<clipPath id="terminal-2653201482-line-10"> <rect x="0" y="245.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-11"> +<clipPath id="terminal-2653201482-line-11"> <rect x="0" y="269.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-12"> +<clipPath id="terminal-2653201482-line-12"> <rect x="0" y="294.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-13"> +<clipPath id="terminal-2653201482-line-13"> <rect x="0" y="318.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-14"> +<clipPath id="terminal-2653201482-line-14"> <rect x="0" y="343.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-15"> +<clipPath id="terminal-2653201482-line-15"> <rect x="0" y="367.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-16"> +<clipPath id="terminal-2653201482-line-16"> <rect x="0" y="391.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-17"> +<clipPath id="terminal-2653201482-line-17"> <rect x="0" y="416.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-18"> +<clipPath id="terminal-2653201482-line-18"> <rect x="0" y="440.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-19"> +<clipPath id="terminal-2653201482-line-19"> <rect x="0" y="465.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-20"> +<clipPath id="terminal-2653201482-line-20"> <rect x="0" y="489.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-21"> +<clipPath id="terminal-2653201482-line-21"> <rect x="0" y="513.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-22"> +<clipPath id="terminal-2653201482-line-22"> <rect x="0" y="538.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-23"> +<clipPath id="terminal-2653201482-line-23"> <rect x="0" y="562.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-24"> +<clipPath id="terminal-2653201482-line-24"> <rect x="0" y="587.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-25"> +<clipPath id="terminal-2653201482-line-25"> <rect x="0" y="611.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-26"> +<clipPath id="terminal-2653201482-line-26"> <rect x="0" y="635.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-27"> +<clipPath id="terminal-2653201482-line-27"> <rect x="0" y="660.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2228657010-line-28"> +<clipPath id="terminal-2653201482-line-28"> <rect x="0" y="684.7" width="1220" height="24.65"/> </clipPath> </defs> - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-2228657010-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-2653201482-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> <g transform="translate(26,22)"> <circle cx="0" cy="0" r="7" fill="#ff5f57"/> <circle cx="22" cy="0" r="7" fill="#febc2e"/> <circle cx="44" cy="0" r="7" fill="#28c840"/> </g> - <g transform="translate(9, 41)" clip-path="url(#terminal-2228657010-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="292.8" y="294.3" width="927.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="1171.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="183" y="367.5" width="1037" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="817.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="719.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-2228657010-matrix"> - <text class="terminal-2228657010-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-2228657010-line-0)"> -</text><text class="terminal-2228657010-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-2228657010-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-2228657010-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-2228657010-line-1)"> -</text><text class="terminal-2228657010-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-2228657010-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-2228657010-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-2228657010-line-2)"> -</text><text class="terminal-2228657010-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-2228657010-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-2228657010-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-2228657010-line-3)"> -</text><text class="terminal-2228657010-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-2228657010-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-2228657010-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-2228657010-line-4)"> -</text><text class="terminal-2228657010-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-2228657010-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-2228657010-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-2228657010-line-5)"> -</text><text class="terminal-2228657010-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-2228657010-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-2228657010-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-2228657010-line-6)"> -</text><text class="terminal-2228657010-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-2228657010-line-7)">v9.9.9</text><text class="terminal-2228657010-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-2228657010-line-7)"> -</text><text class="terminal-2228657010-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-2228657010-line-8)"> -</text><text class="terminal-2228657010-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-2228657010-line-9)">Listening… start talking when you're ready.</text><text class="terminal-2228657010-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-2228657010-line-9)"> -</text><text class="terminal-2228657010-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-2228657010-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-2228657010-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-2228657010-line-10)"> -</text><text class="terminal-2228657010-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-2228657010-line-11)"> -</text><text class="terminal-2228657010-r6" x="24.4" y="312.8" textLength="268.4" clip-path="url(#terminal-2228657010-line-12)">» tell me a long story</text><text class="terminal-2228657010-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-2228657010-line-12)"> -</text><text class="terminal-2228657010-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-2228657010-line-13)"> -</text><text class="terminal-2228657010-r4" x="24.4" y="361.6" textLength="1171.2" clip-path="url(#terminal-2228657010-line-14)">Once upon a time, in a faraway land,                                                            </text><text class="terminal-2228657010-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-2228657010-line-14)"> -</text><text class="terminal-2228657010-r7" x="24.4" y="386" textLength="158.6" clip-path="url(#terminal-2228657010-line-15)">(interrupted)</text><text class="terminal-2228657010-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-2228657010-line-15)"> -</text><text class="terminal-2228657010-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-2228657010-line-16)"> -</text><text class="terminal-2228657010-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-2228657010-line-17)"> -</text><text class="terminal-2228657010-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-2228657010-line-18)"> -</text><text class="terminal-2228657010-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-2228657010-line-19)"> -</text><text class="terminal-2228657010-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-2228657010-line-20)"> -</text><text class="terminal-2228657010-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-2228657010-line-21)"> -</text><text class="terminal-2228657010-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-2228657010-line-22)"> -</text><text class="terminal-2228657010-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-2228657010-line-23)"> -</text><text class="terminal-2228657010-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-2228657010-line-24)"> -</text><text class="terminal-2228657010-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-2228657010-line-25)"> -</text><text class="terminal-2228657010-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-2228657010-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-2228657010-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-2228657010-line-26)"> -</text><text class="terminal-2228657010-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-2228657010-line-27)">│</text><text class="terminal-2228657010-r5" x="402.6" y="678.8" textLength="36.6" clip-path="url(#terminal-2228657010-line-27)">▆▇▅</text><text class="terminal-2228657010-r4" x="439.2" y="678.8" textLength="378.2" clip-path="url(#terminal-2228657010-line-27)"> Listening — speak your request</text><text class="terminal-2228657010-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2228657010-line-27)">│</text><text class="terminal-2228657010-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-2228657010-line-27)"> -</text><text class="terminal-2228657010-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-2228657010-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-2228657010-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-2228657010-line-28)"> -</text><text class="terminal-2228657010-r3" x="12.2" y="727.6" textLength="488" clip-path="url(#terminal-2228657010-line-29)">Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + <g transform="translate(9, 41)" clip-path="url(#terminal-2653201482-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="292.8" y="294.3" width="927.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="1171.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="183" y="367.5" width="1037" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="817.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-2653201482-matrix"> + <text class="terminal-2653201482-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-2653201482-line-0)"> +</text><text class="terminal-2653201482-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-2653201482-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-2653201482-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-2653201482-line-1)"> +</text><text class="terminal-2653201482-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-2653201482-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-2653201482-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-2653201482-line-2)"> +</text><text class="terminal-2653201482-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-2653201482-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-2653201482-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-2653201482-line-3)"> +</text><text class="terminal-2653201482-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-2653201482-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-2653201482-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-2653201482-line-4)"> +</text><text class="terminal-2653201482-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-2653201482-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-2653201482-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-2653201482-line-5)"> +</text><text class="terminal-2653201482-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-2653201482-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-2653201482-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-2653201482-line-6)"> +</text><text class="terminal-2653201482-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-2653201482-line-7)">v9.9.9</text><text class="terminal-2653201482-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-2653201482-line-7)"> +</text><text class="terminal-2653201482-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-2653201482-line-8)"> +</text><text class="terminal-2653201482-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-2653201482-line-9)">Listening… start talking when you're ready.</text><text class="terminal-2653201482-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-2653201482-line-9)"> +</text><text class="terminal-2653201482-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-2653201482-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-2653201482-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-2653201482-line-10)"> +</text><text class="terminal-2653201482-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-2653201482-line-11)"> +</text><text class="terminal-2653201482-r6" x="24.4" y="312.8" textLength="268.4" clip-path="url(#terminal-2653201482-line-12)">» tell me a long story</text><text class="terminal-2653201482-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-2653201482-line-12)"> +</text><text class="terminal-2653201482-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-2653201482-line-13)"> +</text><text class="terminal-2653201482-r4" x="24.4" y="361.6" textLength="1171.2" clip-path="url(#terminal-2653201482-line-14)">Once upon a time, in a faraway land,                                                            </text><text class="terminal-2653201482-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-2653201482-line-14)"> +</text><text class="terminal-2653201482-r7" x="24.4" y="386" textLength="158.6" clip-path="url(#terminal-2653201482-line-15)">(interrupted)</text><text class="terminal-2653201482-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-2653201482-line-15)"> +</text><text class="terminal-2653201482-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-2653201482-line-16)"> +</text><text class="terminal-2653201482-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-2653201482-line-17)"> +</text><text class="terminal-2653201482-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-2653201482-line-18)"> +</text><text class="terminal-2653201482-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-2653201482-line-19)"> +</text><text class="terminal-2653201482-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-2653201482-line-20)"> +</text><text class="terminal-2653201482-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-2653201482-line-21)"> +</text><text class="terminal-2653201482-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-2653201482-line-22)"> +</text><text class="terminal-2653201482-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-2653201482-line-23)"> +</text><text class="terminal-2653201482-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-2653201482-line-24)"> +</text><text class="terminal-2653201482-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-2653201482-line-25)"> +</text><text class="terminal-2653201482-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-2653201482-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-2653201482-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-2653201482-line-26)"> +</text><text class="terminal-2653201482-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-2653201482-line-27)">│</text><text class="terminal-2653201482-r5" x="402.6" y="678.8" textLength="36.6" clip-path="url(#terminal-2653201482-line-27)">▆▇▅</text><text class="terminal-2653201482-r4" x="439.2" y="678.8" textLength="378.2" clip-path="url(#terminal-2653201482-line-27)"> Listening — speak your request</text><text class="terminal-2653201482-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2653201482-line-27)">│</text><text class="terminal-2653201482-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-2653201482-line-27)"> +</text><text class="terminal-2653201482-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-2653201482-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-2653201482-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-2653201482-line-28)"> +</text><text class="terminal-2653201482-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-2653201482-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> </g> </g> </svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_paused.raw b/tests/__snapshots__/test_tui_snapshots/test_live_paused.raw new file mode 100644 index 00000000..5ace9036 --- /dev/null +++ b/tests/__snapshots__/test_tui_snapshots/test_live_paused.raw @@ -0,0 +1,178 @@ +<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> + <!-- Generated with Rich https://www.textualize.io --> + <style> + + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Regular"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Bold"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); + font-style: bold; + font-weight: 700; + } + + .terminal-1682726817-matrix { + font-family: Fira Code, monospace; + font-size: 20px; + line-height: 24.4px; + font-variant-east-asian: full-width; + } + + .terminal-1682726817-title { + font-size: 18px; + font-weight: bold; + font-family: arial; + } + + .terminal-1682726817-r1 { fill: #c5c8c6 } +.terminal-1682726817-r2 { fill: #614fd2;font-weight: bold } +.terminal-1682726817-r3 { fill: #939393 } +.terminal-1682726817-r4 { fill: #e0e0e0 } +.terminal-1682726817-r5 { fill: #614fd2 } +.terminal-1682726817-r6 { fill: #6b7280 } + </style> + + <defs> + <clipPath id="terminal-1682726817-clip-terminal"> + <rect x="0" y="0" width="1219.0" height="731.0" /> + </clipPath> + <clipPath id="terminal-1682726817-line-0"> + <rect x="0" y="1.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-1"> + <rect x="0" y="25.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-2"> + <rect x="0" y="50.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-3"> + <rect x="0" y="74.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-4"> + <rect x="0" y="99.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-5"> + <rect x="0" y="123.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-6"> + <rect x="0" y="147.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-7"> + <rect x="0" y="172.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-8"> + <rect x="0" y="196.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-9"> + <rect x="0" y="221.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-10"> + <rect x="0" y="245.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-11"> + <rect x="0" y="269.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-12"> + <rect x="0" y="294.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-13"> + <rect x="0" y="318.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-14"> + <rect x="0" y="343.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-15"> + <rect x="0" y="367.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-16"> + <rect x="0" y="391.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-17"> + <rect x="0" y="416.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-18"> + <rect x="0" y="440.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-19"> + <rect x="0" y="465.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-20"> + <rect x="0" y="489.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-21"> + <rect x="0" y="513.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-22"> + <rect x="0" y="538.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-23"> + <rect x="0" y="562.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-24"> + <rect x="0" y="587.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-25"> + <rect x="0" y="611.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-26"> + <rect x="0" y="635.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-27"> + <rect x="0" y="660.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-1682726817-line-28"> + <rect x="0" y="684.7" width="1220" height="24.65"/> + </clipPath> + </defs> + + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-1682726817-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <g transform="translate(26,22)"> + <circle cx="0" cy="0" r="7" fill="#ff5f57"/> + <circle cx="22" cy="0" r="7" fill="#febc2e"/> + <circle cx="44" cy="0" r="7" fill="#28c840"/> + </g> + + <g transform="translate(9, 41)" clip-path="url(#terminal-1682726817-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="341.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="378.2" y="660.3" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="878.4" y="660.3" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-1682726817-matrix"> + <text class="terminal-1682726817-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-1682726817-line-0)"> +</text><text class="terminal-1682726817-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-1682726817-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-1682726817-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-1682726817-line-1)"> +</text><text class="terminal-1682726817-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-1682726817-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-1682726817-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-1682726817-line-2)"> +</text><text class="terminal-1682726817-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-1682726817-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-1682726817-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-1682726817-line-3)"> +</text><text class="terminal-1682726817-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-1682726817-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-1682726817-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-1682726817-line-4)"> +</text><text class="terminal-1682726817-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-1682726817-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-1682726817-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-1682726817-line-5)"> +</text><text class="terminal-1682726817-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-1682726817-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-1682726817-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-1682726817-line-6)"> +</text><text class="terminal-1682726817-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-1682726817-line-7)">v9.9.9</text><text class="terminal-1682726817-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-1682726817-line-7)"> +</text><text class="terminal-1682726817-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-1682726817-line-8)"> +</text><text class="terminal-1682726817-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-1682726817-line-9)">Listening… start talking when you're ready.</text><text class="terminal-1682726817-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-1682726817-line-9)"> +</text><text class="terminal-1682726817-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-1682726817-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-1682726817-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-1682726817-line-10)"> +</text><text class="terminal-1682726817-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-1682726817-line-11)"> +</text><text class="terminal-1682726817-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-1682726817-line-12)"> +</text><text class="terminal-1682726817-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-1682726817-line-13)"> +</text><text class="terminal-1682726817-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-1682726817-line-14)"> +</text><text class="terminal-1682726817-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-1682726817-line-15)"> +</text><text class="terminal-1682726817-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-1682726817-line-16)"> +</text><text class="terminal-1682726817-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-1682726817-line-17)"> +</text><text class="terminal-1682726817-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-1682726817-line-18)"> +</text><text class="terminal-1682726817-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-1682726817-line-19)"> +</text><text class="terminal-1682726817-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-1682726817-line-20)"> +</text><text class="terminal-1682726817-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-1682726817-line-21)"> +</text><text class="terminal-1682726817-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-1682726817-line-22)"> +</text><text class="terminal-1682726817-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-1682726817-line-23)"> +</text><text class="terminal-1682726817-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-1682726817-line-24)"> +</text><text class="terminal-1682726817-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-1682726817-line-25)"> +</text><text class="terminal-1682726817-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-1682726817-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-1682726817-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-1682726817-line-26)"> +</text><text class="terminal-1682726817-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-1682726817-line-27)">│</text><text class="terminal-1682726817-r6" x="341.6" y="678.8" textLength="36.6" clip-path="url(#terminal-1682726817-line-27)">▁▁▁</text><text class="terminal-1682726817-r4" x="378.2" y="678.8" textLength="500.2" clip-path="url(#terminal-1682726817-line-27)"> Paused — press space to resume listening</text><text class="terminal-1682726817-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1682726817-line-27)">│</text><text class="terminal-1682726817-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-1682726817-line-27)"> +</text><text class="terminal-1682726817-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-1682726817-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-1682726817-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-1682726817-line-28)"> +</text><text class="terminal-1682726817-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-1682726817-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + </g> + </g> +</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_splash_listening.raw b/tests/__snapshots__/test_tui_snapshots/test_live_splash_listening.raw index b415f85f..66cabadd 100644 --- a/tests/__snapshots__/test_tui_snapshots/test_live_splash_listening.raw +++ b/tests/__snapshots__/test_tui_snapshots/test_live_splash_listening.raw @@ -19,159 +19,159 @@ font-weight: 700; } - .terminal-2401064036-matrix { + .terminal-1633508668-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2401064036-title { + .terminal-1633508668-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2401064036-r1 { fill: #c5c8c6 } -.terminal-2401064036-r2 { fill: #614fd2;font-weight: bold } -.terminal-2401064036-r3 { fill: #939393 } -.terminal-2401064036-r4 { fill: #e0e0e0 } -.terminal-2401064036-r5 { fill: #614fd2 } + .terminal-1633508668-r1 { fill: #c5c8c6 } +.terminal-1633508668-r2 { fill: #614fd2;font-weight: bold } +.terminal-1633508668-r3 { fill: #939393 } +.terminal-1633508668-r4 { fill: #e0e0e0 } +.terminal-1633508668-r5 { fill: #614fd2 } </style> <defs> - <clipPath id="terminal-2401064036-clip-terminal"> + <clipPath id="terminal-1633508668-clip-terminal"> <rect x="0" y="0" width="1219.0" height="731.0" /> </clipPath> - <clipPath id="terminal-2401064036-line-0"> + <clipPath id="terminal-1633508668-line-0"> <rect x="0" y="1.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-1"> +<clipPath id="terminal-1633508668-line-1"> <rect x="0" y="25.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-2"> +<clipPath id="terminal-1633508668-line-2"> <rect x="0" y="50.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-3"> +<clipPath id="terminal-1633508668-line-3"> <rect x="0" y="74.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-4"> +<clipPath id="terminal-1633508668-line-4"> <rect x="0" y="99.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-5"> +<clipPath id="terminal-1633508668-line-5"> <rect x="0" y="123.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-6"> +<clipPath id="terminal-1633508668-line-6"> <rect x="0" y="147.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-7"> +<clipPath id="terminal-1633508668-line-7"> <rect x="0" y="172.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-8"> +<clipPath id="terminal-1633508668-line-8"> <rect x="0" y="196.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-9"> +<clipPath id="terminal-1633508668-line-9"> <rect x="0" y="221.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-10"> +<clipPath id="terminal-1633508668-line-10"> <rect x="0" y="245.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-11"> +<clipPath id="terminal-1633508668-line-11"> <rect x="0" y="269.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-12"> +<clipPath id="terminal-1633508668-line-12"> <rect x="0" y="294.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-13"> +<clipPath id="terminal-1633508668-line-13"> <rect x="0" y="318.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-14"> +<clipPath id="terminal-1633508668-line-14"> <rect x="0" y="343.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-15"> +<clipPath id="terminal-1633508668-line-15"> <rect x="0" y="367.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-16"> +<clipPath id="terminal-1633508668-line-16"> <rect x="0" y="391.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-17"> +<clipPath id="terminal-1633508668-line-17"> <rect x="0" y="416.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-18"> +<clipPath id="terminal-1633508668-line-18"> <rect x="0" y="440.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-19"> +<clipPath id="terminal-1633508668-line-19"> <rect x="0" y="465.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-20"> +<clipPath id="terminal-1633508668-line-20"> <rect x="0" y="489.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-21"> +<clipPath id="terminal-1633508668-line-21"> <rect x="0" y="513.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-22"> +<clipPath id="terminal-1633508668-line-22"> <rect x="0" y="538.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-23"> +<clipPath id="terminal-1633508668-line-23"> <rect x="0" y="562.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-24"> +<clipPath id="terminal-1633508668-line-24"> <rect x="0" y="587.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-25"> +<clipPath id="terminal-1633508668-line-25"> <rect x="0" y="611.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-26"> +<clipPath id="terminal-1633508668-line-26"> <rect x="0" y="635.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-27"> +<clipPath id="terminal-1633508668-line-27"> <rect x="0" y="660.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-2401064036-line-28"> +<clipPath id="terminal-1633508668-line-28"> <rect x="0" y="684.7" width="1220" height="24.65"/> </clipPath> </defs> - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-2401064036-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-1633508668-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> <g transform="translate(26,22)"> <circle cx="0" cy="0" r="7" fill="#ff5f57"/> <circle cx="22" cy="0" r="7" fill="#febc2e"/> <circle cx="44" cy="0" r="7" fill="#28c840"/> </g> - <g transform="translate(9, 41)" clip-path="url(#terminal-2401064036-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="817.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="719.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-2401064036-matrix"> - <text class="terminal-2401064036-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-2401064036-line-0)"> -</text><text class="terminal-2401064036-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-2401064036-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-2401064036-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-2401064036-line-1)"> -</text><text class="terminal-2401064036-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-2401064036-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-2401064036-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-2401064036-line-2)"> -</text><text class="terminal-2401064036-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-2401064036-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-2401064036-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-2401064036-line-3)"> -</text><text class="terminal-2401064036-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-2401064036-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-2401064036-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-2401064036-line-4)"> -</text><text class="terminal-2401064036-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-2401064036-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-2401064036-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-2401064036-line-5)"> -</text><text class="terminal-2401064036-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-2401064036-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-2401064036-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-2401064036-line-6)"> -</text><text class="terminal-2401064036-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-2401064036-line-7)">v9.9.9</text><text class="terminal-2401064036-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-2401064036-line-7)"> -</text><text class="terminal-2401064036-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-2401064036-line-8)"> -</text><text class="terminal-2401064036-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-2401064036-line-9)">Listening… start talking when you're ready.</text><text class="terminal-2401064036-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-2401064036-line-9)"> -</text><text class="terminal-2401064036-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-2401064036-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-2401064036-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-2401064036-line-10)"> -</text><text class="terminal-2401064036-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-2401064036-line-11)"> -</text><text class="terminal-2401064036-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-2401064036-line-12)"> -</text><text class="terminal-2401064036-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-2401064036-line-13)"> -</text><text class="terminal-2401064036-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-2401064036-line-14)"> -</text><text class="terminal-2401064036-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-2401064036-line-15)"> -</text><text class="terminal-2401064036-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-2401064036-line-16)"> -</text><text class="terminal-2401064036-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-2401064036-line-17)"> -</text><text class="terminal-2401064036-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-2401064036-line-18)"> -</text><text class="terminal-2401064036-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-2401064036-line-19)"> -</text><text class="terminal-2401064036-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-2401064036-line-20)"> -</text><text class="terminal-2401064036-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-2401064036-line-21)"> -</text><text class="terminal-2401064036-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-2401064036-line-22)"> -</text><text class="terminal-2401064036-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-2401064036-line-23)"> -</text><text class="terminal-2401064036-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-2401064036-line-24)"> -</text><text class="terminal-2401064036-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-2401064036-line-25)"> -</text><text class="terminal-2401064036-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-2401064036-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-2401064036-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-2401064036-line-26)"> -</text><text class="terminal-2401064036-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-2401064036-line-27)">│</text><text class="terminal-2401064036-r5" x="402.6" y="678.8" textLength="36.6" clip-path="url(#terminal-2401064036-line-27)">▁▃▅</text><text class="terminal-2401064036-r4" x="439.2" y="678.8" textLength="378.2" clip-path="url(#terminal-2401064036-line-27)"> Listening — speak your request</text><text class="terminal-2401064036-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2401064036-line-27)">│</text><text class="terminal-2401064036-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-2401064036-line-27)"> -</text><text class="terminal-2401064036-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-2401064036-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-2401064036-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-2401064036-line-28)"> -</text><text class="terminal-2401064036-r3" x="12.2" y="727.6" textLength="488" clip-path="url(#terminal-2401064036-line-29)">Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + <g transform="translate(9, 41)" clip-path="url(#terminal-1633508668-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="817.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-1633508668-matrix"> + <text class="terminal-1633508668-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-1633508668-line-0)"> +</text><text class="terminal-1633508668-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-1633508668-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-1633508668-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-1633508668-line-1)"> +</text><text class="terminal-1633508668-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-1633508668-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-1633508668-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-1633508668-line-2)"> +</text><text class="terminal-1633508668-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-1633508668-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-1633508668-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-1633508668-line-3)"> +</text><text class="terminal-1633508668-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-1633508668-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-1633508668-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-1633508668-line-4)"> +</text><text class="terminal-1633508668-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-1633508668-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-1633508668-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-1633508668-line-5)"> +</text><text class="terminal-1633508668-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-1633508668-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-1633508668-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-1633508668-line-6)"> +</text><text class="terminal-1633508668-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-1633508668-line-7)">v9.9.9</text><text class="terminal-1633508668-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-1633508668-line-7)"> +</text><text class="terminal-1633508668-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-1633508668-line-8)"> +</text><text class="terminal-1633508668-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-1633508668-line-9)">Listening… start talking when you're ready.</text><text class="terminal-1633508668-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-1633508668-line-9)"> +</text><text class="terminal-1633508668-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-1633508668-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-1633508668-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-1633508668-line-10)"> +</text><text class="terminal-1633508668-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-1633508668-line-11)"> +</text><text class="terminal-1633508668-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-1633508668-line-12)"> +</text><text class="terminal-1633508668-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-1633508668-line-13)"> +</text><text class="terminal-1633508668-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-1633508668-line-14)"> +</text><text class="terminal-1633508668-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-1633508668-line-15)"> +</text><text class="terminal-1633508668-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-1633508668-line-16)"> +</text><text class="terminal-1633508668-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-1633508668-line-17)"> +</text><text class="terminal-1633508668-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-1633508668-line-18)"> +</text><text class="terminal-1633508668-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-1633508668-line-19)"> +</text><text class="terminal-1633508668-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-1633508668-line-20)"> +</text><text class="terminal-1633508668-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-1633508668-line-21)"> +</text><text class="terminal-1633508668-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-1633508668-line-22)"> +</text><text class="terminal-1633508668-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-1633508668-line-23)"> +</text><text class="terminal-1633508668-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-1633508668-line-24)"> +</text><text class="terminal-1633508668-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-1633508668-line-25)"> +</text><text class="terminal-1633508668-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-1633508668-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-1633508668-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-1633508668-line-26)"> +</text><text class="terminal-1633508668-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-1633508668-line-27)">│</text><text class="terminal-1633508668-r5" x="402.6" y="678.8" textLength="36.6" clip-path="url(#terminal-1633508668-line-27)">▁▃▅</text><text class="terminal-1633508668-r4" x="439.2" y="678.8" textLength="378.2" clip-path="url(#terminal-1633508668-line-27)"> Listening — speak your request</text><text class="terminal-1633508668-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1633508668-line-27)">│</text><text class="terminal-1633508668-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-1633508668-line-27)"> +</text><text class="terminal-1633508668-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-1633508668-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-1633508668-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-1633508668-line-28)"> +</text><text class="terminal-1633508668-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-1633508668-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> </g> </g> </svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_thinking.raw b/tests/__snapshots__/test_tui_snapshots/test_live_thinking.raw index 422fa129..8f2dab11 100644 --- a/tests/__snapshots__/test_tui_snapshots/test_live_thinking.raw +++ b/tests/__snapshots__/test_tui_snapshots/test_live_thinking.raw @@ -19,161 +19,161 @@ font-weight: 700; } - .terminal-996065968-matrix { + .terminal-2078985096-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-996065968-title { + .terminal-2078985096-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-996065968-r1 { fill: #c5c8c6 } -.terminal-996065968-r2 { fill: #614fd2;font-weight: bold } -.terminal-996065968-r3 { fill: #939393 } -.terminal-996065968-r4 { fill: #e0e0e0 } -.terminal-996065968-r5 { fill: #614fd2 } -.terminal-996065968-r6 { fill: #38bdf8;font-weight: bold } -.terminal-996065968-r7 { fill: #f59e0b } + .terminal-2078985096-r1 { fill: #c5c8c6 } +.terminal-2078985096-r2 { fill: #614fd2;font-weight: bold } +.terminal-2078985096-r3 { fill: #939393 } +.terminal-2078985096-r4 { fill: #e0e0e0 } +.terminal-2078985096-r5 { fill: #614fd2 } +.terminal-2078985096-r6 { fill: #38bdf8;font-weight: bold } +.terminal-2078985096-r7 { fill: #f59e0b } </style> <defs> - <clipPath id="terminal-996065968-clip-terminal"> + <clipPath id="terminal-2078985096-clip-terminal"> <rect x="0" y="0" width="1219.0" height="731.0" /> </clipPath> - <clipPath id="terminal-996065968-line-0"> + <clipPath id="terminal-2078985096-line-0"> <rect x="0" y="1.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-1"> +<clipPath id="terminal-2078985096-line-1"> <rect x="0" y="25.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-2"> +<clipPath id="terminal-2078985096-line-2"> <rect x="0" y="50.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-3"> +<clipPath id="terminal-2078985096-line-3"> <rect x="0" y="74.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-4"> +<clipPath id="terminal-2078985096-line-4"> <rect x="0" y="99.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-5"> +<clipPath id="terminal-2078985096-line-5"> <rect x="0" y="123.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-6"> +<clipPath id="terminal-2078985096-line-6"> <rect x="0" y="147.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-7"> +<clipPath id="terminal-2078985096-line-7"> <rect x="0" y="172.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-8"> +<clipPath id="terminal-2078985096-line-8"> <rect x="0" y="196.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-9"> +<clipPath id="terminal-2078985096-line-9"> <rect x="0" y="221.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-10"> +<clipPath id="terminal-2078985096-line-10"> <rect x="0" y="245.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-11"> +<clipPath id="terminal-2078985096-line-11"> <rect x="0" y="269.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-12"> +<clipPath id="terminal-2078985096-line-12"> <rect x="0" y="294.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-13"> +<clipPath id="terminal-2078985096-line-13"> <rect x="0" y="318.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-14"> +<clipPath id="terminal-2078985096-line-14"> <rect x="0" y="343.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-15"> +<clipPath id="terminal-2078985096-line-15"> <rect x="0" y="367.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-16"> +<clipPath id="terminal-2078985096-line-16"> <rect x="0" y="391.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-17"> +<clipPath id="terminal-2078985096-line-17"> <rect x="0" y="416.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-18"> +<clipPath id="terminal-2078985096-line-18"> <rect x="0" y="440.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-19"> +<clipPath id="terminal-2078985096-line-19"> <rect x="0" y="465.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-20"> +<clipPath id="terminal-2078985096-line-20"> <rect x="0" y="489.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-21"> +<clipPath id="terminal-2078985096-line-21"> <rect x="0" y="513.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-22"> +<clipPath id="terminal-2078985096-line-22"> <rect x="0" y="538.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-23"> +<clipPath id="terminal-2078985096-line-23"> <rect x="0" y="562.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-24"> +<clipPath id="terminal-2078985096-line-24"> <rect x="0" y="587.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-25"> +<clipPath id="terminal-2078985096-line-25"> <rect x="0" y="611.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-26"> +<clipPath id="terminal-2078985096-line-26"> <rect x="0" y="635.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-27"> +<clipPath id="terminal-2078985096-line-27"> <rect x="0" y="660.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-996065968-line-28"> +<clipPath id="terminal-2078985096-line-28"> <rect x="0" y="684.7" width="1220" height="24.65"/> </clipPath> </defs> - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-996065968-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-2078985096-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> <g transform="translate(26,22)"> <circle cx="0" cy="0" r="7" fill="#ff5f57"/> <circle cx="22" cy="0" r="7" fill="#febc2e"/> <circle cx="44" cy="0" r="7" fill="#28c840"/> </g> - <g transform="translate(9, 41)" clip-path="url(#terminal-996065968-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="463.6" y="294.3" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="683.2" y="660.3" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="719.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-996065968-matrix"> - <text class="terminal-996065968-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-996065968-line-0)"> -</text><text class="terminal-996065968-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-996065968-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-996065968-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-996065968-line-1)"> -</text><text class="terminal-996065968-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-996065968-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-996065968-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-996065968-line-2)"> -</text><text class="terminal-996065968-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-996065968-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-996065968-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-996065968-line-3)"> -</text><text class="terminal-996065968-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-996065968-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-996065968-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-996065968-line-4)"> -</text><text class="terminal-996065968-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-996065968-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-996065968-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-996065968-line-5)"> -</text><text class="terminal-996065968-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-996065968-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-996065968-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-996065968-line-6)"> -</text><text class="terminal-996065968-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-996065968-line-7)">v9.9.9</text><text class="terminal-996065968-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-996065968-line-7)"> -</text><text class="terminal-996065968-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-996065968-line-8)"> -</text><text class="terminal-996065968-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-996065968-line-9)">Listening… start talking when you're ready.</text><text class="terminal-996065968-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-996065968-line-9)"> -</text><text class="terminal-996065968-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-996065968-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-996065968-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-996065968-line-10)"> -</text><text class="terminal-996065968-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-996065968-line-11)"> -</text><text class="terminal-996065968-r6" x="24.4" y="312.8" textLength="439.2" clip-path="url(#terminal-996065968-line-12)">» what's the weather like in Boston?</text><text class="terminal-996065968-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-996065968-line-12)"> -</text><text class="terminal-996065968-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-996065968-line-13)"> -</text><text class="terminal-996065968-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-996065968-line-14)"> -</text><text class="terminal-996065968-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-996065968-line-15)"> -</text><text class="terminal-996065968-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-996065968-line-16)"> -</text><text class="terminal-996065968-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-996065968-line-17)"> -</text><text class="terminal-996065968-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-996065968-line-18)"> -</text><text class="terminal-996065968-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-996065968-line-19)"> -</text><text class="terminal-996065968-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-996065968-line-20)"> -</text><text class="terminal-996065968-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-996065968-line-21)"> -</text><text class="terminal-996065968-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-996065968-line-22)"> -</text><text class="terminal-996065968-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-996065968-line-23)"> -</text><text class="terminal-996065968-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-996065968-line-24)"> -</text><text class="terminal-996065968-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-996065968-line-25)"> -</text><text class="terminal-996065968-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-996065968-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-996065968-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-996065968-line-26)"> -</text><text class="terminal-996065968-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-996065968-line-27)">│</text><text class="terminal-996065968-r7" x="524.6" y="678.8" textLength="36.6" clip-path="url(#terminal-996065968-line-27)">▃▅▇</text><text class="terminal-996065968-r4" x="561.2" y="678.8" textLength="122" clip-path="url(#terminal-996065968-line-27)"> Thinking…</text><text class="terminal-996065968-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-996065968-line-27)">│</text><text class="terminal-996065968-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-996065968-line-27)"> -</text><text class="terminal-996065968-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-996065968-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-996065968-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-996065968-line-28)"> -</text><text class="terminal-996065968-r3" x="12.2" y="727.6" textLength="488" clip-path="url(#terminal-996065968-line-29)">Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + <g transform="translate(9, 41)" clip-path="url(#terminal-2078985096-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="463.6" y="294.3" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="683.2" y="660.3" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-2078985096-matrix"> + <text class="terminal-2078985096-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-2078985096-line-0)"> +</text><text class="terminal-2078985096-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-2078985096-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-2078985096-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-2078985096-line-1)"> +</text><text class="terminal-2078985096-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-2078985096-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-2078985096-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-2078985096-line-2)"> +</text><text class="terminal-2078985096-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-2078985096-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-2078985096-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-2078985096-line-3)"> +</text><text class="terminal-2078985096-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-2078985096-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-2078985096-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-2078985096-line-4)"> +</text><text class="terminal-2078985096-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-2078985096-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-2078985096-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-2078985096-line-5)"> +</text><text class="terminal-2078985096-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-2078985096-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-2078985096-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-2078985096-line-6)"> +</text><text class="terminal-2078985096-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-2078985096-line-7)">v9.9.9</text><text class="terminal-2078985096-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-2078985096-line-7)"> +</text><text class="terminal-2078985096-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-2078985096-line-8)"> +</text><text class="terminal-2078985096-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-2078985096-line-9)">Listening… start talking when you're ready.</text><text class="terminal-2078985096-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-2078985096-line-9)"> +</text><text class="terminal-2078985096-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-2078985096-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-2078985096-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-2078985096-line-10)"> +</text><text class="terminal-2078985096-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-2078985096-line-11)"> +</text><text class="terminal-2078985096-r6" x="24.4" y="312.8" textLength="439.2" clip-path="url(#terminal-2078985096-line-12)">» what's the weather like in Boston?</text><text class="terminal-2078985096-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-2078985096-line-12)"> +</text><text class="terminal-2078985096-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-2078985096-line-13)"> +</text><text class="terminal-2078985096-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-2078985096-line-14)"> +</text><text class="terminal-2078985096-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-2078985096-line-15)"> +</text><text class="terminal-2078985096-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-2078985096-line-16)"> +</text><text class="terminal-2078985096-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-2078985096-line-17)"> +</text><text class="terminal-2078985096-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-2078985096-line-18)"> +</text><text class="terminal-2078985096-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-2078985096-line-19)"> +</text><text class="terminal-2078985096-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-2078985096-line-20)"> +</text><text class="terminal-2078985096-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-2078985096-line-21)"> +</text><text class="terminal-2078985096-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-2078985096-line-22)"> +</text><text class="terminal-2078985096-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-2078985096-line-23)"> +</text><text class="terminal-2078985096-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-2078985096-line-24)"> +</text><text class="terminal-2078985096-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-2078985096-line-25)"> +</text><text class="terminal-2078985096-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-2078985096-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-2078985096-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-2078985096-line-26)"> +</text><text class="terminal-2078985096-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-2078985096-line-27)">│</text><text class="terminal-2078985096-r7" x="524.6" y="678.8" textLength="36.6" clip-path="url(#terminal-2078985096-line-27)">▃▅▇</text><text class="terminal-2078985096-r4" x="561.2" y="678.8" textLength="122" clip-path="url(#terminal-2078985096-line-27)"> Thinking…</text><text class="terminal-2078985096-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-2078985096-line-27)">│</text><text class="terminal-2078985096-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-2078985096-line-27)"> +</text><text class="terminal-2078985096-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-2078985096-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-2078985096-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-2078985096-line-28)"> +</text><text class="terminal-2078985096-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-2078985096-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> </g> </g> </svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_tool_call_note.raw b/tests/__snapshots__/test_tui_snapshots/test_live_tool_call_note.raw index db136a9f..681a94f5 100644 --- a/tests/__snapshots__/test_tui_snapshots/test_live_tool_call_note.raw +++ b/tests/__snapshots__/test_tui_snapshots/test_live_tool_call_note.raw @@ -19,162 +19,162 @@ font-weight: 700; } - .terminal-104702934-matrix { + .terminal-670018734-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-104702934-title { + .terminal-670018734-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-104702934-r1 { fill: #c5c8c6 } -.terminal-104702934-r2 { fill: #614fd2;font-weight: bold } -.terminal-104702934-r3 { fill: #939393 } -.terminal-104702934-r4 { fill: #e0e0e0 } -.terminal-104702934-r5 { fill: #614fd2 } -.terminal-104702934-r6 { fill: #38bdf8;font-weight: bold } -.terminal-104702934-r7 { fill: #8a8f98 } -.terminal-104702934-r8 { fill: #f59e0b } + .terminal-670018734-r1 { fill: #c5c8c6 } +.terminal-670018734-r2 { fill: #614fd2;font-weight: bold } +.terminal-670018734-r3 { fill: #939393 } +.terminal-670018734-r4 { fill: #e0e0e0 } +.terminal-670018734-r5 { fill: #614fd2 } +.terminal-670018734-r6 { fill: #38bdf8;font-weight: bold } +.terminal-670018734-r7 { fill: #8a8f98 } +.terminal-670018734-r8 { fill: #f59e0b } </style> <defs> - <clipPath id="terminal-104702934-clip-terminal"> + <clipPath id="terminal-670018734-clip-terminal"> <rect x="0" y="0" width="1219.0" height="731.0" /> </clipPath> - <clipPath id="terminal-104702934-line-0"> + <clipPath id="terminal-670018734-line-0"> <rect x="0" y="1.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-1"> +<clipPath id="terminal-670018734-line-1"> <rect x="0" y="25.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-2"> +<clipPath id="terminal-670018734-line-2"> <rect x="0" y="50.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-3"> +<clipPath id="terminal-670018734-line-3"> <rect x="0" y="74.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-4"> +<clipPath id="terminal-670018734-line-4"> <rect x="0" y="99.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-5"> +<clipPath id="terminal-670018734-line-5"> <rect x="0" y="123.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-6"> +<clipPath id="terminal-670018734-line-6"> <rect x="0" y="147.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-7"> +<clipPath id="terminal-670018734-line-7"> <rect x="0" y="172.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-8"> +<clipPath id="terminal-670018734-line-8"> <rect x="0" y="196.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-9"> +<clipPath id="terminal-670018734-line-9"> <rect x="0" y="221.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-10"> +<clipPath id="terminal-670018734-line-10"> <rect x="0" y="245.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-11"> +<clipPath id="terminal-670018734-line-11"> <rect x="0" y="269.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-12"> +<clipPath id="terminal-670018734-line-12"> <rect x="0" y="294.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-13"> +<clipPath id="terminal-670018734-line-13"> <rect x="0" y="318.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-14"> +<clipPath id="terminal-670018734-line-14"> <rect x="0" y="343.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-15"> +<clipPath id="terminal-670018734-line-15"> <rect x="0" y="367.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-16"> +<clipPath id="terminal-670018734-line-16"> <rect x="0" y="391.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-17"> +<clipPath id="terminal-670018734-line-17"> <rect x="0" y="416.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-18"> +<clipPath id="terminal-670018734-line-18"> <rect x="0" y="440.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-19"> +<clipPath id="terminal-670018734-line-19"> <rect x="0" y="465.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-20"> +<clipPath id="terminal-670018734-line-20"> <rect x="0" y="489.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-21"> +<clipPath id="terminal-670018734-line-21"> <rect x="0" y="513.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-22"> +<clipPath id="terminal-670018734-line-22"> <rect x="0" y="538.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-23"> +<clipPath id="terminal-670018734-line-23"> <rect x="0" y="562.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-24"> +<clipPath id="terminal-670018734-line-24"> <rect x="0" y="587.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-25"> +<clipPath id="terminal-670018734-line-25"> <rect x="0" y="611.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-26"> +<clipPath id="terminal-670018734-line-26"> <rect x="0" y="635.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-27"> +<clipPath id="terminal-670018734-line-27"> <rect x="0" y="660.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-104702934-line-28"> +<clipPath id="terminal-670018734-line-28"> <rect x="0" y="684.7" width="1220" height="24.65"/> </clipPath> </defs> - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-104702934-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-670018734-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> <g transform="translate(26,22)"> <circle cx="0" cy="0" r="7" fill="#ff5f57"/> <circle cx="22" cy="0" r="7" fill="#febc2e"/> <circle cx="44" cy="0" r="7" fill="#28c840"/> </g> - <g transform="translate(9, 41)" clip-path="url(#terminal-104702934-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="463.6" y="294.3" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="427" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="451.4" y="343.1" width="768.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="390.4" y="367.5" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="683.2" y="660.3" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="719.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-104702934-matrix"> - <text class="terminal-104702934-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-104702934-line-0)"> -</text><text class="terminal-104702934-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-104702934-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-104702934-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-104702934-line-1)"> -</text><text class="terminal-104702934-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-104702934-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-104702934-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-104702934-line-2)"> -</text><text class="terminal-104702934-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-104702934-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-104702934-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-104702934-line-3)"> -</text><text class="terminal-104702934-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-104702934-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-104702934-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-104702934-line-4)"> -</text><text class="terminal-104702934-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-104702934-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-104702934-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-104702934-line-5)"> -</text><text class="terminal-104702934-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-104702934-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-104702934-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-104702934-line-6)"> -</text><text class="terminal-104702934-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-104702934-line-7)">v9.9.9</text><text class="terminal-104702934-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-104702934-line-7)"> -</text><text class="terminal-104702934-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-104702934-line-8)"> -</text><text class="terminal-104702934-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-104702934-line-9)">Listening… start talking when you're ready.</text><text class="terminal-104702934-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-104702934-line-9)"> -</text><text class="terminal-104702934-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-104702934-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-104702934-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-104702934-line-10)"> -</text><text class="terminal-104702934-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-104702934-line-11)"> -</text><text class="terminal-104702934-r6" x="24.4" y="312.8" textLength="439.2" clip-path="url(#terminal-104702934-line-12)">» what's the weather like in Boston?</text><text class="terminal-104702934-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-104702934-line-12)"> -</text><text class="terminal-104702934-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-104702934-line-13)"> -</text><text class="terminal-104702934-r7" x="24.4" y="361.6" textLength="427" clip-path="url(#terminal-104702934-line-14)">Searching the web · Boston weather…</text><text class="terminal-104702934-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-104702934-line-14)"> -</text><text class="terminal-104702934-r7" x="24.4" y="386" textLength="366" clip-path="url(#terminal-104702934-line-15)">Using read_file · forecast.md…</text><text class="terminal-104702934-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-104702934-line-15)"> -</text><text class="terminal-104702934-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-104702934-line-16)"> -</text><text class="terminal-104702934-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-104702934-line-17)"> -</text><text class="terminal-104702934-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-104702934-line-18)"> -</text><text class="terminal-104702934-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-104702934-line-19)"> -</text><text class="terminal-104702934-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-104702934-line-20)"> -</text><text class="terminal-104702934-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-104702934-line-21)"> -</text><text class="terminal-104702934-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-104702934-line-22)"> -</text><text class="terminal-104702934-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-104702934-line-23)"> -</text><text class="terminal-104702934-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-104702934-line-24)"> -</text><text class="terminal-104702934-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-104702934-line-25)"> -</text><text class="terminal-104702934-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-104702934-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-104702934-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-104702934-line-26)"> -</text><text class="terminal-104702934-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-104702934-line-27)">│</text><text class="terminal-104702934-r8" x="524.6" y="678.8" textLength="36.6" clip-path="url(#terminal-104702934-line-27)">▃▅▇</text><text class="terminal-104702934-r4" x="561.2" y="678.8" textLength="122" clip-path="url(#terminal-104702934-line-27)"> Thinking…</text><text class="terminal-104702934-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-104702934-line-27)">│</text><text class="terminal-104702934-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-104702934-line-27)"> -</text><text class="terminal-104702934-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-104702934-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-104702934-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-104702934-line-28)"> -</text><text class="terminal-104702934-r3" x="12.2" y="727.6" textLength="488" clip-path="url(#terminal-104702934-line-29)">Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + <g transform="translate(9, 41)" clip-path="url(#terminal-670018734-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="463.6" y="294.3" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="427" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="451.4" y="343.1" width="768.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="390.4" y="367.5" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="683.2" y="660.3" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-670018734-matrix"> + <text class="terminal-670018734-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-670018734-line-0)"> +</text><text class="terminal-670018734-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-670018734-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-670018734-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-670018734-line-1)"> +</text><text class="terminal-670018734-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-670018734-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-670018734-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-670018734-line-2)"> +</text><text class="terminal-670018734-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-670018734-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-670018734-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-670018734-line-3)"> +</text><text class="terminal-670018734-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-670018734-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-670018734-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-670018734-line-4)"> +</text><text class="terminal-670018734-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-670018734-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-670018734-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-670018734-line-5)"> +</text><text class="terminal-670018734-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-670018734-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-670018734-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-670018734-line-6)"> +</text><text class="terminal-670018734-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-670018734-line-7)">v9.9.9</text><text class="terminal-670018734-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-670018734-line-7)"> +</text><text class="terminal-670018734-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-670018734-line-8)"> +</text><text class="terminal-670018734-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-670018734-line-9)">Listening… start talking when you're ready.</text><text class="terminal-670018734-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-670018734-line-9)"> +</text><text class="terminal-670018734-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-670018734-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-670018734-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-670018734-line-10)"> +</text><text class="terminal-670018734-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-670018734-line-11)"> +</text><text class="terminal-670018734-r6" x="24.4" y="312.8" textLength="439.2" clip-path="url(#terminal-670018734-line-12)">» what's the weather like in Boston?</text><text class="terminal-670018734-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-670018734-line-12)"> +</text><text class="terminal-670018734-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-670018734-line-13)"> +</text><text class="terminal-670018734-r7" x="24.4" y="361.6" textLength="427" clip-path="url(#terminal-670018734-line-14)">Searching the web · Boston weather…</text><text class="terminal-670018734-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-670018734-line-14)"> +</text><text class="terminal-670018734-r7" x="24.4" y="386" textLength="366" clip-path="url(#terminal-670018734-line-15)">Using read_file · forecast.md…</text><text class="terminal-670018734-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-670018734-line-15)"> +</text><text class="terminal-670018734-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-670018734-line-16)"> +</text><text class="terminal-670018734-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-670018734-line-17)"> +</text><text class="terminal-670018734-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-670018734-line-18)"> +</text><text class="terminal-670018734-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-670018734-line-19)"> +</text><text class="terminal-670018734-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-670018734-line-20)"> +</text><text class="terminal-670018734-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-670018734-line-21)"> +</text><text class="terminal-670018734-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-670018734-line-22)"> +</text><text class="terminal-670018734-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-670018734-line-23)"> +</text><text class="terminal-670018734-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-670018734-line-24)"> +</text><text class="terminal-670018734-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-670018734-line-25)"> +</text><text class="terminal-670018734-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-670018734-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-670018734-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-670018734-line-26)"> +</text><text class="terminal-670018734-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-670018734-line-27)">│</text><text class="terminal-670018734-r8" x="524.6" y="678.8" textLength="36.6" clip-path="url(#terminal-670018734-line-27)">▃▅▇</text><text class="terminal-670018734-r4" x="561.2" y="678.8" textLength="122" clip-path="url(#terminal-670018734-line-27)"> Thinking…</text><text class="terminal-670018734-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-670018734-line-27)">│</text><text class="terminal-670018734-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-670018734-line-27)"> +</text><text class="terminal-670018734-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-670018734-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-670018734-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-670018734-line-28)"> +</text><text class="terminal-670018734-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-670018734-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> </g> </g> </svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_tool_then_answer_ordering.raw b/tests/__snapshots__/test_tui_snapshots/test_live_tool_then_answer_ordering.raw new file mode 100644 index 00000000..2b1897c9 --- /dev/null +++ b/tests/__snapshots__/test_tui_snapshots/test_live_tool_then_answer_ordering.raw @@ -0,0 +1,180 @@ +<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> + <!-- Generated with Rich https://www.textualize.io --> + <style> + + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Regular"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Bold"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); + font-style: bold; + font-weight: 700; + } + + .terminal-3735494197-matrix { + font-family: Fira Code, monospace; + font-size: 20px; + line-height: 24.4px; + font-variant-east-asian: full-width; + } + + .terminal-3735494197-title { + font-size: 18px; + font-weight: bold; + font-family: arial; + } + + .terminal-3735494197-r1 { fill: #c5c8c6 } +.terminal-3735494197-r2 { fill: #614fd2;font-weight: bold } +.terminal-3735494197-r3 { fill: #939393 } +.terminal-3735494197-r4 { fill: #e0e0e0 } +.terminal-3735494197-r5 { fill: #614fd2 } +.terminal-3735494197-r6 { fill: #38bdf8;font-weight: bold } +.terminal-3735494197-r7 { fill: #8a8f98 } +.terminal-3735494197-r8 { fill: #22c55e } + </style> + + <defs> + <clipPath id="terminal-3735494197-clip-terminal"> + <rect x="0" y="0" width="1219.0" height="731.0" /> + </clipPath> + <clipPath id="terminal-3735494197-line-0"> + <rect x="0" y="1.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-1"> + <rect x="0" y="25.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-2"> + <rect x="0" y="50.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-3"> + <rect x="0" y="74.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-4"> + <rect x="0" y="99.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-5"> + <rect x="0" y="123.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-6"> + <rect x="0" y="147.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-7"> + <rect x="0" y="172.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-8"> + <rect x="0" y="196.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-9"> + <rect x="0" y="221.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-10"> + <rect x="0" y="245.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-11"> + <rect x="0" y="269.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-12"> + <rect x="0" y="294.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-13"> + <rect x="0" y="318.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-14"> + <rect x="0" y="343.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-15"> + <rect x="0" y="367.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-16"> + <rect x="0" y="391.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-17"> + <rect x="0" y="416.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-18"> + <rect x="0" y="440.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-19"> + <rect x="0" y="465.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-20"> + <rect x="0" y="489.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-21"> + <rect x="0" y="513.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-22"> + <rect x="0" y="538.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-23"> + <rect x="0" y="562.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-24"> + <rect x="0" y="587.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-25"> + <rect x="0" y="611.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-26"> + <rect x="0" y="635.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-27"> + <rect x="0" y="660.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-3735494197-line-28"> + <rect x="0" y="684.7" width="1220" height="24.65"/> + </clipPath> + </defs> + + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-3735494197-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <g transform="translate(26,22)"> + <circle cx="0" cy="0" r="7" fill="#ff5f57"/> + <circle cx="22" cy="0" r="7" fill="#febc2e"/> + <circle cx="44" cy="0" r="7" fill="#28c840"/> + </g> + + <g transform="translate(9, 41)" clip-path="url(#terminal-3735494197-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="463.6" y="294.3" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="343.1" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="390.4" y="343.1" width="829.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="367.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="367.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="416.3" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="585.6" y="416.3" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="524.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="561.2" y="660.3" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="683.2" y="660.3" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-3735494197-matrix"> + <text class="terminal-3735494197-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-3735494197-line-0)"> +</text><text class="terminal-3735494197-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-3735494197-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-3735494197-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-3735494197-line-1)"> +</text><text class="terminal-3735494197-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-3735494197-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-3735494197-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-3735494197-line-2)"> +</text><text class="terminal-3735494197-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-3735494197-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-3735494197-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-3735494197-line-3)"> +</text><text class="terminal-3735494197-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-3735494197-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-3735494197-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-3735494197-line-4)"> +</text><text class="terminal-3735494197-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-3735494197-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-3735494197-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-3735494197-line-5)"> +</text><text class="terminal-3735494197-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-3735494197-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-3735494197-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-3735494197-line-6)"> +</text><text class="terminal-3735494197-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-3735494197-line-7)">v9.9.9</text><text class="terminal-3735494197-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-3735494197-line-7)"> +</text><text class="terminal-3735494197-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-3735494197-line-8)"> +</text><text class="terminal-3735494197-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-3735494197-line-9)">Listening… start talking when you're ready.</text><text class="terminal-3735494197-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-3735494197-line-9)"> +</text><text class="terminal-3735494197-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-3735494197-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-3735494197-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-3735494197-line-10)"> +</text><text class="terminal-3735494197-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-3735494197-line-11)"> +</text><text class="terminal-3735494197-r6" x="24.4" y="312.8" textLength="439.2" clip-path="url(#terminal-3735494197-line-12)">» what's the weather like in Boston?</text><text class="terminal-3735494197-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-3735494197-line-12)"> +</text><text class="terminal-3735494197-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-3735494197-line-13)"> +</text><text class="terminal-3735494197-r7" x="24.4" y="361.6" textLength="366" clip-path="url(#terminal-3735494197-line-14)">Checking the weather · Boston…</text><text class="terminal-3735494197-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-3735494197-line-14)"> +</text><text class="terminal-3735494197-r7" x="24.4" y="386" textLength="475.8" clip-path="url(#terminal-3735494197-line-15)">Checking the weather · Boston forecast…</text><text class="terminal-3735494197-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-3735494197-line-15)"> +</text><text class="terminal-3735494197-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-3735494197-line-16)"> +</text><text class="terminal-3735494197-r4" x="24.4" y="434.8" textLength="561.2" clip-path="url(#terminal-3735494197-line-17)">It's sunny and about sixty degrees right now. </text><text class="terminal-3735494197-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-3735494197-line-17)"> +</text><text class="terminal-3735494197-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-3735494197-line-18)"> +</text><text class="terminal-3735494197-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-3735494197-line-19)"> +</text><text class="terminal-3735494197-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-3735494197-line-20)"> +</text><text class="terminal-3735494197-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-3735494197-line-21)"> +</text><text class="terminal-3735494197-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-3735494197-line-22)"> +</text><text class="terminal-3735494197-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-3735494197-line-23)"> +</text><text class="terminal-3735494197-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-3735494197-line-24)"> +</text><text class="terminal-3735494197-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-3735494197-line-25)"> +</text><text class="terminal-3735494197-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-3735494197-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-3735494197-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-3735494197-line-26)"> +</text><text class="terminal-3735494197-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-3735494197-line-27)">│</text><text class="terminal-3735494197-r8" x="524.6" y="678.8" textLength="36.6" clip-path="url(#terminal-3735494197-line-27)">▅▇▆</text><text class="terminal-3735494197-r4" x="561.2" y="678.8" textLength="122" clip-path="url(#terminal-3735494197-line-27)"> Speaking…</text><text class="terminal-3735494197-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-3735494197-line-27)">│</text><text class="terminal-3735494197-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-3735494197-line-27)"> +</text><text class="terminal-3735494197-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-3735494197-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-3735494197-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-3735494197-line-28)"> +</text><text class="terminal-3735494197-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-3735494197-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + </g> + </g> +</svg> diff --git a/tests/__snapshots__/test_tui_snapshots/test_live_user_partial.raw b/tests/__snapshots__/test_tui_snapshots/test_live_user_partial.raw index 85682dff..d304c771 100644 --- a/tests/__snapshots__/test_tui_snapshots/test_live_user_partial.raw +++ b/tests/__snapshots__/test_tui_snapshots/test_live_user_partial.raw @@ -19,160 +19,160 @@ font-weight: 700; } - .terminal-546096555-matrix { + .terminal-1679019651-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-546096555-title { + .terminal-1679019651-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-546096555-r1 { fill: #c5c8c6 } -.terminal-546096555-r2 { fill: #614fd2;font-weight: bold } -.terminal-546096555-r3 { fill: #939393 } -.terminal-546096555-r4 { fill: #e0e0e0 } -.terminal-546096555-r5 { fill: #614fd2 } -.terminal-546096555-r6 { fill: #38bdf8;font-weight: bold } + .terminal-1679019651-r1 { fill: #c5c8c6 } +.terminal-1679019651-r2 { fill: #614fd2;font-weight: bold } +.terminal-1679019651-r3 { fill: #939393 } +.terminal-1679019651-r4 { fill: #e0e0e0 } +.terminal-1679019651-r5 { fill: #614fd2 } +.terminal-1679019651-r6 { fill: #38bdf8;font-weight: bold } </style> <defs> - <clipPath id="terminal-546096555-clip-terminal"> + <clipPath id="terminal-1679019651-clip-terminal"> <rect x="0" y="0" width="1219.0" height="731.0" /> </clipPath> - <clipPath id="terminal-546096555-line-0"> + <clipPath id="terminal-1679019651-line-0"> <rect x="0" y="1.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-1"> +<clipPath id="terminal-1679019651-line-1"> <rect x="0" y="25.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-2"> +<clipPath id="terminal-1679019651-line-2"> <rect x="0" y="50.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-3"> +<clipPath id="terminal-1679019651-line-3"> <rect x="0" y="74.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-4"> +<clipPath id="terminal-1679019651-line-4"> <rect x="0" y="99.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-5"> +<clipPath id="terminal-1679019651-line-5"> <rect x="0" y="123.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-6"> +<clipPath id="terminal-1679019651-line-6"> <rect x="0" y="147.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-7"> +<clipPath id="terminal-1679019651-line-7"> <rect x="0" y="172.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-8"> +<clipPath id="terminal-1679019651-line-8"> <rect x="0" y="196.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-9"> +<clipPath id="terminal-1679019651-line-9"> <rect x="0" y="221.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-10"> +<clipPath id="terminal-1679019651-line-10"> <rect x="0" y="245.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-11"> +<clipPath id="terminal-1679019651-line-11"> <rect x="0" y="269.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-12"> +<clipPath id="terminal-1679019651-line-12"> <rect x="0" y="294.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-13"> +<clipPath id="terminal-1679019651-line-13"> <rect x="0" y="318.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-14"> +<clipPath id="terminal-1679019651-line-14"> <rect x="0" y="343.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-15"> +<clipPath id="terminal-1679019651-line-15"> <rect x="0" y="367.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-16"> +<clipPath id="terminal-1679019651-line-16"> <rect x="0" y="391.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-17"> +<clipPath id="terminal-1679019651-line-17"> <rect x="0" y="416.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-18"> +<clipPath id="terminal-1679019651-line-18"> <rect x="0" y="440.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-19"> +<clipPath id="terminal-1679019651-line-19"> <rect x="0" y="465.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-20"> +<clipPath id="terminal-1679019651-line-20"> <rect x="0" y="489.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-21"> +<clipPath id="terminal-1679019651-line-21"> <rect x="0" y="513.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-22"> +<clipPath id="terminal-1679019651-line-22"> <rect x="0" y="538.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-23"> +<clipPath id="terminal-1679019651-line-23"> <rect x="0" y="562.7" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-24"> +<clipPath id="terminal-1679019651-line-24"> <rect x="0" y="587.1" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-25"> +<clipPath id="terminal-1679019651-line-25"> <rect x="0" y="611.5" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-26"> +<clipPath id="terminal-1679019651-line-26"> <rect x="0" y="635.9" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-27"> +<clipPath id="terminal-1679019651-line-27"> <rect x="0" y="660.3" width="1220" height="24.65"/> </clipPath> -<clipPath id="terminal-546096555-line-28"> +<clipPath id="terminal-1679019651-line-28"> <rect x="0" y="684.7" width="1220" height="24.65"/> </clipPath> </defs> - <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-546096555-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-1679019651-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">AssemblyAI Live</text> <g transform="translate(26,22)"> <circle cx="0" cy="0" r="7" fill="#ff5f57"/> <circle cx="22" cy="0" r="7" fill="#febc2e"/> <circle cx="44" cy="0" r="7" fill="#28c840"/> </g> - <g transform="translate(9, 41)" clip-path="url(#terminal-546096555-clip-terminal)"> - <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="366" y="294.3" width="854" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="817.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="500.2" y="709.1" width="719.8" height="24.65" shape-rendering="crispEdges"/> - <g class="terminal-546096555-matrix"> - <text class="terminal-546096555-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-546096555-line-0)"> -</text><text class="terminal-546096555-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-546096555-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-546096555-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-546096555-line-1)"> -</text><text class="terminal-546096555-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-546096555-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-546096555-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-546096555-line-2)"> -</text><text class="terminal-546096555-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-546096555-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-546096555-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-546096555-line-3)"> -</text><text class="terminal-546096555-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-546096555-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-546096555-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-546096555-line-4)"> -</text><text class="terminal-546096555-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-546096555-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-546096555-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-546096555-line-5)"> -</text><text class="terminal-546096555-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-546096555-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-546096555-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-546096555-line-6)"> -</text><text class="terminal-546096555-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-546096555-line-7)">v9.9.9</text><text class="terminal-546096555-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-546096555-line-7)"> -</text><text class="terminal-546096555-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-546096555-line-8)"> -</text><text class="terminal-546096555-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-546096555-line-9)">Listening… start talking when you're ready.</text><text class="terminal-546096555-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-546096555-line-9)"> -</text><text class="terminal-546096555-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-546096555-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-546096555-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-546096555-line-10)"> -</text><text class="terminal-546096555-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-546096555-line-11)"> -</text><text class="terminal-546096555-r6" x="24.4" y="312.8" textLength="341.6" clip-path="url(#terminal-546096555-line-12)">» what's the weather like in</text><text class="terminal-546096555-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-546096555-line-12)"> -</text><text class="terminal-546096555-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-546096555-line-13)"> -</text><text class="terminal-546096555-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-546096555-line-14)"> -</text><text class="terminal-546096555-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-546096555-line-15)"> -</text><text class="terminal-546096555-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-546096555-line-16)"> -</text><text class="terminal-546096555-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-546096555-line-17)"> -</text><text class="terminal-546096555-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-546096555-line-18)"> -</text><text class="terminal-546096555-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-546096555-line-19)"> -</text><text class="terminal-546096555-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-546096555-line-20)"> -</text><text class="terminal-546096555-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-546096555-line-21)"> -</text><text class="terminal-546096555-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-546096555-line-22)"> -</text><text class="terminal-546096555-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-546096555-line-23)"> -</text><text class="terminal-546096555-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-546096555-line-24)"> -</text><text class="terminal-546096555-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-546096555-line-25)"> -</text><text class="terminal-546096555-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-546096555-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-546096555-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-546096555-line-26)"> -</text><text class="terminal-546096555-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-546096555-line-27)">│</text><text class="terminal-546096555-r5" x="402.6" y="678.8" textLength="36.6" clip-path="url(#terminal-546096555-line-27)">▃▅▇</text><text class="terminal-546096555-r4" x="439.2" y="678.8" textLength="378.2" clip-path="url(#terminal-546096555-line-27)"> Listening — speak your request</text><text class="terminal-546096555-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-546096555-line-27)">│</text><text class="terminal-546096555-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-546096555-line-27)"> -</text><text class="terminal-546096555-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-546096555-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-546096555-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-546096555-line-28)"> -</text><text class="terminal-546096555-r3" x="12.2" y="727.6" textLength="488" clip-path="url(#terminal-546096555-line-29)">Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> + <g transform="translate(9, 41)" clip-path="url(#terminal-1679019651-clip-terminal)"> + <rect fill="#000000" x="0" y="1.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="25.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="25.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="50.3" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="50.3" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="74.7" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="74.7" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="99.1" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="99.1" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="123.5" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="123.5" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="147.9" width="915" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="939.4" y="147.9" width="280.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="172.3" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="97.6" y="172.3" width="1122.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="196.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="221.1" width="524.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="549" y="221.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="245.5" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="744.2" y="245.5" width="475.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="269.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="294.3" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="366" y="294.3" width="854" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="318.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="343.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="367.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="391.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="416.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="440.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="465.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="489.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="513.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="538.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="562.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="587.1" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="611.5" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="24.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="402.6" y="660.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="439.2" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="817.4" y="660.3" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1195.6" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="684.7" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="1207.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="0" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="12.2" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#000000" x="890.6" y="709.1" width="329.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-1679019651-matrix"> + <text class="terminal-1679019651-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-1679019651-line-0)"> +</text><text class="terminal-1679019651-r2" x="24.4" y="44.4" textLength="915" clip-path="url(#terminal-1679019651-line-1)"> █████╗  ███████╗ ███████╗ ███████╗ ███╗   ███╗ ██████╗  ██╗      ██╗   ██╗</text><text class="terminal-1679019651-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-1679019651-line-1)"> +</text><text class="terminal-1679019651-r2" x="24.4" y="68.8" textLength="915" clip-path="url(#terminal-1679019651-line-2)">██╔══██╗ ██╔════╝ ██╔════╝ ██╔════╝ ████╗ ████║ ██╔══██╗ ██║      ╚██╗ ██╔╝</text><text class="terminal-1679019651-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-1679019651-line-2)"> +</text><text class="terminal-1679019651-r2" x="24.4" y="93.2" textLength="915" clip-path="url(#terminal-1679019651-line-3)">███████║ ███████╗ ███████╗ █████╗   ██╔████╔██║ ██████╔╝ ██║       ╚████╔╝ </text><text class="terminal-1679019651-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-1679019651-line-3)"> +</text><text class="terminal-1679019651-r2" x="24.4" y="117.6" textLength="915" clip-path="url(#terminal-1679019651-line-4)">██╔══██║ ╚════██║ ╚════██║ ██╔══╝   ██║╚██╔╝██║ ██╔══██╗ ██║        ╚██╔╝  </text><text class="terminal-1679019651-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-1679019651-line-4)"> +</text><text class="terminal-1679019651-r2" x="24.4" y="142" textLength="915" clip-path="url(#terminal-1679019651-line-5)">██║  ██║ ███████║ ███████║ ███████╗ ██║ ╚═╝ ██║ ██████╔╝ ███████╗    ██║   </text><text class="terminal-1679019651-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-1679019651-line-5)"> +</text><text class="terminal-1679019651-r2" x="24.4" y="166.4" textLength="915" clip-path="url(#terminal-1679019651-line-6)">╚═╝  ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝     ╚═╝ ╚═════╝  ╚══════╝    ╚═╝   </text><text class="terminal-1679019651-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-1679019651-line-6)"> +</text><text class="terminal-1679019651-r3" x="24.4" y="190.8" textLength="73.2" clip-path="url(#terminal-1679019651-line-7)">v9.9.9</text><text class="terminal-1679019651-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-1679019651-line-7)"> +</text><text class="terminal-1679019651-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-1679019651-line-8)"> +</text><text class="terminal-1679019651-r5" x="24.4" y="239.6" textLength="524.6" clip-path="url(#terminal-1679019651-line-9)">Listening… start talking when you're ready.</text><text class="terminal-1679019651-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-1679019651-line-9)"> +</text><text class="terminal-1679019651-r3" x="24.4" y="264" textLength="719.8" clip-path="url(#terminal-1679019651-line-10)">Use headphones — the mic stays open while the agent speaks.</text><text class="terminal-1679019651-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-1679019651-line-10)"> +</text><text class="terminal-1679019651-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-1679019651-line-11)"> +</text><text class="terminal-1679019651-r6" x="24.4" y="312.8" textLength="341.6" clip-path="url(#terminal-1679019651-line-12)">» what's the weather like in</text><text class="terminal-1679019651-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-1679019651-line-12)"> +</text><text class="terminal-1679019651-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-1679019651-line-13)"> +</text><text class="terminal-1679019651-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-1679019651-line-14)"> +</text><text class="terminal-1679019651-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-1679019651-line-15)"> +</text><text class="terminal-1679019651-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-1679019651-line-16)"> +</text><text class="terminal-1679019651-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-1679019651-line-17)"> +</text><text class="terminal-1679019651-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-1679019651-line-18)"> +</text><text class="terminal-1679019651-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-1679019651-line-19)"> +</text><text class="terminal-1679019651-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-1679019651-line-20)"> +</text><text class="terminal-1679019651-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-1679019651-line-21)"> +</text><text class="terminal-1679019651-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-1679019651-line-22)"> +</text><text class="terminal-1679019651-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-1679019651-line-23)"> +</text><text class="terminal-1679019651-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-1679019651-line-24)"> +</text><text class="terminal-1679019651-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-1679019651-line-25)"> +</text><text class="terminal-1679019651-r5" x="12.2" y="654.4" textLength="1195.6" clip-path="url(#terminal-1679019651-line-26)">╭────────────────────────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-1679019651-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-1679019651-line-26)"> +</text><text class="terminal-1679019651-r5" x="12.2" y="678.8" textLength="12.2" clip-path="url(#terminal-1679019651-line-27)">│</text><text class="terminal-1679019651-r5" x="402.6" y="678.8" textLength="36.6" clip-path="url(#terminal-1679019651-line-27)">▃▅▇</text><text class="terminal-1679019651-r4" x="439.2" y="678.8" textLength="378.2" clip-path="url(#terminal-1679019651-line-27)"> Listening — speak your request</text><text class="terminal-1679019651-r5" x="1195.6" y="678.8" textLength="12.2" clip-path="url(#terminal-1679019651-line-27)">│</text><text class="terminal-1679019651-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-1679019651-line-27)"> +</text><text class="terminal-1679019651-r5" x="12.2" y="703.2" textLength="1195.6" clip-path="url(#terminal-1679019651-line-28)">╰────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-1679019651-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-1679019651-line-28)"> +</text><text class="terminal-1679019651-r3" x="12.2" y="727.6" textLength="878.4" clip-path="url(#terminal-1679019651-line-29)">Space to start/stop listening · Esc/Ctrl-C to interrupt · Ctrl-Q to quit</text> </g> </g> </svg> diff --git a/tests/_cascade_fakes.py b/tests/_cascade_fakes.py index fac72229..8bbb52df 100644 --- a/tests/_cascade_fakes.py +++ b/tests/_cascade_fakes.py @@ -94,16 +94,25 @@ def turn(text, *, end_of_turn=True, turn_is_formatted=True): ) +def _default_stream_reply(messages): + from aai_cli.agent_cascade.brain import SpeechDelta + + return [SpeechDelta("Hello there.")] + + def make_session( *, - complete_reply=lambda messages, on_tool=None: "Hello there.", - synthesize=lambda text: b"pcm:" + text.encode(), + stream_reply=None, + synthesize=lambda text, sink: sink(b"pcm:" + text.encode()), spawn=sync_spawn, run_stt=lambda on_turn: None, config=None, ): deps = CascadeDeps( - run_stt=run_stt, complete_reply=complete_reply, synthesize=synthesize, spawn=spawn + run_stt=run_stt, + stream_reply=stream_reply or _default_stream_reply, + synthesize=synthesize, + spawn=spawn, ) renderer = FakeRenderer() player = FakePlayer() @@ -111,3 +120,10 @@ def make_session( deps=deps, renderer=renderer, player=player, config=config or CascadeConfig() ) return session, renderer, player + + +def deltas(*texts): + """A stream_reply that yields the given strings as SpeechDelta events.""" + from aai_cli.agent_cascade.brain import SpeechDelta + + return lambda messages: [SpeechDelta(t) for t in texts] diff --git a/tests/_snapshot_surface.py b/tests/_snapshot_surface.py index 1737be18..30a59ac5 100644 --- a/tests/_snapshot_surface.py +++ b/tests/_snapshot_surface.py @@ -26,7 +26,6 @@ # module suffix). A brand-new panel must be mapped here before its commands ship. PANEL_TO_GROUP: dict[str, str] = { help_panels.QUICK_START: "build", - help_panels.CODE: "run", help_panels.BUILD: "build", help_panels.TRANSCRIPTION: "run", help_panels.SETUP: "tools", diff --git a/tests/_tui_snapshot.py b/tests/_tui_snapshot.py index 227c3dcf..edbb21ee 100644 --- a/tests/_tui_snapshot.py +++ b/tests/_tui_snapshot.py @@ -2,12 +2,11 @@ ``pytest-textual-snapshot``'s ``snap_compare`` fixture renders a Textual ``App`` to an SVG and diffs it against a committed golden, catching the CSS / layout / docking -regressions the behavioral pilot tests (``test_code_tui.py`` / ``test_live_tui.py``) -can't see — those assert on one widget at a time, never the whole painted frame. +regressions the behavioral pilot tests (``test_live_tui.py``) can't see — those assert +on one widget at a time, never the whole painted frame. -Four things make our two apps (:class:`~aai_cli.code_agent.tui.CodeAgentApp` and -:class:`~aai_cli.agent_cascade.tui.LiveAgentApp`) non-deterministic under a raw render, -so the goldens would churn or flake without neutralising them here: +Two things make :class:`~aai_cli.agent_cascade.tui.LiveAgentApp` non-deterministic under +a raw render, so the goldens would churn or flake without neutralising them here: * **The splash prints ``banner.version()``**, which hatch-vcs derives from the git tag (``v0.1.devN+g<sha>``) — a different string on every commit. ``pin_banner_version`` @@ -19,21 +18,15 @@ worker returns it exits the app before the screenshot. :func:`build_live_app` returns a subclass whose ``_start`` is a no-op, so a snapshot drives the transcript directly with no thread. -* **The code TUI status line renders the cwd, its git branch, and a ``~``-abbreviated - home** — all environment- and platform-specific. :func:`stable_workdir` builds a fixed - cwd (with a fake ``.git/HEAD``) and pins ``Path.home`` so the line is identical on every - machine the suite runs on. """ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING from textual.app import App from aai_cli.agent_cascade.tui import LiveAgentApp -from aai_cli.code_agent.tui import CodeAgentApp if TYPE_CHECKING: import pytest @@ -57,23 +50,6 @@ def invoke(self, *args: object, **kwargs: object) -> dict[str, object]: return {} -class FakeVoice: - """A no-op ``_VoiceIO``; voice-mode snapshots never reach the capture/readback legs. - - The capture leg is stubbed in :class:`_SnapshotCodeApp`, so these are unreached by any - render and are covered by ``test_fake_voice_is_inert`` instead. - """ - - def listen(self) -> str | None: - return None - - def speak(self, text: str) -> None: - pass - - def cancel(self) -> None: - pass - - class _SnapshotLiveApp(LiveAgentApp): """``LiveAgentApp`` whose cascade worker never starts, so the app stays up for a render. @@ -86,32 +62,13 @@ def _start(self) -> None: pass -class _SnapshotCodeApp(CodeAgentApp): - """``CodeAgentApp`` whose background voice-capture leg never starts. - - In voice mode ``on_mount`` spawns a daemon thread that blocks on ``voice.listen()`` and - marshals phase changes back onto the UI thread — which would race the screenshot and make - the bar frame non-deterministic. Stubbing ``_begin_listening`` keeps the app in the - synchronously-rendered listening state (voice bar shown, prompt hidden) with no thread. - """ - - def _begin_listening(self) -> None: - pass - - -def build_code_app(*, cwd: Path, auto_approve: bool = False) -> CodeAgentApp: - """A ``CodeAgentApp`` wired to a fake agent for a visual snapshot.""" - return CodeAgentApp(agent=FakeAgent(), cwd=cwd, auto_approve=auto_approve) - - -def build_code_voice_app(*, cwd: Path) -> _SnapshotCodeApp: - """A ``CodeAgentApp`` in voice mode (listening), with the mic-capture leg stubbed out.""" - return _SnapshotCodeApp(agent=FakeAgent(), cwd=cwd, voice=FakeVoice()) - - def build_live_app() -> _SnapshotLiveApp: """A ``LiveAgentApp`` whose cascade worker is stubbed out so a snapshot can drive it.""" - return _SnapshotLiveApp(run_conversation=lambda renderer: None, on_stop=lambda: None) + return _SnapshotLiveApp( + run_conversation=lambda renderer: None, + on_stop=lambda: None, + on_toggle_listen=lambda: True, + ) def freeze_animation(app: App[None]) -> None: @@ -119,37 +76,15 @@ def freeze_animation(app: App[None]) -> None: The voice bar's meter advances on a 0.3s ``set_interval``; left running, the number of ticks by screenshot time depends on wall-clock scheduling, so the frame would flake. Stop - that timer (and the code TUI's spinner timer) — ``run_before`` is the first thing the - screenshot harness runs, before any pause, so no tick fires before the stop, and the bar - then holds the frame from its last explicit render (a fixed count per test). Accepts the - broad ``App`` that ``Pilot.app`` exposes and narrows to our two apps. + that timer — ``run_before`` is the first thing the screenshot harness runs, before any + pause, so no tick fires before the stop, and the bar then holds the frame from its last + explicit render (a fixed count per test). """ - assert isinstance(app, (CodeAgentApp, LiveAgentApp)) + assert isinstance(app, LiveAgentApp) if app._voice_timer is not None: app._voice_timer.stop() - if isinstance(app, CodeAgentApp) and app._spin_timer is not None: - app._spin_timer.stop() def pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None: """Freeze the splash version string (otherwise it changes on every commit).""" - monkeypatch.setattr("aai_cli.code_agent.banner.version", lambda: _PINNED_VERSION) - - -def stable_workdir( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, branch: str = "main" -) -> Path: - """A fixed cwd whose status line renders identically on every machine. - - Pins ``Path.home`` to ``tmp_path`` and returns a ``tmp_path/demo`` cwd, so - ``_abbrev_home`` collapses it to ``~/demo`` regardless of the real home directory, and - writes a fake ``.git/HEAD`` so ``_git_branch`` reports a deterministic ``branch`` rather - than whatever branch the suite happens to run on. - """ - monkeypatch.setattr(Path, "home", lambda: tmp_path) - demo = tmp_path / "demo" - demo.mkdir() - git_dir = demo / ".git" - git_dir.mkdir() - (git_dir / "HEAD").write_text(f"ref: refs/heads/{branch}\n", encoding="utf-8") - return demo + monkeypatch.setattr("aai_cli.agent_cascade.banner.version", lambda: _PINNED_VERSION) diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index e5cf5f9a..5611c0ad 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -137,6 +137,37 @@ def test_duplex_mic_ends_after_close(): assert list(d.mic) == [] # capture loop returns on the close sentinel +def test_toggle_listening_gates_capture_to_silence_without_dropping_the_stream(): + # Start/stop listening (the live TUI's Space) mutes the mic in place: muted frames become + # silence of the same length so STT keeps receiving audio (the socket stays alive) but hears + # nothing, and resuming is instant on the same stream — no reconnect. + cb = {} + + def factory(*, rate, blocksize, callback, device): + cb["fn"] = callback + return FakeStream() + + d = DuplexAudio(target_rate=16000, device_rate=16000, stream_factory=factory) # no resample + d.player.start() + loud = b"\x11\x11" * 8 # non-silent device input + mic = iter(d.mic) + + assert d.listening is True # mic open by default + cb["fn"](loud, bytearray(4), 8, None, None) + assert next(mic) == loud # listening: real audio passes through + + assert d.toggle_listening() is False # stop listening -> returns the new (muted) state + assert d.listening is False + cb["fn"](loud, bytearray(4), 8, None, None) + assert next(mic) == b"\x00" * len(loud) # muted: same length of frames, but silence + + assert d.toggle_listening() is True # resume on the same stream + assert d.listening is True + cb["fn"](loud, bytearray(4), 8, None, None) + assert next(mic) == loud # listening again: real audio + d.close() + + def test_duplex_start_is_idempotent(): calls = {"n": 0} diff --git a/tests/test_agent_cascade_brain.py b/tests/test_agent_cascade_brain.py index 108bd584..0bf507e4 100644 --- a/tests/test_agent_cascade_brain.py +++ b/tests/test_agent_cascade_brain.py @@ -1,6 +1,6 @@ """Tests for the deepagents reply brain behind `assembly live`. -The brain's only network seam is the compiled graph, so `build_completer` is driven +The brain's only network seam is the compiled graph, so `build_streamer` is driven against the *real* deepagents graph wired to a fake chat model (pytest-socket stays armed) — no sockets. `build_live_tools` and `build_model`'s new knobs are unit-tested directly. @@ -9,15 +9,16 @@ from __future__ import annotations import logging +from pathlib import Path import pytest from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage from langchain_core.outputs import ChatGeneration, ChatResult -from aai_cli.agent_cascade import brain +from aai_cli.agent_cascade import brain, datetime_tool, weather_tool, webpage_tool +from aai_cli.agent_cascade import model as model_mod from aai_cli.agent_cascade.config import CascadeConfig -from aai_cli.code_agent import model as model_mod from aai_cli.core.errors import CLIError @@ -42,78 +43,53 @@ def _generate(self, messages, stop=None, run_manager=None, **kwargs): return ChatResult(generations=[ChatGeneration(message=message)]) -def _graph(model: BaseChatModel): - from deepagents import create_deep_agent +# --- _graph_kwargs (real-cwd backend + write-gating when --files is on) ------- - return create_deep_agent(model=model, tools=[], system_prompt="be a friendly live agent") - -# --- build_system_prompt ----------------------------------------------------- - - -class _NamedTool: - """A stand-in tool exposing just the ``.name`` the prompt builder inspects.""" - - def __init__(self, name: str): - self.name = name - - -def test_system_prompt_advertises_web_search_when_present(): - prompt = brain.build_system_prompt( - "You are a pirate.", tools=[_NamedTool(brain.WEB_SEARCH_TOOL_NAME)] - ) - # The persona is preserved, and the guidance advertises the web-search capability the - # present tool backs (the plain cascade persona never mentions tools). - assert prompt.startswith("You are a pirate.") - assert "search the web" in prompt +def test_graph_kwargs_empty_when_files_off(): + # With files off the graph is built exactly as before: no backend swap, no gating. + assert brain._graph_kwargs(CascadeConfig(files=False)) == {} -def test_system_prompt_omits_web_search_when_search_tool_absent(): - # Without the Firecrawl search tool the guidance must NOT promise web search — announcing - # a missing tool makes the agent narrate "I'll search…" and then stall with no answer. A - # non-search tool name must not falsely trigger the web-search capability. - prompt = brain.build_system_prompt("persona", tools=[_NamedTool("some_other_tool")]) - assert "search the web for current or unfamiliar facts" not in prompt +def test_graph_kwargs_gates_writes_and_execute_and_sets_memory(monkeypatch, tmp_path): + from aai_cli.agent_cascade import sandbox + monkeypatch.chdir(tmp_path) + kwargs = brain._graph_kwargs(CascadeConfig(files=True)) -def test_system_prompt_tells_model_not_to_promise_tools_when_none(): - # No tools at all: the model must answer from its own knowledge and explicitly not - # promise to search or look anything up (the bug that left replies never coming back). - prompt = brain.build_system_prompt("persona", tools=[]) - assert "search the web for current or unfamiliar facts" not in prompt - assert "your own knowledge" in prompt - assert "Never say" in prompt + backend = kwargs["backend"] + assert isinstance(backend, sandbox.SandboxedShellBackend) + assert Path(backend.cwd) == tmp_path.resolve() + assert backend.virtual_mode is True + # execute now joins the write gate. + assert kwargs["interrupt_on"] == {"write_file": True, "edit_file": True, "execute": True} + assert kwargs["checkpointer"] is not None + # Durable per-project memory is turned on. + assert kwargs["memory"] == ["./.deepagents/AGENTS.md"] -def test_extra_capability_lists_sorted_tool_names(): - # MCP tools are advertised generically, by name, alphabetically. - phrase = brain._extra_capability([_NamedTool("zeta"), _NamedTool("alpha")]) - assert phrase == "use your connected tools (alpha, zeta)" +def test_sandboxed_backend_implements_sandbox_protocol(monkeypatch, tmp_path): + from deepagents.backends.protocol import SandboxBackendProtocol + monkeypatch.chdir(tmp_path) + backend = brain._build_fs_backend() + assert isinstance(backend, SandboxBackendProtocol) -def test_extra_capability_is_none_without_extra_tools(): - assert brain._extra_capability([]) is None +# --- build_system_prompt ----------------------------------------------------- -def test_system_prompt_advertises_mcp_extra_tools(): - # With MCP tools bound (but no built-in legs), the model must be told it HAS tools — - # not handed the "no external tools" guidance — and the tools are named. - prompt = brain.build_system_prompt("persona", tools=[], extra_tools=[_NamedTool("get_time")]) - assert "your own knowledge" not in prompt - assert "use your connected tools (get_time)" in prompt +class _NamedTool: + """A stand-in tool exposing just the ``.name`` the prompt builder inspects.""" -def test_join_clause_grammar(): - # One/two/three capability phrases each render with natural conjunctions. - assert brain._join_clause(["a"]) == "a" - assert brain._join_clause(["a", "b"]) == "a and b" - assert brain._join_clause(["a", "b", "c"]) == "a, b, and c" + def __init__(self, name: str): + self.name = name def test_web_search_tool_name_matches_built_tool(monkeypatch): # The prompt builder detects search by WEB_SEARCH_TOOL_NAME, so pin it against the real # Firecrawl tool's registered name — if it renames, detection would silently break. - from aai_cli.code_agent import firecrawl_search + from aai_cli.agent_cascade import firecrawl_search monkeypatch.setenv(firecrawl_search.FIRECRAWL_API_KEY_ENV, "fc-x") tool = firecrawl_search.build_web_search_tool() @@ -122,202 +98,27 @@ def test_web_search_tool_name_matches_built_tool(monkeypatch): def test_web_search_absent_without_firecrawl_key(monkeypatch): - from aai_cli.code_agent import firecrawl_search + from aai_cli.agent_cascade import firecrawl_search monkeypatch.delenv(firecrawl_search.FIRECRAWL_API_KEY_ENV, raising=False) assert firecrawl_search.build_web_search_tool() is None -# --- build_completer (driving the real graph with a fake model) -------------- - - -def test_completer_returns_final_spoken_text(): - graph = _graph(FakeChatModel(responses=[AIMessage(content="Hello there.")])) - completer = brain.build_completer("k", CascadeConfig(), graph=graph) - reply = completer([{"role": "system", "content": "x"}, {"role": "user", "content": "hi"}]) - assert reply == "Hello there." - - -def test_completer_strips_system_message_before_invoking(): - # The cascade prepends its own system message each turn, but the graph already owns - # the system prompt — so the completer must drop it before invoking, leaving only the - # conversation. We capture what the graph received to prove the system line is gone. - captured = {} - - class _CapturingGraph: - def invoke(self, value): - captured["messages"] = value["messages"] - return {"messages": [AIMessage(content="ok")]} - - completer = brain.build_completer("k", CascadeConfig(), graph=_CapturingGraph()) - completer([{"role": "system", "content": "persona"}, {"role": "user", "content": "hi"}]) - roles = [m["role"] for m in captured["messages"]] - assert roles == ["user"] - - -# --- _run_graph / _log_flow (verbose tool-call flow) ------------------------- - - -class _StreamingGraph: - """A graph that streams scripted state snapshots (the shape the real graph yields). - - Records the kwargs it was streamed with so a test can prove ``_run_graph`` asked for - incremental value snapshots, and exposes an ``invoke`` that must never run on the - verbose path.""" - - def __init__(self, snapshots): - self.snapshots = snapshots - self.stream_kwargs = None - self.invoked = False - - def stream(self, graph_input, config, *, stream_mode): - del graph_input, config - self.stream_kwargs = stream_mode - yield from self.snapshots - - def invoke(self, graph_input): - del graph_input - self.invoked = True - return {"messages": []} - - -def _search_call_message(): - return AIMessage( - content="Let me search.", - tool_calls=[{"name": "tavily_search", "args": {"query": "weather"}, "id": "c1"}], - ) - - -def test_run_graph_streams_and_logs_flow_when_verbose(monkeypatch, caplog, preserve_logging_state): - # Verbose mode streams the loop and logs each step — the assistant's interim line, the - # tool call (name + args), and the tool result — so a stalled spoken turn is debuggable. - monkeypatch.setattr(brain.debuglog, "active", lambda: True) - call = _search_call_message() - snapshots = [ - {"messages": [call]}, - { - "messages": [ - call, - ToolMessage(content="rainy, 52F", name="tavily_search", tool_call_id="c1"), - ] - }, - { - "messages": [ - call, - ToolMessage(content="rainy, 52F", name="tavily_search", tool_call_id="c1"), - AIMessage(content="It's rainy and 52 degrees in Portland."), - ] - }, - ] - graph = _StreamingGraph(snapshots) - completer = brain.build_completer("k", CascadeConfig(), graph=graph) - with caplog.at_level(logging.INFO, logger="aai_cli.agent_cascade.brain"): - reply = completer([{"role": "user", "content": "weather?"}]) - # The streamed final state still yields the spoken reply, and the graph was streamed - # for incremental value snapshots (not invoked). - assert reply == "It's rainy and 52 degrees in Portland." - assert graph.stream_kwargs == "values" - assert graph.invoked is False - # The flow log carries the tool call (with its args), the tool result, and the interim - # assistant line — each logged exactly once despite the growing snapshots. - messages = [record.getMessage() for record in caplog.records] - assert messages == [ - "llm: Let me search.", - "tool call tavily_search args={'query': 'weather'}", - "tool result tavily_search -> rainy, 52F", - "llm: It's rainy and 52 degrees in Portland.", - ] - - -def test_run_graph_invokes_when_not_verbose(): - # Default (non-verbose, no tool sink): invoked once, never streamed, nothing logged. - graph = _StreamingGraph([{"messages": [AIMessage(content="hi")]}]) - completer = brain.build_completer("k", CascadeConfig(), graph=graph) - assert completer([{"role": "user", "content": "hi"}]) == "" - assert graph.invoked is True - assert graph.stream_kwargs is None - - -def test_on_tool_sink_streams_and_reports_each_tool_call_by_label(): - # A wired tool sink (the live UI affordance) streams the graph — even without -v — and - # reports each tool call by its composed affordance (label · identifying arg), while still - # returning the final reply. - labels: list[str] = [] - call = AIMessage( - content="", - tool_calls=[ - {"name": brain.WEB_SEARCH_TOOL_NAME, "args": {"query": "today's news"}, "id": "c1"} - ], - ) - snapshots = [{"messages": [call]}, {"messages": [call, AIMessage(content="Here's the news.")]}] - graph = _StreamingGraph(snapshots) - completer = brain.build_completer("k", CascadeConfig(), graph=graph) - reply = completer([{"role": "user", "content": "news?"}], on_tool=labels.append) - assert reply == "Here's the news." - assert labels == ["Searching the web · today's news"] - assert graph.stream_kwargs == "values" and graph.invoked is False # streamed, not invoked - - def test_tool_label_maps_web_search_and_falls_back_for_others(): assert brain._tool_label(brain.WEB_SEARCH_TOOL_NAME) == "Searching the web" assert brain._tool_label("get_time") == "Using get_time" -def test_tool_affordance_appends_the_identifying_arg(): - # The web-search query and a generic tool's identifying arg are appended after a middle dot; - # an argument-less call degrades to the bare label (no trailing separator). - assert ( - brain._tool_affordance(brain.WEB_SEARCH_TOOL_NAME, {"query": "ai house Seattle"}) - == "Searching the web · ai house Seattle" - ) - assert brain._tool_affordance("read_file", {"path": "notes.md"}) == "Using read_file · notes.md" - assert brain._tool_affordance("get_time", {}) == "Using get_time" - - -def test_run_graph_invokes_when_graph_cannot_stream(monkeypatch): - # Verbose but the (test) graph only implements invoke: fall back to invoke rather than - # crashing on a missing .stream — the fakes and any non-streaming graph stay supported. - monkeypatch.setattr(brain.debuglog, "active", lambda: True) - - class _InvokeOnly: - def invoke(self, graph_input): - del graph_input - return {"messages": [AIMessage(content="from invoke")]} - - completer = brain.build_completer("k", CascadeConfig(), graph=_InvokeOnly()) - assert completer([{"role": "user", "content": "hi"}]) == "from invoke" - - -def test_run_graph_converts_graph_errors_to_cli_error(): - # A graph failure (gateway 4xx/5xx, a tool raising, a recursion limit) must become a - # CLIError so the cascade surfaces it instead of the reply worker dying silently. - class _Boom: - def invoke(self, graph_input): - del graph_input - raise ValueError("bedrock said no") - - completer = brain.build_completer("k", CascadeConfig(), graph=_Boom()) - with pytest.raises(CLIError) as excinfo: - completer([{"role": "user", "content": "hi"}]) - assert "couldn't complete the turn" in excinfo.value.message - assert "bedrock said no" in excinfo.value.message # the cause is preserved for diagnosis - - -def test_run_graph_passes_cli_error_through(): - # A CLIError from the graph is already user-facing -> propagate as-is, not re-wrapped. - class _CliBoom: - def invoke(self, graph_input): - del graph_input - raise CLIError("already clean", error_type="x") - - completer = brain.build_completer("k", CascadeConfig(), graph=_CliBoom()) - with pytest.raises(CLIError, match="already clean"): - completer([{"role": "user", "content": "hi"}]) +def test_tool_label_for_file_ops_is_speakable(): + # The file tools get speakable affordance labels so a write/search turn reads as progress. + assert brain._tool_label("write_file") == "Writing a file" + assert brain._tool_label("edit_file") == "Editing a file" + assert brain._tool_label("read_file") == "Reading a file" + assert brain._tool_label("grep") == "Searching files" -def test_log_flow_ignores_non_list_messages(): - # Defensive: a snapshot without a messages list logs nothing and reports no progress. - assert brain._log_flow({"messages": None}, 3) == 3 +def test_tool_label_execute_is_running_code(): + assert brain._tool_label("execute") == "Running code" def test_clip_passes_short_text_and_truncates_long_text(): @@ -343,38 +144,7 @@ def test_clip_flattens_whitespace_so_tool_output_cant_forge_log_lines(): assert "\r" not in brain._clip(forged) -# --- _reply_text / _content_text --------------------------------------------- - - -def test_reply_text_skips_empty_ai_messages_and_takes_last_text(): - # Scanning from the end, a trailing empty AIMessage (a tool-call request with no - # spoken text) is skipped so the reply falls back to the prior AIMessage's text, - # rather than coming back blank. - result = { - "messages": [ - AIMessage(content="The answer is 42."), - AIMessage(content=""), - ] - } - assert brain._reply_text(result) == "The answer is 42." - - -def test_reply_text_joins_list_content_blocks(): - result = {"messages": [AIMessage(content=[{"type": "text", "text": "Hello "}, "world"])]} - assert brain._reply_text(result) == "Hello world" - - -def test_reply_text_skips_non_assistant_messages(): - - # Scanning from the end, a trailing non-assistant message (e.g. a tool result) is - # skipped — the spoken reply is the AIMessage before it. - result = { - "messages": [ - AIMessage(content="hello there"), - ToolMessage(content="tool output", tool_call_id="c1"), - ] - } - assert brain._reply_text(result) == "hello there" +# --- _content_text ----------------------------------------------------------- def test_content_text_coerces_unexpected_content(): @@ -382,25 +152,46 @@ def test_content_text_coerces_unexpected_content(): assert brain._content_text(123) == "123" -def test_reply_text_is_empty_without_an_assistant_message(): - assert brain._reply_text({"messages": []}) == "" - assert brain._reply_text({}) == "" +def test_content_text_joins_list_content_blocks(): + assert brain._content_text([{"type": "text", "text": "Hello "}, "world"]) == "Hello world" # --- build_live_tools -------------------------------------------------------- -def test_build_live_tools_is_just_web_search_when_keyed(monkeypatch): - search = object() - monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: search) - # The live agent's sole built-in tool is Firecrawl web search — no URL fetch, no docs. - assert brain.build_live_tools() == [search] +def test_build_live_tools_has_weather_and_web_search_when_keyed(monkeypatch): + search = _NamedTool(brain.WEB_SEARCH_TOOL_NAME) + monkeypatch.setattr( + "aai_cli.agent_cascade.firecrawl_search.build_web_search_tool", lambda: search + ) + names = [tool.name for tool in brain.build_live_tools()] + # Web search is the optional keyed leg; the keyless weather, read-url, and datetime tools + # are always present. Exact set assertion kills duplicated/extra tools a loose `in` check would miss. + assert sorted(names) == sorted( + [ + brain.WEB_SEARCH_TOOL_NAME, + weather_tool.WEATHER_TOOL_NAME, + webpage_tool.READ_URL_TOOL_NAME, + datetime_tool.DATETIME_TOOL_NAME, + ] + ) + + +def test_build_live_tools_has_keyless_tools_without_firecrawl_key(monkeypatch): + monkeypatch.setattr( + "aai_cli.agent_cascade.firecrawl_search.build_web_search_tool", lambda: None + ) + # No FIRECRAWL_API_KEY -> no web search, but the keyless weather, read-url, and datetime tools load. + names = [tool.name for tool in brain.build_live_tools()] + assert names == [ + weather_tool.WEATHER_TOOL_NAME, + webpage_tool.READ_URL_TOOL_NAME, + datetime_tool.DATETIME_TOOL_NAME, + ] -def test_build_live_tools_is_empty_without_firecrawl_key(monkeypatch): - monkeypatch.setattr("aai_cli.code_agent.firecrawl_search.build_web_search_tool", lambda: None) - # No FIRECRAWL_API_KEY -> no tool at all; the agent then runs tool-free. - assert brain.build_live_tools() == [] +def test_tool_label_maps_read_url(): + assert brain._tool_label(webpage_tool.READ_URL_TOOL_NAME) == "Reading the page" # --- build_graph (model construction + compile, with the docs probe skipped) - @@ -421,8 +212,9 @@ def fake_build_model(api_key, *, model, max_tokens, extra): # The cascade's model + knobs are threaded into the gateway model build. assert captured == {"model": "claude-x", "max_tokens": 128, "extra": {"temperature": 0.2}} # The compiled graph is a real deepagents graph that answers offline via the fake model. - completer = brain.build_completer("k", cfg, graph=graph) - assert completer([{"role": "user", "content": "hi"}]) == "hi from the agent" + streamer = brain.build_streamer("k", cfg, graph=graph) + spoken = "".join(e.text for e in streamer([{"role": "user", "content": "hi"}])) + assert spoken == "hi from the agent" # --- build_graph MCP tool wiring --------------------------------------------- @@ -433,10 +225,11 @@ def test_build_graph_binds_builtin_plus_mcp_tools_and_advertises_both(monkeypatc captured = {} - def fake_create(*, model, tools, system_prompt): + def fake_create(*, model, tools, system_prompt, middleware): del model captured["tools"] = tools captured["system_prompt"] = system_prompt + captured["middleware"] = middleware return "graph" monkeypatch.setattr(deepagents, "create_deep_agent", fake_create) @@ -450,6 +243,10 @@ def fake_create(*, model, tools, system_prompt): # The prompt advertises the built-in web-search leg AND the MCP tool by name. assert "search the web" in captured["system_prompt"] assert "use your connected tools (get_time)" in captured["system_prompt"] + # The per-turn tool-call budget is wired into the deepagents middleware stack. + from langchain.agents.middleware import ToolCallLimitMiddleware + + assert any(isinstance(mw, ToolCallLimitMiddleware) for mw in captured["middleware"]) def test_build_graph_loads_mcp_tools_from_config_when_not_injected(monkeypatch): @@ -484,3 +281,165 @@ def test_build_model_defaults_have_no_extra(): model = model_mod.build_model("k", model="claude-x") assert model.max_tokens is None assert model.extra_body is None + + +def test_tool_label_maps_weather(): + assert brain._tool_label(weather_tool.WEATHER_TOOL_NAME) == "Checking the weather" + + +def test_tool_label_maps_datetime(): + assert brain._tool_label(datetime_tool.DATETIME_TOOL_NAME) == "Checking the time" + + +# --- build_streamer (token streaming -> SpeechDelta / ToolNotice) ------------ + + +class _MessageStreamGraph: + """A graph whose .stream yields (message_chunk, metadata) pairs — the shape + langgraph emits under stream_mode='messages'. Records the stream_mode it saw.""" + + def __init__(self, items): + self._items = items + self.stream_mode = None + + def stream(self, graph_input, config, *, stream_mode): + del graph_input, config + self.stream_mode = stream_mode + yield from self._items + + +def _collect(graph, messages): + streamer = brain.build_streamer("k", CascadeConfig(), graph=graph) + return list(streamer(messages)) + + +def test_streamer_yields_speech_deltas_for_assistant_tokens(): + graph = _MessageStreamGraph( + [ + (AIMessageChunk(content="Hello "), {}), + (AIMessageChunk(content="there."), {}), + ] + ) + events = _collect(graph, [{"role": "user", "content": "hi"}]) + assert [e.text for e in events if isinstance(e, brain.SpeechDelta)] == ["Hello ", "there."] + assert graph.stream_mode == "messages" + + +def test_streamer_strips_system_message_before_streaming(): + captured = {} + + class _Capture(_MessageStreamGraph): + def stream(self, graph_input, config, *, stream_mode): + captured["roles"] = [m["role"] for m in graph_input["messages"]] + return super().stream(graph_input, config, stream_mode=stream_mode) + + graph = _Capture([(AIMessageChunk(content="ok"), {})]) + _collect(graph, [{"role": "system", "content": "p"}, {"role": "user", "content": "hi"}]) + assert captured["roles"] == ["user"] + + +def test_build_middleware_caps_tool_calls_with_a_graceful_stop(): + # The brain wires a per-turn tool-call budget that forces a graceful answer instead of + # erroring: a ToolCallLimitMiddleware with the config's run_limit and exit_behavior="continue" + # (block further tool calls and let the model answer with what it has). The default is 10. + from langchain.agents.middleware import ToolCallLimitMiddleware + + (default_mw,) = brain._build_middleware(CascadeConfig()) + assert isinstance(default_mw, ToolCallLimitMiddleware) + assert default_mw.run_limit == 10 # DEFAULT_TOOL_CALL_LIMIT + assert default_mw.exit_behavior == "continue" # answer with what it has, never raise + + (custom_mw,) = brain._build_middleware(CascadeConfig(tool_call_limit=3)) + assert isinstance(custom_mw, ToolCallLimitMiddleware) + assert custom_mw.run_limit == 3 + + +def test_streamer_emits_a_tool_notice_when_a_tool_call_starts(): + call_chunk = AIMessageChunk( + content="", + tool_call_chunks=[{"name": brain.WEB_SEARCH_TOOL_NAME, "args": "", "id": "c1", "index": 0}], + ) + graph = _MessageStreamGraph([(call_chunk, {}), (AIMessageChunk(content="Here it is."), {})]) + events = _collect(graph, [{"role": "user", "content": "news?"}]) + notices = [e.label for e in events if isinstance(e, brain.ToolNotice)] + deltas = [e.text for e in events if isinstance(e, brain.SpeechDelta)] + assert notices == ["Searching the web"] + assert deltas == ["Here it is."] + + +def test_streamer_emits_one_notice_per_call_ignoring_arg_only_chunks(): + # The first tool-call chunk carries the name; later arg-only chunks (name=None) must NOT + # re-fire the affordance. + first = AIMessageChunk( + content="", tool_call_chunks=[{"name": "get_time", "args": "", "id": "c1", "index": 0}] + ) + rest = AIMessageChunk( + content="", tool_call_chunks=[{"name": None, "args": '{"tz":1}', "id": "c1", "index": 0}] + ) + graph = _MessageStreamGraph([(first, {}), (rest, {})]) + events = _collect(graph, [{"role": "user", "content": "time?"}]) + assert [e.label for e in events if isinstance(e, brain.ToolNotice)] == ["Using get_time"] + + +def test_streamer_wraps_graph_errors_in_cli_error(): + class _Boom: + def stream(self, graph_input, config, *, stream_mode): + del graph_input, config, stream_mode + raise ValueError("gateway said no") + + streamer = brain.build_streamer("k", CascadeConfig(), graph=_Boom()) + with pytest.raises(CLIError) as excinfo: + list(streamer([{"role": "user", "content": "hi"}])) + assert "couldn't complete the turn" in excinfo.value.message + assert "gateway said no" in excinfo.value.message + + +def test_streamer_passes_cli_error_through(): + class _CliBoom: + def stream(self, graph_input, config, *, stream_mode): + del graph_input, config, stream_mode + raise CLIError("already clean", error_type="x") + + streamer = brain.build_streamer("k", CascadeConfig(), graph=_CliBoom()) + with pytest.raises(CLIError, match="already clean"): + list(streamer([{"role": "user", "content": "hi"}])) + + +def test_streamer_errors_when_graph_cannot_stream(): + # A graph that only implements invoke (no .stream) can't be streamed — the streamer + # must surface a clean CLIError rather than AttributeError-ing mid-turn. + class _InvokeOnly: + def invoke(self, graph_input): + del graph_input + return {"messages": []} + + streamer = brain.build_streamer("k", CascadeConfig(), graph=_InvokeOnly()) + with pytest.raises(CLIError) as excinfo: + list(streamer([{"role": "user", "content": "hi"}])) + assert "cannot stream" in excinfo.value.message + + +def test_streamer_logs_flow_when_verbose(monkeypatch, caplog, preserve_logging_state): + monkeypatch.setattr(brain.debuglog, "active", lambda: True) + call_chunk = AIMessageChunk( + content="", tool_call_chunks=[{"name": "tavily_search", "args": "", "id": "c1", "index": 0}] + ) + items = [ + (AIMessageChunk(content="Let me "), {}), + (AIMessageChunk(content="search."), {}), + (call_chunk, {}), + (ToolMessage(content="rainy, 52F", name="tavily_search", tool_call_id="c1"), {}), + (AIMessageChunk(content="It's rainy."), {}), + ] + graph = _MessageStreamGraph(items) + with caplog.at_level(logging.INFO, logger="aai_cli.agent_cascade.brain"): + _collect(graph, [{"role": "user", "content": "weather?"}]) + messages = [r.getMessage() for r in caplog.records] + # Accumulated assistant text is logged as one line per assistant turn, around the + # tool call and its result. + assert messages == [ + "llm: Let me search.", + "tool call tavily_search", + "tool result tavily_search -> rainy, 52F", + "llm: It's rainy.", + ] diff --git a/tests/test_agent_cascade_brain_approval.py b/tests/test_agent_cascade_brain_approval.py new file mode 100644 index 00000000..0e0cfe63 --- /dev/null +++ b/tests/test_agent_cascade_brain_approval.py @@ -0,0 +1,109 @@ +"""Write-approval tests for the deepagents reply brain behind `assembly live`. + +Split out of `test_agent_cascade_brain.py` to keep each file under the 500-line gate. +These drive `build_streamer`'s `--files` write-gating against a *real* deepagents graph +wired to a fake chat model (pytest-socket stays armed) — no sockets. +""" + +from __future__ import annotations + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +from aai_cli.agent_cascade import brain +from aai_cli.agent_cascade.config import CascadeConfig + + +class FakeChatModel(BaseChatModel): + """A chat model that replays a scripted list of AIMessages (mirrors the code agent's).""" + + responses: list[AIMessage] + index: int = 0 + + @property + def _llm_type(self) -> str: + return "fake-live-model" + + def bind_tools(self, tools, **kwargs): + del tools, kwargs + return self + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + del messages, stop, run_manager, kwargs + message = self.responses[self.index] + self.index += 1 + return ChatResult(generations=[ChatGeneration(message=message)]) + + +def _gated_graph(model: BaseChatModel, root: str): + """A real deepagents graph that gates write_file/edit_file, rooted at ``root``.""" + from deepagents import create_deep_agent + from deepagents.backends import FilesystemBackend + from langgraph.checkpoint.memory import InMemorySaver + + return create_deep_agent( + model=model, + backend=FilesystemBackend(root_dir=root, virtual_mode=True), + interrupt_on={"write_file": True, "edit_file": True}, + checkpointer=InMemorySaver(), + system_prompt="be a friendly live agent", + ) + + +def _write_then(reply: str) -> FakeChatModel: + """A model that calls write_file once, then (after resume) answers with ``reply``.""" + call = AIMessage( + content="", + tool_calls=[ + {"name": "write_file", "args": {"file_path": "/n.txt", "content": "hi"}, "id": "w1"} + ], + ) + return FakeChatModel(responses=[call, AIMessage(content=reply)]) + + +def test_streamer_approves_write_then_resumes(tmp_path): + asked: list[tuple[str, dict]] = [] + + def approve(name, args): + asked.append((name, args)) + return True + + graph = _gated_graph(_write_then("Saved your note."), str(tmp_path)) + streamer = brain.build_streamer("k", CascadeConfig(files=True), graph=graph, approver=approve) + events = list(streamer([{"role": "user", "content": "save a note"}])) + spoken = "".join(e.text for e in events if isinstance(e, brain.SpeechDelta)) + assert spoken == "Saved your note." + # The approver was consulted for the write, and the approved write hit the rooted dir. + assert asked and asked[0][0] == "write_file" + assert (tmp_path / "n.txt").read_text() == "hi" + + +def test_streamer_rejects_write_without_approval(tmp_path): + graph = _gated_graph(_write_then("Okay, I won't save it."), str(tmp_path)) + streamer = brain.build_streamer( + "k", CascadeConfig(files=True), graph=graph, approver=lambda name, args: False + ) + events = list(streamer([{"role": "user", "content": "save a note"}])) + spoken = "".join(e.text for e in events if isinstance(e, brain.SpeechDelta)) + assert spoken == "Okay, I won't save it." + # Declined: nothing was written to the rooted directory. + assert not (tmp_path / "n.txt").exists() + + +def test_streamer_brackets_write_approval_with_pause_events(tmp_path): + # The human-think wait is bracketed by ApprovalPause(active=True/False) so the engine can + # suspend its reply-timeout deadline for exactly that interval. The approver runs between + # the two markers by construction (the streamer yields True, asks, then yields False). + asked: list[str] = [] + graph = _gated_graph(_write_then("Done."), str(tmp_path)) + streamer = brain.build_streamer( + "k", + CascadeConfig(files=True), + graph=graph, + approver=lambda name, args: asked.append(name) or True, + ) + events = list(streamer([{"role": "user", "content": "save"}])) + pauses = [event.active for event in events if isinstance(event, brain.ApprovalPause)] + assert pauses == [True, False] # the write was bracketed: pause on, then resume + assert asked == ["write_file"] # the approver was consulted exactly once, for the write diff --git a/tests/test_agent_cascade_command.py b/tests/test_agent_cascade_command.py index 405a508e..84c6eaee 100644 --- a/tests/test_agent_cascade_command.py +++ b/tests/test_agent_cascade_command.py @@ -15,7 +15,7 @@ from typer.testing import CliRunner from aai_cli.agent.render import AgentRenderer -from aai_cli.agent_cascade import engine +from aai_cli.agent_cascade import _io, engine from aai_cli.agent_cascade.config import CascadeConfig from aai_cli.agent_cascade.engine import CascadeDeps from aai_cli.app.context import AppState @@ -33,7 +33,7 @@ source=None, sample=False, voice="jane", - model="claude-haiku-4-5-20251001", + model="kimi-k2.5", system_prompt="be nice", system_prompt_file=None, greeting="hello", @@ -49,6 +49,7 @@ language=None, tts_config=(), mcp_config=(), + files=False, show_code=False, ) @@ -223,7 +224,7 @@ def test_no_mcp_servers_load_by_default(monkeypatch): captured = {} # Capture config at the deps seam so the graph never builds. - def fake_real(api_key, config, *, audio, stt_params): + def fake_real(api_key, config, *, audio, stt_params, approver=None): captured["config"] = config return "deps" @@ -244,8 +245,10 @@ def test_run_wires_deps_and_invokes_cascade(monkeypatch): monkeypatch.setattr(_exec, "FileSource", lambda src: fake_source) monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") # CascadeDeps.real builds the brain graph (which would launch the default MCP servers); - # stub the completer so deps still wire up without spawning any npx/uvx subprocess. - monkeypatch.setattr(_exec.engine.brain, "build_completer", lambda api_key, config: lambda m: "") + # stub the streamer so deps still wire up without spawning any npx/uvx subprocess. + monkeypatch.setattr( + _exec.engine.brain, "build_streamer", lambda api_key, config, *, approver=None: lambda m: [] + ) captured = {} def fake_run_cascade(*, renderer, player, config, deps): @@ -284,8 +287,10 @@ def _wire_run(monkeypatch, run_cascade): monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k") monkeypatch.setattr(_exec, "FileSource", lambda src: types.SimpleNamespace(sample_rate=16000)) monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") - # Stub the brain completer so CascadeDeps.real never launches the default MCP servers. - monkeypatch.setattr(_exec.engine.brain, "build_completer", lambda api_key, config: lambda m: "") + # Stub the brain streamer so CascadeDeps.real never launches the default MCP servers. + monkeypatch.setattr( + _exec.engine.brain, "build_streamer", lambda api_key, config, *, approver=None: lambda m: [] + ) monkeypatch.setattr(_exec.engine, "run_cascade", run_cascade) rendered = {} monkeypatch.setattr( @@ -342,6 +347,7 @@ def test_build_stt_params_threads_named_flags(): params = _exec._build_stt_params(_opts(speech_model="u3-rt-pro", format_turns=False), 8000) assert params.sample_rate == 8000 # fixed by the audio source, not a flag assert params.format_turns is False + assert params.speech_model is not None assert params.speech_model.value == "u3-rt-pro" @@ -396,11 +402,14 @@ def test_run_threads_all_leg_options_into_config_and_params(monkeypatch): monkeypatch.setattr(_exec.engine, "run_cascade", lambda **kw: None) captured = {} - def fake_real(api_key, config, *, audio, stt_params): + def fake_real(api_key, config, *, audio, stt_params, approver=None): captured["config"] = config captured["stt_params"] = stt_params + captured["approver"] = approver return CascadeDeps( - run_stt=lambda _o: None, complete_reply=lambda _m: "", synthesize=lambda _t: b"" + run_stt=lambda _o: None, + stream_reply=lambda _m: [], + synthesize=lambda _t, _sink: None, ) monkeypatch.setattr(_exec.engine.CascadeDeps, "real", fake_real) @@ -446,7 +455,7 @@ def fake_stream_audio(api_key, source, *, params, on_turn): captured["source"] = source captured["params"] = params - monkeypatch.setattr(engine.client, "stream_audio", fake_stream_audio) + monkeypatch.setattr(_io.client, "stream_audio", fake_stream_audio) audio: list[bytes] = [] params = _stt_params() deps = CascadeDeps.real("k", CascadeConfig(), audio=audio, stt_params=params) @@ -457,44 +466,34 @@ def fake_stream_audio(api_key, source, *, params, on_turn): assert captured["params"] is params -def test_deps_real_complete_reply_is_built_by_the_deepagents_brain(monkeypatch): - # The LLM leg is now a deepagents graph: .real delegates to brain.build_completer, - # passing the api key + config, and uses whatever completer it returns. We assert the - # exact wiring so the brain swap (not a plain llm.complete) can't silently regress. - captured = {} +def test_deps_real_stream_reply_is_built_by_the_deepagents_brain(monkeypatch): + from aai_cli.agent_cascade.brain import SpeechDelta - def fake_build_completer(api_key, config): - captured["api_key"] = api_key - captured["config"] = config - return lambda messages: f"reply to {messages[-1]['content']}" + def fake_build_streamer(api_key, config, *, approver=None): + del api_key, config + return lambda messages: [SpeechDelta("reply to " + messages[-1]["content"])] - monkeypatch.setattr(engine.brain, "build_completer", fake_build_completer) - cfg = CascadeConfig(model="m", max_tokens=222, llm_extra={"temperature": 0.5}) + monkeypatch.setattr(engine.brain, "build_streamer", fake_build_streamer) + cfg = CascadeConfig() deps = CascadeDeps.real("k", cfg, audio=[], stt_params=_stt_params()) - assert deps.complete_reply([{"role": "user", "content": "hi"}]) == "reply to hi" - assert captured["api_key"] == "k" - assert captured["config"] is cfg + events = list(deps.stream_reply([{"role": "user", "content": "hi"}])) + assert [e.text for e in events if isinstance(e, SpeechDelta)] == ["reply to hi"] -def test_deps_real_synthesize_threads_voice_language_and_extra(monkeypatch): +def test_deps_real_synthesize_streams_frames_and_threads_voice(monkeypatch): captured = {} - def fake_synth(api_key, spec): + def fake_synth(api_key, spec, *, on_audio): captured["voice"] = spec.voice - captured["language"] = spec.language - captured["text"] = spec.text captured["sample_rate"] = spec.sample_rate - captured["params"] = spec.query_params() - return types.SimpleNamespace(pcm=b"AUDIO") + on_audio(b"AUDIO", spec.sample_rate or 0) + return _io.tts_session.SpeakResult(b"AUDIO", spec.sample_rate or 0, 0.0) - monkeypatch.setattr(engine.tts_session, "synthesize", fake_synth) - cfg = CascadeConfig(voice="vera", language="en", tts_extra={"chunk_size_ms": "100"}) + monkeypatch.setattr(_io.tts_session, "synthesize", fake_synth) + cfg = CascadeConfig(voice="luna") deps = CascadeDeps.real("k", cfg, audio=[], stt_params=_stt_params()) - assert deps.synthesize("say this") == b"AUDIO" - assert captured["voice"] == "vera" - assert captured["language"] == "en" - assert captured["text"] == "say this" - # TTS always synthesizes at the 24 kHz the live player is opened at. - assert captured["sample_rate"] == engine.TTS_SAMPLE_RATE == 24000 - # The --tts-config escape hatch rides along as an extra query param. - assert captured["params"]["chunk_size_ms"] == "100" + frames = [] + deps.synthesize("say this", frames.append) + assert frames == [b"AUDIO"] + assert captured["voice"] == "luna" + assert captured["sample_rate"] == 24000 # TTS always synthesizes at the live player's rate diff --git a/tests/test_agent_cascade_config.py b/tests/test_agent_cascade_config.py index e722fcac..90a4fee9 100644 --- a/tests/test_agent_cascade_config.py +++ b/tests/test_agent_cascade_config.py @@ -10,6 +10,7 @@ DEFAULT_GREETING, DEFAULT_MAX_HISTORY, DEFAULT_MODEL, + DEFAULT_SYSTEM_PROMPT, CascadeConfig, ) from aai_cli.agent_cascade.voices import DEFAULT_VOICE @@ -19,8 +20,18 @@ def test_default_config_values(): config = CascadeConfig() assert config.voice == DEFAULT_VOICE - assert config.model == DEFAULT_MODEL == "claude-haiku-4-5-20251001" # `assembly live` default + assert config.model == DEFAULT_MODEL == "kimi-k2.5" # `assembly live` default assert config.greeting == DEFAULT_GREETING + # The default prompt drives brevity (a sentence or two) and bans markup, since the + # reply is spoken. Pin each clause against an independent literal so a mutated segment + # of DEFAULT_SYSTEM_PROMPT can't pass by moving config.system_prompt with it. + assert config.system_prompt == DEFAULT_SYSTEM_PROMPT + assert config.system_prompt == ( + "You are a friendly, concise voice assistant. Keep replies as short as " + "possible — usually a single sentence, never more than two. Answer directly " + "without preamble or filler. Your reply is read aloud by a text-to-speech " + "engine, so write plain spoken prose — no markdown, emoji, bullet lists, or code." + ) # The sliding-window default keeps the last 40 messages of context. assert config.max_history == 40 assert DEFAULT_MAX_HISTORY == 40 @@ -33,6 +44,11 @@ def test_default_config_values(): assert dict(config.tts_extra) == {} +def test_files_defaults_off(): + # File read/write is opt-in (--files); default behavior is unchanged and disk-free. + assert CascadeConfig().files is False + + def test_config_is_frozen(): # Frozen so a parsed run config can't be mutated mid-conversation. config = CascadeConfig() diff --git a/tests/test_agent_cascade_datetime.py b/tests/test_agent_cascade_datetime.py new file mode 100644 index 00000000..945310a0 --- /dev/null +++ b/tests/test_agent_cascade_datetime.py @@ -0,0 +1,46 @@ +"""Tests for the keyless local date/time tool behind `assembly live`. + +The tool's only non-determinism is the injected ``Clock`` callable, so the whole +flow is deterministic with no real clock (and pytest-socket stays armed — no I/O). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from aai_cli.agent_cascade import datetime_tool + +# A fixed, timezone-aware instant: Monday, 2026-06-22 14:30 at a fixed -07:00 offset. +# A fixed offset (not a named zone) keeps %Z deterministic cross-platform without tzdata. +_FIXED = datetime(2026, 6, 22, 14, 30, tzinfo=timezone(timedelta(hours=-7))) +_EXPECTED = "It's Monday, June 22, 2026 at 02:30 PM UTC-07:00." + + +# --- _format ----------------------------------------------------------------- + + +def test_format_renders_exact_speakable_string(): + assert datetime_tool._format(_FIXED) == _EXPECTED + + +# --- _now (default seam) ----------------------------------------------------- + + +def test_now_returns_timezone_aware_datetime(): + n = datetime_tool._now() + assert isinstance(n, datetime) + # astimezone() makes it aware; a naive datetime (mutation dropping it) fails here. + assert n.tzinfo is not None + + +# --- build_datetime_tool ----------------------------------------------------- + + +def test_tool_is_named_get_current_datetime(): + tool = datetime_tool.build_datetime_tool(now=lambda: _FIXED) + assert tool.name == datetime_tool.DATETIME_TOOL_NAME + + +def test_tool_returns_formatted_current_datetime(): + tool = datetime_tool.build_datetime_tool(now=lambda: _FIXED) + assert tool.invoke({}) == _EXPECTED diff --git a/tests/test_agent_cascade_engine.py b/tests/test_agent_cascade_engine.py index 091c64d0..2ae6d01a 100644 --- a/tests/test_agent_cascade_engine.py +++ b/tests/test_agent_cascade_engine.py @@ -11,11 +11,13 @@ import pytest -from aai_cli.agent_cascade import engine +from aai_cli.agent_cascade import _runtime, engine +from aai_cli.agent_cascade.brain import SpeechDelta, ToolNotice from aai_cli.agent_cascade.config import CascadeConfig from aai_cli.agent_cascade.engine import CascadeDeps, CascadeSession, run_cascade from aai_cli.core.errors import APIError from tests._cascade_fakes import FakePlayer, FakeRenderer, FakeWorker, make_session +from tests._cascade_fakes import deltas as _deltas from tests._cascade_fakes import sync_spawn as _sync_spawn from tests._cascade_fakes import turn as _turn @@ -39,7 +41,7 @@ def test_greet_empty_greeting_is_silent(): def test_greet_records_tts_failure(): - def boom(text): + def boom(text, sink): raise APIError("tts down") session, _renderer, player = make_session(synthesize=boom) @@ -60,7 +62,7 @@ def test_on_turn_blank_transcript_ignored(): def test_on_turn_final_renders_and_replies(): - session, renderer, player = make_session(complete_reply=lambda m, on_tool=None: "Sure thing.") + session, renderer, player = make_session(stream_reply=_deltas("Sure thing.")) session.on_turn(_turn("what time is it")) assert ("user_final", "what time is it") in renderer.calls assert {"role": "user", "content": "what time is it"} in session.history @@ -70,25 +72,23 @@ def test_on_turn_final_renders_and_replies(): def test_reply_forwards_tool_calls_to_the_renderer(): - # The reply worker hands complete_reply an on_tool sink; a tool call it makes surfaces on - # the renderer, so the live UI can show a "Searching the web…" affordance mid-turn. - def reply(messages, on_tool): - on_tool("Searching the web") - return "Found it." + def stream(messages): + yield ToolNotice("Searching the web", ("Searching now.",)) + yield SpeechDelta("Found it.") - session, renderer, _player = make_session(complete_reply=reply) + session, renderer, _player = make_session(stream_reply=stream) session.on_turn(_turn("what's the news")) assert ("tool_call", "Searching the web") in renderer.calls def test_on_turn_interim_shows_partial_and_does_not_reply(): - replies = [] + streamed = [] session, renderer, _player = make_session( - complete_reply=lambda m, on_tool=None: replies.append(m) or "x" + stream_reply=lambda m: streamed.append(m) or [SpeechDelta("x")] ) session.on_turn(_turn("partial words", end_of_turn=False)) assert ("user_partial", "partial words") in renderer.calls - assert replies == [] # no reply generated for an interim turn + assert streamed == [] # no reply generated for an interim turn assert session.history == [] @@ -100,209 +100,61 @@ def test_on_turn_interim_barges_in_on_live_reply(): assert session._reply is None -# --- reply generation -------------------------------------------------------- +# --- spoken approval (--files): route the next final transcript to the open gate ------------- -def test_generate_reply_speaks_each_sentence(): - spoken = [] - session, renderer, player = make_session( - complete_reply=lambda m, on_tool=None: "One. Two! Three?", - synthesize=lambda text: spoken.append(text) or text.encode(), - ) - session._generate_reply() - assert spoken == ["One.", "Two!", "Three?"] - assert player.enqueued == [b"One.", b"Two!", b"Three?"] - assert ("reply_started",) in renderer.calls - assert ("agent_transcript", "One.", False) in renderer.calls - assert session.history[-1] == {"role": "assistant", "content": "One. Two! Three?"} - assert ("reply_done", False) in renderer.calls - - -def test_generate_reply_threads_system_prompt_and_history(): - captured = {} - - def capture(messages, on_tool=None): - captured["messages"] = messages - return "Ok." - - session, _renderer, _player = make_session( - complete_reply=capture, config=CascadeConfig(system_prompt="be terse") - ) - session.history.append({"role": "user", "content": "prior"}) - session._generate_reply() - assert captured["messages"][0] == {"role": "system", "content": "be terse"} - assert {"role": "user", "content": "prior"} in captured["messages"] - - -def test_generate_reply_trims_history_window(): - session, _renderer, _player = make_session( - complete_reply=lambda m, on_tool=None: "a. b.", config=CascadeConfig(max_history=1) - ) - session.history.append({"role": "user", "content": "hi"}) - session._generate_reply() - # user + assistant would be 2; the window caps it to the most recent 1. - assert session.history == [{"role": "assistant", "content": "a. b."}] - - -def test_on_turn_trims_history_window(): - # An empty reply adds no assistant turn, so only on_turn's own trim caps the list. - session, _renderer, _player = make_session( - complete_reply=lambda m, on_tool=None: "", config=CascadeConfig(max_history=1) - ) - session.history.append({"role": "assistant", "content": "old"}) - session.on_turn(_turn("newest")) - assert session.history == [{"role": "user", "content": "newest"}] - - -def test_generate_reply_stop_after_first_sentence_records_partial(): - def synth(text): - if text == "Two.": - session._stop.set() - return text.encode() - - session, renderer, player = make_session( - complete_reply=lambda m, on_tool=None: "One. Two. Three." - ) - session.deps.synthesize = synth - session._generate_reply() - # Only the first sentence finished enqueuing before the barge-in stop landed. - assert player.enqueued == [b"One."] - assert session.history[-1] == {"role": "assistant", "content": "One."} - assert ("reply_done", True) in renderer.calls - - -def test_generate_reply_stop_before_first_sentence_speaks_nothing(): - session, renderer, player = make_session(complete_reply=lambda m, on_tool=None: "One. Two.") - session._stop.set() - session._generate_reply() - assert player.enqueued == [] - # nothing spoken -> no assistant turn recorded - assert all(item.get("role") != "assistant" for item in session.history) - assert ("reply_done", True) in renderer.calls - - -def test_generate_reply_llm_failure_is_recorded_and_surfaced(): - def boom(messages, on_tool=None): - del messages - raise APIError("gateway down") - - session, renderer, player = make_session(complete_reply=boom) - session._generate_reply() - assert isinstance(session.error, APIError) # recorded for the exit path - # Surfaced in the transcript (not swallowed) but nothing is spoken — the turn aborts. - assert ("agent_transcript", "(error: gateway down)", False) in renderer.calls - assert ("reply_done", False) in renderer.calls # the error line is closed off cleanly - assert player.enqueued == [] - - -def test_generate_reply_tts_failure_midway_is_recorded(): - def boom(text): - raise APIError("tts down") - - session, renderer, player = make_session( - complete_reply=lambda m, on_tool=None: "Hi.", synthesize=boom - ) - session._generate_reply() - assert isinstance(session.error, APIError) - assert player.enqueued == [] - assert ("reply_started",) in renderer.calls - assert ("reply_done", False) in renderer.calls - - -def test_record_error_keeps_first_and_warns(monkeypatch): - printed = [] - monkeypatch.setattr(engine.output.error_console, "print", lambda msg: printed.append(msg)) - session, _renderer, _player = make_session() - session._record_error(APIError("first")) - session._record_error(APIError("second")) - assert isinstance(session.error, APIError) - assert session.error.message == "first" - assert any("first" in str(msg) for msg in printed) - - -# --- barge-in / shutdown ----------------------------------------------------- - - -def test_barge_in_cancels_and_flushes_live_worker(): - session, _renderer, player = make_session() - worker = FakeWorker(alive=True) - session._reply = worker - session._barge_in() - assert session._stop.is_set() - assert player.flushed == 1 - assert worker.joined == 1 - assert session._reply is None +def test_on_turn_routes_final_to_voice_during_approval_pause(): + # While a --files write/run awaits approval, the next FINAL transcript answers the gate by + # voice — it does NOT render a user turn or start a new reply. + session, renderer, _player = make_session() + voiced: list[str] = [] + session.on_approval_voice = voiced.append + session._set_awaiting_approval(active=True) + session.on_turn(_turn("yes, run it")) -def test_barge_in_without_a_live_worker_does_not_flush(): - # No worker, or one that already finished: nothing to cancel, so no flush. - session, _renderer, player = make_session() - session._barge_in() # no worker - session._reply = FakeWorker(alive=False) - session._barge_in() # finished worker - assert player.flushed == 0 - assert session._reply is None + assert voiced == ["yes, run it"] + assert session.history == [] # no new turn started + assert ("user_final", "yes, run it") not in renderer.calls -def test_interrupt_reply_signals_stop_and_flushes_without_joining(): - # Live TUI Escape/Ctrl-C silences a playing reply: stop flag + flush, but NO join. - session, _renderer, player = make_session() - worker = FakeWorker(alive=True) - session._reply = worker - assert session.interrupt_reply() is True - assert session._stop.is_set() - assert player.flushed == 1 - assert worker.joined == 0 # not joined — the worker unwinds on its own - assert session._reply is worker # still tracked; the next turn's barge-in joins it +def test_on_turn_ignores_interim_during_approval_pause(): + # Interim partials during the pause are dropped (only a final transcript answers the gate). + session, renderer, _player = make_session() + voiced: list[str] = [] + session.on_approval_voice = voiced.append + session._set_awaiting_approval(active=True) + session.on_turn(_turn("yes", end_of_turn=False)) -def test_interrupt_reply_is_a_noop_when_nothing_is_playing(): - # No worker, or one that already finished: nothing to stop, so no flush and no stop flag. - session, _renderer, player = make_session() - assert session.interrupt_reply() is False # no worker - session._reply = FakeWorker(alive=False) - assert session.interrupt_reply() is False # finished worker - assert player.flushed == 0 - assert not session._stop.is_set() + assert voiced == [] + assert renderer.calls == [] -def test_interrupt_reply_silences_the_greeting_with_no_worker(): - # The greeting is enqueued with no reply worker; Escape/Ctrl-C must still cut it. With audio - # queued (pending>0) the interrupt flushes the player and reports that it silenced something, - # so the live TUI interrupts the greeting instead of (for Ctrl-C) quitting the session. - session, _renderer, player = make_session() - player.pending_samples = 1 # even a single queued sample (>0) means sound is still playing - assert session.interrupt_reply() is True - assert player.flushed == 1 - assert player.pending() == 0 # the queued greeting was dropped +def test_on_turn_final_during_pause_without_voice_sink_is_dropped(): + # Keyboard-only path (no voice sink): a final transcript during the pause is simply dropped, + # not started as a turn — pins the `on_approval_voice is not None` guard. + session, renderer, _player = make_session() # on_approval_voice defaults to None + session._set_awaiting_approval(active=True) + session.on_turn(_turn("anything")) -def test_barge_in_silences_a_draining_reply_tail_after_the_worker_exits(): - # The reply worker enqueues every sentence then exits, but the audio keeps draining. A new - # spoken turn in that window must still cut the tail — a bare is_alive() check would miss it. - session, _renderer, player = make_session() - session._reply = FakeWorker(alive=False) # worker finished enqueuing - player.pending_samples = 9600 # ...but its audio is still playing - session._barge_in() - assert session._stop.is_set() - assert player.flushed == 1 - assert session._reply is None + assert session.history == [] + assert renderer.calls == [] -def test_shutdown_joins_live_worker(): - session, _renderer, _player = make_session() - worker = FakeWorker(alive=True) - session._reply = worker - session.shutdown() - assert session._stop.is_set() - assert worker.joined == 1 - assert session._reply is None +def test_on_turn_resumes_normal_turns_once_approval_clears(): + # After the pause clears (active=False), a final transcript starts a reply again, NOT voice. + session, renderer, _player = make_session(stream_reply=_deltas("Done.")) + voiced: list[str] = [] + session.on_approval_voice = voiced.append + session._set_awaiting_approval(active=True) + session._set_awaiting_approval(active=False) + session.on_turn(_turn("what time is it")) -def test_shutdown_without_worker_is_safe(): - session, _renderer, _player = make_session() - session.shutdown() # no worker spawned - assert session._reply is None + assert voiced == [] + assert ("user_final", "what time is it") in renderer.calls # --- helpers ----------------------------------------------------------------- @@ -343,7 +195,7 @@ def test_is_final_turn_defaults_missing_attrs_to_not_final(): def test_spawn_thread_runs_target(): ran = threading.Event() - worker = engine._spawn_thread(ran.set) + worker = _runtime.spawn_thread(ran.set) worker.join() assert ran.is_set() assert worker.is_alive() is False @@ -358,17 +210,17 @@ def run_stt(on_turn): session_box = {} - def complete_reply(messages, on_tool=None): + def stream_reply(messages): session_box["messages"] = messages - return "Hi back." + return [SpeechDelta("Hi back.")] renderer = FakeRenderer() player = FakePlayer() config = CascadeConfig(greeting="Welcome.") deps = CascadeDeps( run_stt=run_stt, - complete_reply=complete_reply, - synthesize=lambda text: text.encode(), + stream_reply=stream_reply, + synthesize=lambda text, sink: sink(text.encode()), spawn=_sync_spawn, ) run_cascade(renderer=renderer, player=player, config=config, deps=deps) @@ -387,8 +239,8 @@ def test_run_cascade_hands_the_session_to_on_session_before_greeting(): player = FakePlayer() deps = CascadeDeps( run_stt=lambda on_turn: None, - complete_reply=lambda m, on_tool=None: "hi", - synthesize=lambda text: b"", + stream_reply=_deltas("hi"), + synthesize=lambda text, sink: sink(b""), spawn=_sync_spawn, ) run_cascade( @@ -414,8 +266,8 @@ def run_stt(on_turn): deps = CascadeDeps( run_stt=run_stt, - complete_reply=lambda m, on_tool=None: "hi", - synthesize=lambda t: b"", + stream_reply=_deltas("hi"), + synthesize=lambda text, sink: sink(b""), spawn=lazy_spawn, ) run_cascade( @@ -428,11 +280,14 @@ def test_run_cascade_reraises_recorded_leg_error(): def run_stt(on_turn): on_turn(_turn("hi")) - def boom(messages, on_tool=None): + def boom(messages): raise APIError("gateway down") deps = CascadeDeps( - run_stt=run_stt, complete_reply=boom, synthesize=lambda t: b"", spawn=_sync_spawn + run_stt=run_stt, + stream_reply=boom, + synthesize=lambda text, sink: sink(b""), + spawn=_sync_spawn, ) with pytest.raises(APIError, match="gateway down"): run_cascade( @@ -450,8 +305,8 @@ def run_stt(on_turn): player = FakePlayer() deps = CascadeDeps( run_stt=run_stt, - complete_reply=lambda m, on_tool=None: "", - synthesize=lambda t: b"", + stream_reply=_deltas(""), + synthesize=lambda text, sink: sink(b""), spawn=_sync_spawn, ) with pytest.raises(APIError, match="stt failed"): @@ -459,3 +314,34 @@ def run_stt(on_turn): renderer=FakeRenderer(), player=player, config=CascadeConfig(greeting=""), deps=deps ) assert player.closed is True + + +def test_runtime_reply_sentinels_are_frozen(): + # Done/Failure/Timeout are frozen dataclasses; the frozen=True->False mutant is killed by + # asserting a write raises. A variable attr name dodges ruff B010 and pyright's frozen check. + import dataclasses + + probe = "injected_probe" + for instance in (_runtime.Done(), _runtime.Failure(error=APIError("x")), _runtime.Timeout()): + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(instance, probe, 1) + + +def test_cascade_session_speaking_event_is_not_an_init_field(): + # _speaking is internal state, never a constructor argument; the init=False->True mutant is + # killed by asserting the field stays init=False. + import dataclasses + + fields = {f.name: f for f in dataclasses.fields(engine.CascadeSession)} + assert fields["_speaking"].init is False + + +def test_detach_executor_threads_noop_without_registry(monkeypatch): + # When concurrent.futures exposes no thread registry, detach returns before touching it. A + # thread that WOULD be popped is staged, so the mutant dropping the early return crashes on + # None.pop and the test kills it; with the return intact the call is a clean no-op. + monkeypatch.setattr(_runtime.cf_thread, "_threads_queues", None, raising=False) + staged = threading.Thread(target=lambda: None) + monkeypatch.setattr(_runtime, "executor_threads", lambda: {staged}) + + _runtime.detach_executor_threads_since(set()) # no AttributeError: early-returns on None diff --git a/tests/test_agent_cascade_files.py b/tests/test_agent_cascade_files.py new file mode 100644 index 00000000..c4c11ab4 --- /dev/null +++ b/tests/test_agent_cascade_files.py @@ -0,0 +1,207 @@ +"""Engine-level tests for the `assembly live` --files feature: the write approver is +threaded into the streaming leg, and a write-approval pause suspends the reply deadline. + +Kept in its own module (not appended to the already-large engine suite) and driven against +the shared cascade fakes — no sockets, mic, or speaker. +""" + +from __future__ import annotations + +import queue +import types + +import pytest + +from aai_cli.agent_cascade import brain, engine +from aai_cli.agent_cascade.brain import ApprovalPause, SpeechDelta +from aai_cli.agent_cascade.config import CascadeConfig +from aai_cli.app.context import AppState +from aai_cli.commands.agent_cascade import _exec +from aai_cli.commands.agent_cascade._exec import run_agent_cascade +from aai_cli.core import config +from tests._cascade_fakes import make_session +from tests.test_agent_cascade_command import _opts + + +def test_deny_writes_always_rejects(): + # The non-interactive approver declines every write (no channel to confirm one). + assert _exec._deny_writes("write_file", {"file_path": "/x"}) is False + assert _exec._deny_writes("edit_file", {"file_path": "/y"}) is False + + +def test_files_flag_threads_into_config_with_deny_approver_on_headless_path(monkeypatch): + # --files reaches CascadeConfig.files, and the non-interactive (file source) path wires the + # deny-writes approver since there's no keyboard channel to confirm a write. + monkeypatch.setattr(_exec.tts_session, "require_available", lambda _c: None) + monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k") + monkeypatch.setattr(_exec, "FileSource", lambda src: types.SimpleNamespace(sample_rate=16000)) + monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") + captured = {} + + def fake_real(api_key, cfg, *, audio, stt_params, approver=None): + captured["files"] = cfg.files + captured["approver"] = approver + return "deps" + + monkeypatch.setattr(_exec.engine.CascadeDeps, "real", fake_real) + monkeypatch.setattr(_exec.engine, "run_cascade", lambda **kwargs: None) + run_agent_cascade(_opts(source="clip.wav", files=True), AppState(), json_mode=False) + assert captured["files"] is True + assert captured["approver"] is _exec._deny_writes + + +def test_real_passes_approver_to_streamer(monkeypatch): + # CascadeDeps.real must hand the front-end's write approver to build_streamer so gated + # writes can be confirmed; on the non-files path it's simply None. + captured: dict[str, object] = {} + + def fake_build_streamer(api_key, config, *, approver=None): + captured["approver"] = approver + return lambda messages: [] + + monkeypatch.setattr(engine.brain, "build_streamer", fake_build_streamer) + + def approve(name, args): + return True + + from assemblyai.streaming.v3 import StreamingParameters + + engine.CascadeDeps.real( + "k", + CascadeConfig(files=True), + audio=iter([]), + stt_params=StreamingParameters.model_construct(), + approver=approve, + ) + assert captured["approver"] is approve + + +def test_next_event_blocks_with_no_timeout_when_paused(): + # deadline=None means "paused awaiting the user's y/n": block on the queue with no timeout + # (a slow keypress must never surface a _Timeout), returning the event once it lands. + session, _renderer, _player = make_session() + events: queue.Queue = queue.Queue() + events.put(SpeechDelta("hi")) + assert session._next_event(events, None, set()) == SpeechDelta("hi") + + +def test_consume_suspends_then_restores_deadline_across_approval(monkeypatch): + # An ApprovalPause(active=True) drops the consumer's deadline to None (clock paused); the + # matching active=False restores a finite deadline — so only the human-think wait is uncounted. + session, _renderer, _player = make_session() + events: queue.Queue = queue.Queue() + for event in ( + ApprovalPause(active=True), + ApprovalPause(active=False), + SpeechDelta("Hi."), + engine._Done(), + ): + events.put(event) + + seen: list[float | None] = [] + real_next = session._next_event + + def spy(evts, deadline, before): + seen.append(deadline) + return real_next(evts, deadline, before) + + monkeypatch.setattr(session, "_next_event", spy) + session._consume(events, set(), []) + + assert seen[0] is not None # initial deadline is finite + assert seen[1] is None # paused after ApprovalPause(active=True) + assert seen[2] is not None # restored after ApprovalPause(active=False) + + +def test_approval_deadline_suspends_then_restores_into_the_future(): + # active=True suspends the clock (None); active=False restores a deadline in the FUTURE — + # asserting it's ahead of now (not merely non-None) pins the + so the timeout actually fires. + import time + + assert engine._approval_deadline(ApprovalPause(active=True)) is None + restored = engine._approval_deadline(ApprovalPause(active=False)) + assert restored is not None + assert restored > time.monotonic() + + +def test_declined_execute_yields_declined_message(): + action = {"name": "execute", "args": {"command": "rm -rf build"}} + assert brain._decide(action, lambda name, args: False) == { + "type": "reject", + "message": brain._DECLINED, + } + + +def test_decide_coerces_non_dict_args_to_empty_dict(): + # When a pending action's args isn't a dict, _decide hands the approver {} (not the raw + # value). Asserting the approver SAW {} kills the mutant that drops the coercion. + seen: dict[str, object] = {} + + def approver(name: str, args: dict[str, object]) -> bool: + seen["name"] = name + seen["args"] = args + return True + + decision = brain._decide({"name": "execute", "args": [1, 2]}, approver) + + assert decision == {"type": "approve"} + assert seen["name"] == "execute" + assert seen["args"] == {} + + +def test_decide_passes_dict_args_through_unchanged(): + # When args IS a dict, _decide forwards it verbatim (the `or {}` keeps the real dict). This + # kills the Or->And mutant, which would collapse a real dict to {} before the approver sees it. + seen: dict[str, object] = {} + + def approver(name: str, args: dict[str, object]) -> bool: + seen["args"] = args + return True + + brain._decide({"name": "write_file", "args": {"file_path": "n.txt"}}, approver) + + assert seen["args"] == {"file_path": "n.txt"} + + +def test_brain_stream_event_dataclasses_are_frozen(): + # SpeechDelta/ToolNotice/ApprovalPause are frozen; the frozen=True->False mutant is killed + # by asserting a write raises. A variable attr name dodges ruff B010 and pyright's frozen check. + import dataclasses + + probe = "injected_probe" + events = ( + brain.SpeechDelta(text="x"), + brain.ToolNotice(label="Searching", fillers=("one moment",)), + brain.ApprovalPause(active=True), + ) + for event in events: + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(event, probe, 1) + + +class _SpyGatedGraph: + """A graph satisfying _GatedGraph that records get_state calls (the gate inspection).""" + + def __init__(self) -> None: + self.get_state_calls = 0 + + def invoke(self, input, config=None): # mirrors langgraph/CompiledAgent.invoke (unused here) + return {} + + def stream(self, graph_input, config, *, stream_mode): + return iter(()) # no chunks; the test only cares which path runs + + def get_state(self, config): + self.get_state_calls += 1 + return types.SimpleNamespace(interrupts=()) + + +def test_stream_graph_defaults_to_ungated(): + # _stream_graph's `gated` defaults to False: an ungated pass never inspects interrupts. The + # gated=False->True mutant would route a _GatedGraph through _stream_gated -> get_state, so + # asserting get_state is never called kills it. + graph = _SpyGatedGraph() + + list(brain._stream_graph(graph, [])) + + assert graph.get_state_calls == 0 diff --git a/tests/test_agent_cascade_filler.py b/tests/test_agent_cascade_filler.py new file mode 100644 index 00000000..c0bfc597 --- /dev/null +++ b/tests/test_agent_cascade_filler.py @@ -0,0 +1,224 @@ +"""Spoken-filler + planning-discard tests for `assembly live`. + +While a tool runs the cascade speaks a short filler (rotated per tool, once per turn) so a +hands-free turn isn't dead air, and the deep agent's verbose planning between tool calls is held +unspoken so only the final answer is read aloud. Split into its own module (the brain and reply +suites stay under the 500-line file-length gate); driven against the shared cascade fakes — no +sockets, mic, or speaker. +""" + +from __future__ import annotations + +from langchain_core.messages import AIMessageChunk + +from aai_cli.agent_cascade import brain, weather_tool +from aai_cli.agent_cascade.brain import SpeechDelta, ToolNotice +from aai_cli.core.errors import APIError +from tests._cascade_fakes import make_session +from tests.test_agent_cascade_brain import _collect, _MessageStreamGraph + +# --- brain: the per-tool filler table + the carrier ToolNotice --------------- + + +def test_tool_fillers_maps_known_tools_and_falls_back_to_generic(): + # Each known tool carries its own spoken filler variants; an unknown/MCP tool falls back to + # the generic tuple (mirrors how _tool_label falls back to "Using {name}"). + assert brain._tool_fillers(brain.WEB_SEARCH_TOOL_NAME) == ( + "Let me look that up.", + "Searching now.", + "One moment, checking the web.", + ) + assert brain._tool_fillers(weather_tool.WEATHER_TOOL_NAME) == ( + "Let me check the weather.", + "Checking the forecast now.", + ) + assert brain._tool_fillers("totally_unknown_tool") == brain._GENERIC_FILLERS + # The engine rotates fillers[index % len(fillers)], so an empty fallback would divide by zero. + assert brain._GENERIC_FILLERS + + +def test_streamer_tool_notice_carries_the_tools_fillers(): + # The notice carries the tool's filler variants (not a pre-chosen one) so the engine owns + # rotation; here the weather tool's tuple rides along with the affordance label. + call_chunk = AIMessageChunk( + content="", + tool_call_chunks=[ + {"name": weather_tool.WEATHER_TOOL_NAME, "args": "", "id": "c1", "index": 0} + ], + ) + graph = _MessageStreamGraph([(call_chunk, {})]) + events = _collect(graph, [{"role": "user", "content": "weather?"}]) + notices = [e for e in events if isinstance(e, brain.ToolNotice)] + assert notices[0].fillers == brain._tool_fillers(weather_tool.WEATHER_TOOL_NAME) + + +# --- engine: speaking the filler + discarding interim planning --------------- + + +def test_generate_reply_speaks_a_filler_on_the_first_tool_call(): + # While a tool runs the agent says a spoken filler so a hands-free turn isn't dead air; it is + # synthesized BEFORE the answer clauses (which only land after the tool returns). + spoken = [] + + def stream(messages): + yield ToolNotice("Checking the weather", ("Let me check the weather.", "Checking now.")) + yield SpeechDelta("It is sunny today.") + + session, _renderer, _player = make_session( + stream_reply=stream, + synthesize=lambda text, sink: spoken.append(text) or sink(b""), + ) + session._generate_reply() + assert spoken == ["Let me check the weather.", "It is sunny today."] + + +def test_generate_reply_discards_planning_text_emitted_between_tool_calls(): + # A deepagents turn interleaves verbose planning ("SESSION INTENT / NEXT STEPS") with tool + # calls; only the FINAL answer (the text after the last tool call) is spoken and recorded — + # the intermediate planning is held unspoken and discarded at the next tool call. + spoken = [] + + def stream(messages): + yield ToolNotice("Searching the web", ("Searching now.",)) + yield SpeechDelta("SESSION INTENT: the user asked for the news. ") # planning, post-tool + yield SpeechDelta("NEXT STEPS: read a few more pages. ") # more planning + yield ToolNotice("Reading the page", ("Reading now.",)) # discards the buffered planning + yield SpeechDelta("Here is the news: AI is booming.") # the real answer, after last tool + + session, _renderer, _player = make_session( + stream_reply=stream, + synthesize=lambda text, sink: spoken.append(text) or sink(b""), + ) + session._generate_reply() + assert spoken == ["Searching now.", "Here is the news: AI is booming."] + assert session.history[-1] == { + "role": "assistant", + "content": "Here is the news: AI is booming.", + } + + +def test_generate_reply_speaks_only_one_filler_per_turn(): + # Only the first tool call of a turn speaks; chained tool calls stay silent so a multi-tool + # turn doesn't get chatty. + spoken = [] + + def stream(messages): + yield ToolNotice("Searching the web", ("look one", "look two")) + yield ToolNotice("Reading the page", ("read one", "read two")) + yield SpeechDelta("Done.") + + session, _renderer, _player = make_session( + stream_reply=stream, + synthesize=lambda text, sink: spoken.append(text) or sink(b""), + ) + session._generate_reply() + assert spoken == ["look one", "Done."] # the second tool call spoke no filler + + +def test_generate_reply_rotates_fillers_across_turns(): + # A per-session counter rotates variants deterministically, so the same tool across three + # turns says each variant in turn rather than repeating one phrase. + spoken = [] + variants = ("first.", "second.", "third.") + + def stream(messages): + yield ToolNotice("Checking the weather", variants) + yield SpeechDelta("ok.") + + session, _renderer, _player = make_session( + stream_reply=stream, + synthesize=lambda text, sink: spoken.append(text) or sink(b""), + ) + session._generate_reply() + session._generate_reply() + session._generate_reply() + assert [s for s in spoken if s != "ok."] == ["first.", "second.", "third."] + + +def test_generate_reply_does_not_record_the_filler_in_history(): + # The filler is conversational glue, not part of the answer, so history stays a clean + # alternating record of the real reply only. + def stream(messages): + yield ToolNotice("Checking the weather", ("Let me check the weather.",)) + yield SpeechDelta("It is sunny today.") + + session, _renderer, _player = make_session( + stream_reply=stream, synthesize=lambda text, sink: sink(b"") + ) + session._generate_reply() + assert session.history[-1] == {"role": "assistant", "content": "It is sunny today."} + + +def test_generate_reply_marks_started_when_a_turn_opens_with_a_tool_call(): + # The filler is the start of audible output, so _speaking is set and reply_started fires + # before it's synthesized (so the voice bar shows speaking and a barge-in mid-filler is caught). + observed = [] + + def stream(messages): + yield ToolNotice("Checking the weather", ("Let me check.",)) + yield SpeechDelta("ok.") + + session, renderer, _player = make_session(stream_reply=stream) + session.deps.synthesize = lambda text, sink: ( + observed.append((text, session._speaking.is_set())) or sink(b"") + ) + session._generate_reply() + assert ("reply_started",) in renderer.calls + assert observed[0] == ("Let me check.", True) # speaking set before the filler is synthesized + + +def test_barge_in_during_a_filler_drops_its_remaining_audio(): + # A barge-in mid-filler sets _stop; _feed must drop the in-flight frames just like a clause, + # so nothing of the filler keeps playing after the user talks over it. + def stream(messages): + yield ToolNotice("Checking the weather", ("Let me check the weather.",)) + yield SpeechDelta("answer") + + session, _renderer, player = make_session(stream_reply=stream) + + def synthesize(text, sink): + session._stop.set() # the user barges in mid-filler + sink(b"frame") + + session.deps.synthesize = synthesize + session._generate_reply() + assert player.enqueued == [] # _feed dropped the frame once _stop was set + + +def test_generate_reply_aborts_when_the_filler_fails_to_synthesize(): + # A filler that can't be synthesized is the same failure mode as a clause that can't: the + # error is recorded and the turn ends cleanly without speaking the answer. + def stream(messages): + yield ToolNotice("Checking the weather", ("Let me check the weather.",)) + yield SpeechDelta("the answer") + + spoken = [] + + def synthesize(text, sink): + if text == "the answer": + spoken.append(text) + return + raise APIError("tts boom") + + session, _renderer, _player = make_session(stream_reply=stream, synthesize=synthesize) + session._generate_reply() + assert spoken == [] # the answer was never reached + assert session.error is not None + assert "tts boom" in session.error.message + + +def test_generate_reply_speaks_a_generic_filler_for_an_unknown_tool(): + # A tool with no entry in _TOOL_FILLERS still speaks — the notice carries the generic + # fallback tuple (mirrors _tool_label's "Using {name}" fallback). + spoken = [] + + def stream(messages): + yield ToolNotice("Using mcp_thing", brain._tool_fillers("mcp_thing")) + yield SpeechDelta("done.") + + session, _renderer, _player = make_session( + stream_reply=stream, + synthesize=lambda text, sink: spoken.append(text) or sink(b""), + ) + session._generate_reply() + assert spoken == [brain._GENERIC_FILLERS[0], "done."] diff --git a/tests/test_agent_cascade_prompt.py b/tests/test_agent_cascade_prompt.py new file mode 100644 index 00000000..7c45161b --- /dev/null +++ b/tests/test_agent_cascade_prompt.py @@ -0,0 +1,202 @@ +"""Tests for the live agent's system-prompt construction (aai_cli.agent_cascade.prompt). + +Split out of test_agent_cascade_brain.py to keep each file within the 500-line gate. The +prompt is tailored to the bound tools so the model is only told about capabilities it has. +""" + +from __future__ import annotations + +from aai_cli.agent_cascade import datetime_tool, prompt, weather_tool, webpage_tool + + +class _NamedTool: + """A stand-in tool exposing just the ``.name`` the prompt builder inspects.""" + + def __init__(self, name: str): + self.name = name + + +def test_system_prompt_advertises_web_search_when_present(): + text = prompt.build_system_prompt( + "You are a pirate.", tools=[_NamedTool(prompt.WEB_SEARCH_TOOL_NAME)] + ) + # The persona is preserved, and the guidance advertises the web-search capability the + # present tool backs (the plain cascade persona never mentions tools). + assert text.startswith("You are a pirate.") + assert "search the web" in text + + +def test_system_prompt_omits_web_search_when_search_tool_absent(): + # Without the Firecrawl search tool the guidance must NOT promise web search — announcing + # a missing tool makes the agent narrate "I'll search…" and then stall with no answer. A + # non-search tool name must not falsely trigger the web-search capability. + text = prompt.build_system_prompt("persona", tools=[_NamedTool("some_other_tool")]) + assert "search the web for current or unfamiliar facts" not in text + + +def test_system_prompt_tells_model_not_to_promise_tools_when_none(): + # No tools at all: the model must answer from its own knowledge and explicitly not + # promise to search or look anything up (the bug that left replies never coming back). + text = prompt.build_system_prompt("persona", tools=[]) + assert "search the web for current or unfamiliar facts" not in text + assert "your own knowledge" in text + assert "Never say" in text + + +def test_extra_capability_lists_sorted_tool_names(): + # MCP tools are advertised generically, by name, alphabetically. + phrase = prompt._extra_capability([_NamedTool("zeta"), _NamedTool("alpha")]) + assert phrase == "use your connected tools (alpha, zeta)" + + +def test_extra_capability_is_none_without_extra_tools(): + assert prompt._extra_capability([]) is None + + +def test_system_prompt_advertises_mcp_extra_tools(): + # With MCP tools bound (but no built-in legs), the model must be told it HAS tools — + # not handed the "no external tools" guidance — and the tools are named. + text = prompt.build_system_prompt("persona", tools=[], extra_tools=[_NamedTool("get_time")]) + assert "your own knowledge" not in text + assert "use your connected tools (get_time)" in text + + +def test_system_prompt_advertises_files_when_enabled(): + # With --files on, the model must be told it can read/write files in the working dir, + # so it knows the capability is real (and the no-tools guidance must not apply). + text = prompt.build_system_prompt("persona", tools=[], files=True) + assert "read, write, and search files in your working directory" in text + assert "your own knowledge" not in text + + +def test_system_prompt_advertises_code_execution_under_files(): + prompt_text = prompt.build_system_prompt("persona", tools=[], files=True) + assert "run code to solve problems" in prompt_text + + +def test_system_prompt_omits_code_execution_without_files(): + prompt_text = prompt.build_system_prompt("persona", tools=[], files=False) + assert "run code" not in prompt_text + + +def test_system_prompt_advertises_delegation_under_files(): + # --files binds the task tool (a subagent), so the prompt offers delegating to a helper. + assert "delegate a bigger job to a helper" in prompt.build_system_prompt( + "persona", tools=[], files=True + ) + + +def test_system_prompt_omits_delegation_without_files(): + assert "delegate a bigger job" not in prompt.build_system_prompt( + "persona", tools=[], files=False + ) + + +def test_system_prompt_omits_files_when_disabled(): + # Default: no file capability advertised (the model shouldn't promise file access it lacks). + text = prompt.build_system_prompt("persona", tools=[], files=False) + assert "working directory" not in text + + +def test_system_prompt_reports_tool_outcomes_honestly_when_tools_present(): + # A spoken agent that narrates a success it never achieved is worse than one that admits + # it couldn't — so whenever tools are bound the guidance must tell the model not to claim + # an action happened until the tool returns. + text = prompt.build_system_prompt("persona", tools=[_NamedTool(prompt.WEB_SEARCH_TOOL_NAME)]) + assert "until the tool actually returns" in text + + +def test_system_prompt_warns_before_irreversible_file_actions(): + # The --files capability can write files and run code, which speaking can't undo, so the + # model must be told to confirm before destructive actions and not claim a change landed. + text = prompt.build_system_prompt("persona", tools=[], files=True) + assert "can't be undone" in text + + +def test_system_prompt_omits_file_safety_warning_without_files(): + # The irreversibility warning is only meaningful when the file tools are actually bound. + text = prompt.build_system_prompt("persona", tools=[], files=False) + assert "can't be undone" not in text + + +def test_system_prompt_latches_persona_against_rules_with_tools(): + # A user persona can pull against the spoken/honesty rules (a chatty in-character persona + # fighting "keep it short"), so the prompt must state the rules outrank the persona's style, + # and the latch must sit between the persona and the guidance it governs. + text = prompt.build_system_prompt( + "You are a pirate.", tools=[_NamedTool(prompt.WEB_SEARCH_TOOL_NAME)] + ) + latch = "the rules below override the persona's style" + assert latch in text + assert text.index("You are a pirate.") < text.index(latch) < text.index("search the web") + + +def test_system_prompt_latches_persona_against_rules_without_tools(): + # The latch applies on the no-tools path too: the persona must not override the + # answer-from-knowledge / don't-promise-tools rules either. + text = prompt.build_system_prompt("You are a pirate.", tools=[]) + assert "the rules below override the persona's style" in text + assert "your own knowledge" in text + + +def test_system_prompt_tells_model_to_retry_a_thin_lookup_before_giving_up(): + # An empty/thin tool result shouldn't end the turn — the model should rephrase once before + # concluding it found nothing (the openclaw "vary query before concluding" technique). + text = prompt.build_system_prompt("persona", tools=[_NamedTool(prompt.WEB_SEARCH_TOOL_NAME)]) + assert "try once more with different wording" in text + + +def test_no_tools_guidance_gives_a_worked_example_of_not_promising_a_lookup(): + # The documented failure (promise to look something up, then go silent) is reinforced with a + # concrete spoken example, not just the abstract rule. + text = prompt.build_system_prompt("persona", tools=[]) + assert "rather than offering to look it up" in text + + +def test_files_safety_tells_model_to_read_before_overwriting(): + # --files writes can clobber a file wholesale; the model must read first and merge rather + # than replace unless asked. Only meaningful when the file tools are bound. + with_files = prompt.build_system_prompt("persona", tools=[], files=True) + assert "Read a file before overwriting it" in with_files + assert "Read a file before overwriting it" not in prompt.build_system_prompt( + "persona", tools=[], files=False + ) + + +def test_join_clause_grammar(): + # One/two/three capability phrases each render with natural conjunctions. + assert prompt._join_clause(["a"]) == "a" + assert prompt._join_clause(["a", "b"]) == "a and b" + assert prompt._join_clause(["a", "b", "c"]) == "a, b, and c" + + +def test_tool_capabilities_lists_web_search_then_weather_when_both_present(): + caps = prompt._tool_capabilities( + [_NamedTool(prompt.WEB_SEARCH_TOOL_NAME), _NamedTool(weather_tool.WEATHER_TOOL_NAME)] + ) + # Exact list pins BOTH phrases and their order, killing a drop/swap of either block. + assert caps == [ + "search the web for current or unfamiliar facts", + "tell someone the current weather and short forecast for a place", + ] + + +def test_read_url_tool_advertised_in_system_prompt(): + text = prompt.build_system_prompt( + "persona", tools=[_NamedTool(webpage_tool.READ_URL_TOOL_NAME)] + ) + assert "read a web page or PDF" in text + + +def test_weather_tool_advertised_in_system_prompt(): + text = prompt.build_system_prompt("persona", tools=[_NamedTool(weather_tool.WEATHER_TOOL_NAME)]) + assert "current weather and short forecast" in text + # And it isn't the no-tools fallback. + assert "no external tools" not in text + + +def test_datetime_tool_advertised_in_system_prompt(): + text = prompt.build_system_prompt( + "persona", tools=[_NamedTool(datetime_tool.DATETIME_TOOL_NAME)] + ) + assert "current date and time" in text diff --git a/tests/test_agent_cascade_reply.py b/tests/test_agent_cascade_reply.py new file mode 100644 index 00000000..0a21b0b6 --- /dev/null +++ b/tests/test_agent_cascade_reply.py @@ -0,0 +1,411 @@ +"""Reply-streaming and interruption tests for the voice cascade (engine.py). + +Covers reply generation (clause streaming, tool notices, the timeout backstop, +and leg/TTS failures) plus barge-in / interrupt / shutdown. Fixtures come from +tests/_cascade_fakes; every test runs against fakes — no sockets, mic, or speaker. +""" + +from __future__ import annotations + +import threading + +from aai_cli.agent_cascade import engine +from aai_cli.agent_cascade.brain import SpeechDelta, ToolNotice +from aai_cli.agent_cascade.config import CascadeConfig +from aai_cli.core.errors import APIError, CLIError +from tests._cascade_fakes import FakeWorker, make_session +from tests._cascade_fakes import deltas as _deltas +from tests._cascade_fakes import turn as _turn + +# --- reply generation -------------------------------------------------------- + + +def test_generate_reply_pins_min_clause_chars_for_soft_separators(): + # _MIN_CLAUSE_CHARS gates SOFT separators only: a pre-comma clause whose length equals the + # threshold flushes on the comma (two spoken clauses). If the constant were larger, that clause + # would be held and the whole reply would speak as a single clause via the trailing period. + # The hardcoded 25 is the expected value; a mutation (25→26) makes the 25-char clause fall + # below the threshold, so it is not flushed at the comma and only one clause is spoken. + assert engine._MIN_CLAUSE_CHARS == 25 # pin the exact value + spoken = [] + text = ("a" * 24) + ", and the rest is here." # comma-clause is exactly 25 chars -> flushes + session, _renderer, _player = make_session( + stream_reply=_deltas(text), + synthesize=lambda t, sink: spoken.append(t) or sink(b""), + ) + session._generate_reply() + assert len(spoken) == 2 + assert spoken[0].endswith(",") # the soft clause flushed at the comma because len >= 25 + + +def test_generate_reply_speaks_each_clause_as_it_streams(): + spoken = [] + session, renderer, player = make_session( + stream_reply=_deltas("One. ", "Two! ", "Three?"), + synthesize=lambda text, sink: spoken.append(text) or sink(text.encode()), + ) + session._generate_reply() + assert spoken == ["One.", "Two!", "Three?"] + assert player.enqueued == [b"One.", b"Two!", b"Three?"] + assert ("reply_started",) in renderer.calls + assert ("agent_transcript", "One.", False) in renderer.calls + assert session.history[-1] == {"role": "assistant", "content": "One. Two! Three?"} + assert ("reply_done", False) in renderer.calls + + +def test_generate_reply_forwards_tool_notice_and_drops_unspoken_preamble(): + # A ToolNotice surfaces the affordance AND clears any buffered-but-unspoken text, so a + # half-streamed preamble before a tool call is never spoken. + spoken = [] + + def stream(messages): + yield SpeechDelta("Let me check") # incomplete clause, not yet flushed + yield ToolNotice("Searching the web", ("Searching now.",)) + yield SpeechDelta("It is sunny today.") + + session, renderer, _player = make_session( + stream_reply=stream, + synthesize=lambda text, sink: spoken.append(text) or sink(b""), + ) + session._generate_reply() + assert ("tool_call", "Searching the web") in renderer.calls + # The unspoken preamble is dropped; the spoken filler takes its place before the answer. + assert spoken == ["Searching now.", "It is sunny today."] + assert session.history[-1] == {"role": "assistant", "content": "It is sunny today."} + + +def test_generate_reply_marks_speaking_on_first_delta_then_clears(): + observed = [] + session, _renderer, _player = make_session(stream_reply=_deltas("Hi. ", "Yes.")) + session.deps.synthesize = lambda text, sink: observed.append(session._speaking.is_set()) + session._generate_reply() + assert observed == [True, True] + assert not session._speaking.is_set() + + +def test_generate_reply_threads_system_prompt_and_history(): + captured = {} + + def capture(messages): + captured["messages"] = messages + return [SpeechDelta("Ok.")] + + session, _renderer, _player = make_session( + stream_reply=capture, config=CascadeConfig(system_prompt="be terse") + ) + session.history.append({"role": "user", "content": "prior"}) + session._generate_reply() + assert captured["messages"][0] == {"role": "system", "content": "be terse"} + assert {"role": "user", "content": "prior"} in captured["messages"] + + +def test_generate_reply_trims_history_window(): + session, _renderer, _player = make_session( + stream_reply=_deltas("a. b."), config=CascadeConfig(max_history=1) + ) + session.history.append({"role": "user", "content": "hi"}) + session._generate_reply() + assert session.history == [{"role": "assistant", "content": "a. b."}] + + +def test_on_turn_trims_history_window(): + session, _renderer, _player = make_session( + stream_reply=_deltas(""), config=CascadeConfig(max_history=1) + ) + session.history.append({"role": "assistant", "content": "old"}) + session.on_turn(_turn("newest")) + assert session.history == [{"role": "user", "content": "newest"}] + + +def test_generate_reply_stop_during_a_clause_drops_it_from_the_record(): + # A barge-in lands *while* "Two." is synthesizing: its audio is flushed and the clause is NOT + # recorded as spoken (the user never heard it whole), so only the finished "One." survives — + # the post-synthesis stop check is what keeps the half-spoken clause out of the history. + def synth(text, sink): + if text == "Two.": + session._stop.set() # barge-in mid-clause: its frames are dropped by _feed + sink(text.encode()) + + session, renderer, player = make_session(stream_reply=_deltas("One. Two. Three.")) + session.deps.synthesize = synth + session._generate_reply() + assert player.enqueued == [b"One."] # Two.'s frames are dropped once the stop lands + assert session.history[-1] == {"role": "assistant", "content": "One."} + assert ("reply_done", True) in renderer.calls + + +def test_generate_reply_flushes_the_unterminated_tail_at_end_of_stream(): + # A reply that never ends on a terminator still gets spoken: the trailing buffer is + # flushed as one final clause when the stream finishes. + spoken = [] + session, _renderer, player = make_session( + stream_reply=_deltas("no terminator here"), + synthesize=lambda text, sink: spoken.append(text) or sink(text.encode()), + ) + session._generate_reply() + assert spoken == ["no terminator here"] + assert player.enqueued == [b"no terminator here"] + assert session.history[-1] == {"role": "assistant", "content": "no terminator here"} + + +def test_generate_reply_leg_failure_after_speaking_keeps_the_spoken_text(): + # A leg error that arrives *after* a clause was spoken is recorded but not shown inline + # (the spoken text already explains the turn); the spoken part stays in the history. + def stream(messages): + yield SpeechDelta("First clause. ") + raise APIError("gateway died midway") + + session, renderer, player = make_session( + stream_reply=stream, + synthesize=lambda text, sink: sink(text.encode()), + ) + session._generate_reply() + assert isinstance(session.error, APIError) + assert player.enqueued == [b"First clause."] + assert session.history[-1] == {"role": "assistant", "content": "First clause."} + # The error is NOT surfaced inline once speech has started (no "(error: ...)" line). + assert not any(c[0] == "agent_transcript" and "(error:" in c[1] for c in renderer.calls) + assert ("reply_done", False) in renderer.calls + + +def test_generate_reply_stop_before_first_clause_speaks_nothing(): + session, renderer, player = make_session(stream_reply=_deltas("One. Two.")) + session._stop.set() + session._generate_reply() + assert player.enqueued == [] + assert all(item.get("role") != "assistant" for item in session.history) + assert ("reply_done", True) in renderer.calls + + +def test_generate_reply_times_out_via_the_backstop(monkeypatch): + release = threading.Event() + + def hang(messages): + release.wait(timeout=2.0) # self-releases so no mutated deadline can wedge the suite + yield SpeechDelta("late") + + monkeypatch.setattr(engine, "_REPLY_TIMEOUT_SECONDS", 0.05) + session, renderer, player = make_session(stream_reply=hang) + try: + session._generate_reply() + assert isinstance(session.error, CLIError) + assert session.error.error_type == "agent_timeout" + assert any(c[0] == "agent_transcript" and "longer than" in c[1] for c in renderer.calls) + assert ("reply_done", False) in renderer.calls + assert player.enqueued == [] + finally: + release.set() + + +def test_generate_reply_with_an_already_elapsed_deadline_times_out_at_once(monkeypatch): + # A non-positive remaining budget (the deadline is already in the past on the first wait) + # surfaces the timeout immediately without ever blocking on the event queue. + monkeypatch.setattr(engine, "_REPLY_TIMEOUT_SECONDS", 0.0) + session, renderer, player = make_session(stream_reply=_deltas("would have spoken.")) + session._generate_reply() + assert isinstance(session.error, CLIError) + assert session.error.error_type == "agent_timeout" + assert player.enqueued == [] # nothing is ever pulled off the queue + assert ("reply_done", False) in renderer.calls + + +def test_generate_reply_detaches_the_orphaned_executor_on_timeout(monkeypatch): + # Regression: the streamed graph drives each node through a langchain ThreadPoolExecutor. + # A timed-out turn abandons the producer with that worker still blocked on the leg, and + # concurrent.futures joins every executor worker at interpreter exit — a blocked one wedges + # shutdown. _generate_reply's timeout path must unregister that orphan. + import concurrent.futures.thread as cf_thread + from concurrent.futures import ThreadPoolExecutor + + from aai_cli.agent_cascade.brain import SpeechDelta + + release = threading.Event() + executors: list[ThreadPoolExecutor] = [] + + def hang(messages): + executor = ThreadPoolExecutor(max_workers=1) + executors.append(executor) + executor.submit(lambda: release.wait(timeout=2.0)).result() + yield SpeechDelta("late") + + monkeypatch.setattr(engine, "_REPLY_TIMEOUT_SECONDS", 0.2) + session, _renderer, _player = make_session(stream_reply=hang) + before = set(cf_thread._threads_queues) + try: + session._generate_reply() + assert isinstance(session.error, CLIError) + assert session.error.error_type == "agent_timeout" + assert set(cf_thread._threads_queues) - before == set() + finally: + release.set() + for executor in executors: + executor.shutdown(wait=True) + + +def test_generate_reply_llm_failure_is_recorded_and_surfaced(): + def boom(messages): + raise APIError("gateway down") + + session, renderer, player = make_session(stream_reply=boom) + session._generate_reply() + assert isinstance(session.error, APIError) + assert ("agent_transcript", "(error: gateway down)", False) in renderer.calls + assert ("reply_done", False) in renderer.calls + assert player.enqueued == [] + + +def test_generate_reply_tts_failure_midway_is_recorded(): + def boom(text, sink): + raise APIError("tts down") + + session, renderer, player = make_session(stream_reply=_deltas("Hi."), synthesize=boom) + session._generate_reply() + assert isinstance(session.error, APIError) + assert player.enqueued == [] + assert ("reply_started",) in renderer.calls + assert ("reply_done", False) in renderer.calls + + +def test_generate_reply_tts_failure_aborts_the_rest_of_the_turn(): + # A TTS failure cuts the turn: the leg is down, so a *later* streamed delta ("After.") is + # never synthesized — the turn aborts on the failure rather than speaking on. + spoken = [] + + def stream(messages): + yield SpeechDelta("Boom. ") + yield SpeechDelta("After.") + + def synth(text, sink): + if text == "Boom.": + raise APIError("tts down") + spoken.append(text) + sink(text.encode()) + + session, _renderer, player = make_session(stream_reply=stream) + session.deps.synthesize = synth + session._generate_reply() + assert spoken == [] # After. is never reached once Boom. fails the leg + assert player.enqueued == [] + assert all(item.get("role") != "assistant" for item in session.history) + + +def test_generate_reply_succeeds_within_a_short_deadline(monkeypatch): + # A reply that lands inside a tight (sub-second) deadline is spoken normally — the deadline + # only fires on a genuine stall, not on every turn. + monkeypatch.setattr(engine, "_REPLY_TIMEOUT_SECONDS", 0.5) + session, _renderer, player = make_session(stream_reply=_deltas("Quick reply.")) + session._generate_reply() + assert session.error is None + assert player.enqueued == [b"pcm:Quick reply."] + assert session.history[-1] == {"role": "assistant", "content": "Quick reply."} + + +def test_record_error_keeps_first_and_warns(monkeypatch): + printed = [] + monkeypatch.setattr(engine.output.error_console, "print", lambda msg: printed.append(msg)) + session, _renderer, _player = make_session() + session._record_error(APIError("first")) + session._record_error(APIError("second")) + assert isinstance(session.error, APIError) + assert session.error.message == "first" + assert any("first" in str(msg) for msg in printed) + + +# --- barge-in / shutdown ----------------------------------------------------- + + +def test_barge_in_cancels_and_flushes_live_worker(): + # A new spoken turn supersedes a reply that is still *thinking* (alive, not yet speaking): + # unlike a UI interrupt, a barge-in must cancel it so it never speaks over the new turn. + session, _renderer, player = make_session() + worker = FakeWorker(alive=True) + session._reply = worker + session._barge_in() + assert session._stop.is_set() + assert player.flushed == 1 + assert worker.joined == 1 + assert session._reply is None + + +def test_barge_in_without_a_live_worker_does_not_flush(): + # No worker, or one that already finished: nothing to cancel, so no flush. + session, _renderer, player = make_session() + session._barge_in() # no worker + session._reply = FakeWorker(alive=False) + session._barge_in() # finished worker + assert player.flushed == 0 + assert session._reply is None + + +def test_interrupt_reply_signals_stop_and_flushes_without_joining(): + # Live TUI Escape/Ctrl-C silences a *speaking* reply: stop flag + flush, but NO join. + session, _renderer, player = make_session() + worker = FakeWorker(alive=True) + session._reply = worker + session._speaking.set() # the reply has reached its speak-and-enqueue phase + assert session.interrupt_reply() is True + assert session._stop.is_set() + assert player.flushed == 1 + assert worker.joined == 0 # not joined — the worker unwinds on its own + assert session._reply is worker # still tracked; the next turn's barge-in joins it + + +def test_interrupt_reply_while_thinking_returns_false_so_ctrl_c_can_quit(): + # The reply worker is alive but still *thinking* (generating, no audio yet): there's nothing + # audible to cut and the blocking graph can't observe the stop flag, so a UI interrupt is a + # no-op. It must report False (not the bare is_alive() True) so the TUI's Ctrl-C falls + # through to quit instead of being swallowed — otherwise you can't Ctrl-C while it thinks. + session, _renderer, player = make_session() + session._reply = FakeWorker(alive=True) # thinking: alive, but _speaking is not set + assert session.interrupt_reply() is False + assert not session._stop.is_set() # nothing cancelled — the keypress is free to quit + assert player.flushed == 0 + + +def test_interrupt_reply_is_a_noop_when_nothing_is_playing(): + # No worker, or one that already finished: nothing to stop, so no flush and no stop flag. + session, _renderer, player = make_session() + assert session.interrupt_reply() is False # no worker + session._reply = FakeWorker(alive=False) + assert session.interrupt_reply() is False # finished worker + assert player.flushed == 0 + assert not session._stop.is_set() + + +def test_interrupt_reply_silences_the_greeting_with_no_worker(): + # The greeting is enqueued with no reply worker; Escape/Ctrl-C must still cut it. With audio + # queued (pending>0) the interrupt flushes the player and reports that it silenced something, + # so the live TUI interrupts the greeting instead of (for Ctrl-C) quitting the session. + session, _renderer, player = make_session() + player.pending_samples = 1 # even a single queued sample (>0) means sound is still playing + assert session.interrupt_reply() is True + assert player.flushed == 1 + assert player.pending() == 0 # the queued greeting was dropped + + +def test_barge_in_silences_a_draining_reply_tail_after_the_worker_exits(): + # The reply worker enqueues every sentence then exits, but the audio keeps draining. A new + # spoken turn in that window must still cut the tail — a bare is_alive() check would miss it. + session, _renderer, player = make_session() + session._reply = FakeWorker(alive=False) # worker finished enqueuing + player.pending_samples = 9600 # ...but its audio is still playing + session._barge_in() + assert session._stop.is_set() + assert player.flushed == 1 + assert session._reply is None + + +def test_shutdown_joins_live_worker(): + session, _renderer, _player = make_session() + worker = FakeWorker(alive=True) + session._reply = worker + session.shutdown() + assert session._stop.is_set() + assert worker.joined == 1 + assert session._reply is None + + +def test_shutdown_without_worker_is_safe(): + session, _renderer, _player = make_session() + session.shutdown() # no worker spawned + assert session._reply is None diff --git a/tests/test_agent_cascade_sandbox.py b/tests/test_agent_cascade_sandbox.py new file mode 100644 index 00000000..98c330de --- /dev/null +++ b/tests/test_agent_cascade_sandbox.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +from deepagents.backends.protocol import ExecuteResponse + +from aai_cli.agent_cascade import sandbox + + +def test_seatbelt_profile_is_default_allow_reads_deny_default(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + assert "(version 1)" in profile + assert "(deny default)" in profile + assert "(allow process-exec*)" in profile + assert "(allow file-read*)" in profile # default-allow reads + # No network allow anywhere — network stays denied by (deny default). + assert "network" not in profile + + +def test_seatbelt_profile_denies_each_home_secret_for_reads(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + for name in sandbox.HOME_SECRETS: + assert f'(deny file-read* (subpath "/home/u/{name}"))' in profile + + +def test_seatbelt_profile_denies_project_secrets_for_reads(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + # .env (and .env.*) under cwd are read-denied via a regex; .claude/ via subpath. + assert "file-read*" in profile and "/work/proj" in profile + assert any(".env" in line and "deny file-read*" in line for line in profile.splitlines()) + assert '(deny file-read* (subpath "/work/proj/.claude"))' in profile + + +def test_seatbelt_profile_writes_confined_to_cwd_and_tmp(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + assert '(allow file-write* (subpath "/work/proj") (subpath "/tmp"))' in profile + + +def test_seatbelt_profile_denies_persistence_writes_inside_cwd(): + profile = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + assert '(deny file-write* (subpath "/work/proj/.git/hooks"))' in profile + # Shell rc files denied for writes (covers the cwd == $HOME case). + for name in sandbox.SHELL_RC: + assert f'(deny file-write* (subpath "/home/u/{name}"))' in profile + + +def test_bwrap_argv_confines_to_cwd_with_network_unshared(): + argv = sandbox.build_bwrap_argv("/work/proj", "/tmp", "echo hi", "/home/u") + assert argv[0] == "bwrap" + assert "--unshare-all" in argv # includes network namespace + assert "--die-with-parent" in argv + # Whole FS read-only = default-allow reads. + assert _has_pair(argv, "--ro-bind", "/", "/") + # cwd + tmp are read-write bound; chdir into cwd. + assert _has_pair(argv, "--bind", "/work/proj", "/work/proj") + assert _has_pair(argv, "--bind", "/tmp", "/tmp") + assert _adjacent(argv, "--chdir", "/work/proj") + # The command lands at the tail via a shell. + assert argv[-1] == "echo hi" or "echo hi" in argv[-1] + + +def test_bwrap_argv_masks_home_secrets_and_git_hooks(): + argv = sandbox.build_bwrap_argv("/work/proj", "/tmp", "echo hi", "/home/u") + joined = " ".join(argv) + for name in sandbox.HOME_SECRETS: + assert f"/home/u/{name}" in joined # masked (tmpfs / ro-bind /dev/null) + assert "/work/proj/.git/hooks" in joined # write blocked via ro-bind + + +def _has_pair(argv, flag, a, b): + return any( + argv[i] == flag and argv[i + 1] == a and argv[i + 2] == b for i in range(len(argv) - 2) + ) + + +def _adjacent(argv, flag, value): + return any(argv[i] == flag and argv[i + 1] == value for i in range(len(argv) - 1)) + + +def test_renderers_cover_the_same_denylists(): + # Parity: both platform renderers must reference every denylist constant, so a path added + # to one platform can't silently be left unprotected on the other. + seatbelt = sandbox.render_seatbelt_profile("/work/proj", "/tmp", "/home/u") + bwrap = " ".join(sandbox.build_bwrap_argv("/work/proj", "/tmp", "x", "/home/u")) + for name in sandbox.HOME_SECRETS: + assert f"/home/u/{name}" in seatbelt + assert f"/home/u/{name}" in bwrap + assert "/work/proj/.git/hooks" in seatbelt + assert "/work/proj/.git/hooks" in bwrap + + +def test_detect_capability_seatbelt_on_macos_with_binary(): + cap = sandbox.detect_capability( + system=lambda: "Darwin", which=lambda _n: "/usr/bin/sandbox-exec" + ) + assert cap == "seatbelt" + + +def test_detect_capability_bwrap_on_linux_with_binary(): + cap = sandbox.detect_capability(system=lambda: "Linux", which=lambda _n: "/usr/bin/bwrap") + assert cap == "bwrap" + + +def test_detect_capability_none_when_binary_missing(): + cap = sandbox.detect_capability(system=lambda: "Darwin", which=lambda _n: None) + assert cap == "none" + + +def test_detect_capability_none_on_unsupported_platform(): + cap = sandbox.detect_capability(system=lambda: "Windows", which=lambda _n: "anything") + assert cap == "none" + + +# --------------------------------------------------------------------------- +# default_runner tests +# --------------------------------------------------------------------------- + + +def test_default_runner_runs_and_shapes_result(monkeypatch): + import subprocess + + captured: dict[str, object] = {} + + class _Proc: + stdout = "the output" + returncode = 0 + + def fake_run(argv: list[str], **kwargs: object) -> _Proc: + captured["argv"] = argv + captured.update(kwargs) + return _Proc() + + monkeypatch.setattr(subprocess, "run", fake_run) + result = sandbox.default_runner(["echo", "hi"], "/work", 30) + assert result.output == "the output" + assert result.returncode == 0 + assert captured["argv"] == ["echo", "hi"] + assert captured["cwd"] == "/work" + assert captured["timeout"] == 30 + assert captured["check"] is False + assert captured["text"] is True + assert captured["stdout"] == subprocess.PIPE + assert captured["stderr"] == subprocess.STDOUT + + +def test_default_runner_handles_none_stdout(monkeypatch): + import subprocess + + class _Proc: + stdout = None + returncode = 2 + + monkeypatch.setattr(subprocess, "run", lambda argv, **k: _Proc()) + result = sandbox.default_runner(["x"], "/w", 1) + assert result.output == "" and result.returncode == 2 + + +def test_default_runner_timeout_returns_partial_text_output(monkeypatch): + import subprocess + + def fake_run(argv: list[str], **kwargs: object) -> object: + raise subprocess.TimeoutExpired(cmd=argv, timeout=5, output="partial") + + monkeypatch.setattr(subprocess, "run", fake_run) + result = sandbox.default_runner(["sleep", "99"], "/w", 5) + assert "partial" in result.output + assert "timed out after 5s" in result.output + assert result.returncode == 124 # conventional timeout exit code (literal pins the value) + + +def test_default_runner_timeout_decodes_bytes_output(monkeypatch): + import subprocess + + def fake_run(argv: list[str], **kwargs: object) -> object: + raise subprocess.TimeoutExpired(cmd=argv, timeout=1, output=b"raw bytes") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert "raw bytes" in sandbox.default_runner(["x"], "/w", 1).output + + +def test_default_runner_timeout_with_no_output(monkeypatch): + import subprocess + + def fake_run(argv: list[str], **kwargs: object) -> object: + raise subprocess.TimeoutExpired(cmd=argv, timeout=3, output=None) + + monkeypatch.setattr(subprocess, "run", fake_run) + assert "timed out after 3s" in sandbox.default_runner(["x"], "/w", 3).output + + +# --------------------------------------------------------------------------- +# SandboxedShellBackend tests +# --------------------------------------------------------------------------- + + +def _backend( + tmp_path: object, + cap: sandbox.Capability, + runner: sandbox.Runner, +) -> sandbox.SandboxedShellBackend: + return sandbox.SandboxedShellBackend( + root_dir=str(tmp_path), + capability=cap, + runner=runner, + tmp="/tmp", + home="/home/u", + ) + + +def test_execute_seatbelt_wraps_command_in_sandbox_exec(tmp_path): + calls: list[tuple[list[str], str, int]] = [] + + def runner(argv: list[str], cwd: str, timeout: int) -> sandbox._Result: + calls.append((argv, cwd, timeout)) + return sandbox._Result("done", 0) + + backend = _backend(tmp_path, "seatbelt", runner) + resp = backend.execute("pytest -q", timeout=30) + + argv, cwd, timeout = calls[0] + assert argv[0] == "sandbox-exec" and argv[1] == "-p" + assert "(deny default)" in argv[2] # the rendered profile + assert "pytest -q" in argv[-1] # command at the tail (ulimit-wrapped) + assert cwd == str(tmp_path.resolve()) + assert timeout == 30 + assert isinstance(resp, ExecuteResponse) + assert resp.output == "done" and resp.exit_code == 0 + + +def test_execute_bwrap_uses_bwrap_argv(tmp_path): + seen: dict[str, list[str]] = {} + + def runner(argv: list[str], cwd: str, timeout: int) -> sandbox._Result: + seen["argv"] = argv + return sandbox._Result("ok", 0) + + _backend(tmp_path, "bwrap", runner).execute("ls") + assert seen["argv"][0] == "bwrap" + + +def test_execute_capability_none_refuses_and_never_runs(tmp_path): + # Record-and-assert-not-called (no no-cover pragma — that's a gated escape hatch). + calls: list[list[str]] = [] + + def runner(argv: list[str], cwd: str, timeout: int) -> sandbox._Result: + calls.append(argv) + return sandbox._Result("", 0) + + resp = _backend(tmp_path, "none", runner).execute("rm -rf /") + assert resp.output == sandbox.NO_SANDBOX_MESSAGE + assert resp.exit_code is None + assert calls == [] # the killer assertion: refusal must run nothing + + +def test_execute_never_calls_super_execute(tmp_path, monkeypatch): + # The unconfined host shell must never run, even on the happy path. A one-line lambda + # records the call so there's no never-executed function body to leave uncovered. + from deepagents.backends.local_shell import LocalShellBackend + + super_calls: list[str] = [] + monkeypatch.setattr( + LocalShellBackend, + "execute", + lambda self, command, *, timeout=None: super_calls.append(command), + ) + backend = _backend(tmp_path, "seatbelt", lambda a, c, t: sandbox._Result("x", 0)) + assert backend.execute("echo hi").output == "x" + assert super_calls == [] # host shell never invoked + + +def test_execute_runner_failure_returns_apology(tmp_path): + def runner(argv: list[str], cwd: str, timeout: int) -> sandbox._Result: + raise OSError("sandbox-exec missing") + + resp = _backend(tmp_path, "seatbelt", runner).execute("echo hi") + assert resp.output == sandbox.LAUNCH_FAILURE_MESSAGE + assert resp.exit_code is None + + +def test_execute_nonzero_exit_passes_output_and_code_through(tmp_path): + def runner(argv: list[str], cwd: str, timeout: int) -> sandbox._Result: + return sandbox._Result("boom\n", 1) + + resp = _backend(tmp_path, "seatbelt", runner).execute("false") + assert resp.output == "boom\n" and resp.exit_code == 1 + + +def test_execute_clamps_timeout_to_max(tmp_path): + seen: dict[str, int] = {} + + def runner(argv: list[str], cwd: str, timeout: int) -> sandbox._Result: + seen["timeout"] = timeout + return sandbox._Result("", 0) + + _backend(tmp_path, "seatbelt", runner).execute("x", timeout=10_000) + assert seen["timeout"] == sandbox.MAX_TIMEOUT_SECONDS + + +def test_execute_defaults_timeout_when_unset(tmp_path): + seen: dict[str, int] = {} + _backend( + tmp_path, "seatbelt", lambda a, c, t: seen.update(t=t) or sandbox._Result("", 0) + ).execute("x") + assert seen["t"] == sandbox.DEFAULT_TIMEOUT_SECONDS + + +def test_execute_value_error_runner_failure_returns_apology(tmp_path): + # The narrowed except must catch each arm of (OSError, ValueError, SubprocessError) -> apology. + def runner(argv: list[str], cwd: str, timeout: int) -> sandbox._Result: + raise ValueError("bad argv") + + resp = _backend(tmp_path, "seatbelt", runner).execute("echo hi") + assert resp.output == sandbox.LAUNCH_FAILURE_MESSAGE + assert resp.exit_code is None + + +def test_execute_subprocess_error_runner_failure_returns_apology(tmp_path): + import subprocess + + def runner(argv: list[str], cwd: str, timeout: int) -> sandbox._Result: + raise subprocess.SubprocessError("spawn failed") + + resp = _backend(tmp_path, "seatbelt", runner).execute("echo hi") + assert resp.output == sandbox.LAUNCH_FAILURE_MESSAGE + assert resp.exit_code is None + + +def test_backend_defaults_runner_capability_tmp_and_home(tmp_path): + # No runner/capability/tmp/home given: each falls back to its real default. Asserting the + # fallbacks took effect kills the mutants that drop the `or default_runner` / `is not None` arms. + import tempfile + from pathlib import Path + + backend = sandbox.SandboxedShellBackend(root_dir=str(tmp_path)) + + assert backend._runner is sandbox.default_runner + assert backend._capability in ("seatbelt", "bwrap", "none") # the real detector ran + assert backend._tmp == tempfile.gettempdir() + assert backend._home == str(Path("~").expanduser()) + assert backend.virtual_mode is True # defaults to traversal-blocked virtual mode diff --git a/tests/test_agent_cascade_spoken_approval.py b/tests/test_agent_cascade_spoken_approval.py new file mode 100644 index 00000000..d7a57d52 --- /dev/null +++ b/tests/test_agent_cascade_spoken_approval.py @@ -0,0 +1,70 @@ +"""Tests for the spoken-approval grammar (`assembly live --files` hands-free gate).""" + +from __future__ import annotations + +import pytest + +from aai_cli.agent_cascade.spoken_approval import interpret_spoken_approval, spoken_decision + + +@pytest.mark.parametrize( + "transcript", + [ + "approve", + "Approve.", + "yes, run it", + "run it", + "go ahead and run it", + "go ahead", + "do it", + "yeah, go for it", + ], +) +def test_explicit_affirmatives_approve(transcript: str) -> None: + assert interpret_spoken_approval(transcript) is True + + +@pytest.mark.parametrize( + "transcript", + [ + "yes", # bare yes never approves (STT mishears it) + "yeah", + "sure", + "okay", + "no", + "no, don't run it", # negation wins even though it contains "run it" + "stop", + "cancel that", + "do not run it", + "what's the weather", # unrelated utterance + "", # silence / empty final transcript + ], +) +def test_non_affirmatives_reject(transcript: str) -> None: + assert interpret_spoken_approval(transcript) is False + + +def test_spoken_decision_benign_affirmative_approves(): + assert spoken_decision("write_file", {"file_path": "n.txt"}, "yes, run it") is True + + +def test_spoken_decision_benign_bare_yes_rejects(): + # A bare "yes" must not approve even on the voice channel (fail-safe). + assert spoken_decision("write_file", {"file_path": "n.txt"}, "yes") is False + + +def test_spoken_decision_benign_negative_rejects(): + assert spoken_decision("write_file", {"file_path": "n.txt"}, "no") is False + + +def test_spoken_decision_destructive_ignores_voice(): + # A destructive command (risk.risk_warning fires) returns None — the spoken channel is ignored + # even for an explicit "approve", so only the keyboard can green-light it. + assert spoken_decision("execute", {"command": "rm -rf build"}, "approve") is None + assert spoken_decision("execute", {"command": "sudo make install"}, "yes, run it") is None + + +def test_spoken_decision_benign_execute_honors_voice(): + # A benign command (no risk warning) does take the spoken decision. + assert spoken_decision("execute", {"command": "pytest -q"}, "go ahead") is True + assert spoken_decision("execute", {"command": "pytest -q"}, "no") is False diff --git a/tests/test_agent_cascade_subagents.py b/tests/test_agent_cascade_subagents.py new file mode 100644 index 00000000..e517a25e --- /dev/null +++ b/tests/test_agent_cascade_subagents.py @@ -0,0 +1,131 @@ +"""Tests for the general-purpose subagent spec (`assembly live --files` task tool).""" + +from __future__ import annotations + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage + +from aai_cli.agent_cascade import brain +from aai_cli.agent_cascade.config import CascadeConfig +from aai_cli.agent_cascade.subagents import general_purpose_subagent +from tests.test_agent_cascade_brain import FakeChatModel + + +def test_spec_has_required_keys_and_omits_model_and_tools(): + spec = general_purpose_subagent({"write_file": True, "edit_file": True, "execute": True}) + assert spec["name"] == "general-purpose" + assert isinstance(spec["description"], str) and spec["description"] + assert isinstance(spec["system_prompt"], str) and spec["system_prompt"] + # AssemblyAI-only invariant: no provider:model string — must inherit the gateway-bound model. + assert "model" not in spec + # Full-tools path: tools omitted so the subagent inherits the sandboxed main toolset. + assert "tools" not in spec + + +def test_spec_interrupt_on_is_the_passed_mapping(): + # Mirrors the caller's write tools verbatim, so the subagent's mutations also prompt. Passing + # a distinct mapping proves it isn't hardcoded (kills a "return a fixed dict" mutant). + io = {"write_file": True, "edit_file": True, "execute": True} + assert general_purpose_subagent(io)["interrupt_on"] == io + assert general_purpose_subagent({"write_file": True})["interrupt_on"] == {"write_file": True} + + +def test_graph_kwargs_wires_one_gated_gateway_bound_subagent(monkeypatch, tmp_path): + # --files binds exactly one subagent: gateway-bound (no model) with every mutating tool gated. + monkeypatch.chdir(tmp_path) + subs = brain._graph_kwargs(CascadeConfig(files=True))["subagents"] + assert isinstance(subs, list) and len(subs) == 1 + spec = subs[0] + assert spec["name"] == "general-purpose" + assert "model" not in spec # inherits the gateway-bound model + assert spec["interrupt_on"] == {"write_file": True, "edit_file": True, "execute": True} + + +def test_graph_kwargs_off_binds_no_subagents(): + assert "subagents" not in brain._graph_kwargs(CascadeConfig(files=False)) + + +def test_tool_label_task_is_working_on_a_subtask(): + assert brain._tool_label("task") == "Working on a subtask" + + +def _delegating_graph(model: BaseChatModel, root: str): + """A real deepagents graph that binds a gated general-purpose subagent (mirrors the gated + write graph). Inline literals get bidirectional typing; no return annotation so pyright + accepts it as build_streamer's graph (same shape as the gated-write tests).""" + from deepagents import create_deep_agent + from deepagents.backends import FilesystemBackend + from deepagents.middleware.subagents import SubAgent + from langgraph.checkpoint.memory import InMemorySaver + + spec: SubAgent = { + "name": "general-purpose", + "description": "delegate a focused subtask and return a summary", + "system_prompt": "be a focused helper; return a concise summary", + "interrupt_on": {"write_file": True, "edit_file": True}, + } + return create_deep_agent( + model=model, + backend=FilesystemBackend(root_dir=root, virtual_mode=True), + interrupt_on={"write_file": True, "edit_file": True}, + checkpointer=InMemorySaver(), + subagents=[spec], + system_prompt="be a live agent", + ) + + +def _delegate_then_write(reply: str) -> FakeChatModel: + """Scripts main -> task(general-purpose) -> subagent -> write_file -> (resume) replies.""" + task_call = AIMessage( + content="", + tool_calls=[ + { + "name": "task", + "args": {"description": "save a note", "subagent_type": "general-purpose"}, + "id": "t1", + } + ], + ) + write_call = AIMessage( + content="", + tool_calls=[ + {"name": "write_file", "args": {"file_path": "/n.txt", "content": "hi"}, "id": "w1"} + ], + ) + return FakeChatModel( + responses=[ + task_call, + write_call, + AIMessage(content="subtask done"), + AIMessage(content=reply), + ] + ) + + +def test_subagent_write_surfaces_through_the_parent_gate_and_is_approved(tmp_path): + # The DECISIVE M2 invariant (the resolved spike): a subagent's write pauses through OUR parent + # approval loop (build_streamer -> _stream_gated -> _pending_writes -> approver). Approved, it lands. + asked: list[tuple[str, dict]] = [] + graph = _delegating_graph(_delegate_then_write("Saved it via the helper."), str(tmp_path)) + streamer = brain.build_streamer( + "k", + CascadeConfig(files=True), + graph=graph, + approver=lambda name, args: asked.append((name, args)) or True, + ) + + list(streamer([{"role": "user", "content": "have the helper save a note"}])) + + assert any(name == "write_file" for name, _ in asked) # the SUBAGENT's write was gated by us + assert (tmp_path / "n.txt").read_text() == "hi" # approved -> actually written + + +def test_subagent_write_is_declined_when_the_approver_rejects(tmp_path): + graph = _delegating_graph(_delegate_then_write("Okay, left it alone."), str(tmp_path)) + streamer = brain.build_streamer( + "k", CascadeConfig(files=True), graph=graph, approver=lambda name, args: False + ) + + list(streamer([{"role": "user", "content": "have the helper save a note"}])) + + assert not (tmp_path / "n.txt").exists() # declined -> nothing written by the subagent diff --git a/tests/test_agent_cascade_text.py b/tests/test_agent_cascade_text.py index 7817853c..a7164469 100644 --- a/tests/test_agent_cascade_text.py +++ b/tests/test_agent_cascade_text.py @@ -1,8 +1,10 @@ -"""Tests for the cascade's pure text helpers.""" +"""Tests for the cascade's pure text helpers (sentence/clause splitting).""" from __future__ import annotations -from aai_cli.agent_cascade.text import split_sentences, trim_history +import pytest + +from aai_cli.agent_cascade.text import pop_clauses, split_sentences, trim_history def test_split_sentences_breaks_on_terminators(): @@ -57,3 +59,49 @@ def test_trim_history_at_limit_is_untouched(): history = [{"role": "user", "content": str(i)} for i in range(3)] trim_history(history, 3) assert len(history) == 3 + + +def test_pop_clauses_flushes_hard_terminators_and_keeps_tail(): + chunks, remainder = pop_clauses("One. Two! Three", min_chars=1) + assert chunks == ["One.", "Two!"] + assert remainder == " Three" # no terminator yet -> stays buffered + + +def test_pop_clauses_flushes_soft_separator_only_past_min_chars(): + # The clause before the comma is long enough, so the comma ends a clause. + chunks, remainder = pop_clauses("the weather today is, in fact ", min_chars=10) + assert chunks == ["the weather today is,"] + assert remainder == " in fact " + + +def test_pop_clauses_holds_short_soft_clause_to_avoid_choppy_tts(): + # "Yes," is shorter than min_chars, so it is NOT flushed on the comma. + chunks, remainder = pop_clauses("Yes, it is sunny", min_chars=10) + assert chunks == [] + assert remainder == "Yes, it is sunny" + + +def test_pop_clauses_does_not_fragment_a_decimal_or_stacked_terminators(): + # A '.' inside $3.50 (no following space) and stacked '...'/'?!' are not boundaries. + chunks, remainder = pop_clauses("It costs $3.50 total... ", min_chars=1) + assert chunks == ["It costs $3.50 total..."] + assert remainder == " " + + +def test_pop_clauses_returns_nothing_for_an_unterminated_buffer(): + chunks, remainder = pop_clauses("still going", min_chars=1) + assert chunks == [] + assert remainder == "still going" + + +def test_pop_clauses_strips_whitespace_from_each_flushed_clause(): + chunks, _remainder = pop_clauses(" Hi there. Next.", min_chars=1) + assert chunks == ["Hi there.", "Next."] + + +@pytest.mark.parametrize("min_chars", [1, 25]) +def test_pop_clauses_flushes_hard_terminator_regardless_of_min_chars(min_chars): + # min_chars only gates SOFT separators; a sentence terminator always flushes. + chunks, remainder = pop_clauses("Hi. ", min_chars=min_chars) + assert chunks == ["Hi."] + assert remainder == " " diff --git a/tests/test_agent_cascade_weather.py b/tests/test_agent_cascade_weather.py new file mode 100644 index 00000000..155e2620 --- /dev/null +++ b/tests/test_agent_cascade_weather.py @@ -0,0 +1,276 @@ +"""Tests for the keyless Open-Meteo weather tool behind `assembly live`. + +The tool's only network seam is the injected ``fetch`` callable, so the whole +geocode -> forecast -> format flow runs with no sockets (pytest-socket stays armed). +""" + +from __future__ import annotations + +from aai_cli.agent_cascade import weather_tool + +# Canned Open-Meteo payloads keyed by URL prefix, replayed through the fetch seam. +_GEOCODE: dict[str, object] = { + "results": [{"name": "Paris", "latitude": 48.85, "longitude": 2.35, "country": "France"}] +} +_FORECAST: dict[str, object] = { + "current": { + "temperature_2m": 14.3, + "relative_humidity_2m": 82, + "apparent_temperature": 13.1, + "weather_code": 2, + "wind_speed_10m": 11.5, + }, + "daily": { + "time": ["2026-06-22", "2026-06-23", "2026-06-24"], + # Today's high/low (index 0) deliberately differ after rounding from tomorrow's + # (index 1) so an off-by-one index mutant in the Today line can't survive. + "temperature_2m_max": [24.4, 17.0, 19.1], + "temperature_2m_min": [14.0, 9.4, 11.2], + "weather_code": [2, 61, 0], + "precipitation_probability_max": [30, 80, 10], + }, +} + + +def _fake_fetch(geocode=_GEOCODE, forecast=_FORECAST): + """A fetch seam that returns canned geocode/forecast JSON by URL.""" + + def fetch(url: str) -> object: + return geocode if "geocoding-api" in url else forecast + + return fetch + + +# --- describe_weather_code --------------------------------------------------- + + +def test_describe_weather_code_known(): + assert weather_tool.describe_weather_code(0) == "clear sky" + assert weather_tool.describe_weather_code(61) == "light rain" + + +def test_describe_weather_code_unknown_falls_back(): + # An unmapped WMO code must not raise; it returns the generic fallback. + assert weather_tool.describe_weather_code(999) == "unsettled weather" + + +# --- _geocode ---------------------------------------------------------------- + + +def test_geocode_returns_top_match_and_hits_geocoding_host(): + seen = {} + + def fetch(url: str) -> object: + seen["url"] = url + return _GEOCODE + + result = weather_tool._geocode("Paris", fetch=fetch) + assert result == ("Paris", 48.85, 2.35) + assert "geocoding-api.open-meteo.com" in seen["url"] + assert "name=Paris" in seen["url"] + assert "count=1" in seen["url"] + + +def test_geocode_no_results_is_none(): + assert weather_tool._geocode("Nowhereville", fetch=lambda _url: {"results": []}) is None + + +def test_geocode_missing_results_key_is_none(): + assert weather_tool._geocode("x", fetch=lambda _url: {}) is None + + +# --- _forecast --------------------------------------------------------------- + + +def test_forecast_requests_current_and_daily_for_coordinates(): + seen = {} + + def fetch(url: str) -> object: + seen["url"] = url + return _FORECAST + + data = weather_tool._forecast(48.85, 2.35, fetch=fetch) + assert data == _FORECAST + assert "api.open-meteo.com/v1/forecast" in seen["url"] + assert "latitude=48.85" in seen["url"] + assert "longitude=2.35" in seen["url"] + assert "current=temperature_2m" in seen["url"] + assert "daily=temperature_2m_max" in seen["url"] + assert "forecast_days=3" in seen["url"] + # The widened field set: current humidity/feels-like/wind and daily rain chance must + # all be requested, or the report can't speak them. Each substring kills the mutant + # that drops that field from the query. + assert "relative_humidity_2m" in seen["url"] + assert "apparent_temperature" in seen["url"] + assert "wind_speed_10m" in seen["url"] + assert "precipitation_probability_max" in seen["url"] + + +# --- format_report ----------------------------------------------------------- + + +def test_format_report_renders_current_today_and_two_forecast_days(): + report = weather_tool.format_report("Paris", _FORECAST) + # Current line: rounded °C, derived °F, feels-like, condition, humidity, wind. + assert ( + "In Paris it's 14°C (58°F), feels like 13°C, partly cloudy. " + "Humidity 82%, wind 12 km/h." in report + ) + # Today's own high/low + rain chance + condition (the bug fix: today was dropped). + assert "Today 14 to 24°C, 30% chance of rain, partly cloudy." in report + # Two forecast days, labelled, °C lows-to-highs with their own conditions. + assert "Tomorrow 9 to 17°C, light rain." in report + assert "Then 11 to 19°C, clear sky." in report + + +def test_format_report_today_line_omits_rain_chance_when_absent(): + # No precipitation_probability_max in the daily payload: the Today line still renders + # high/low/condition but drops the rain clause rather than speaking "0% chance". + data: dict[str, object] = { + "current": {"temperature_2m": 10.0, "weather_code": 0}, + "daily": { + "temperature_2m_max": [20.0], + "temperature_2m_min": [8.0], + "weather_code": [0], + }, + } + report = weather_tool.format_report("Testville", data) + assert "Today 8 to 20°C, clear sky." in report + assert "chance of rain" not in report + + +def test_format_report_omits_today_line_when_daily_is_empty(): + # An empty daily block must not synthesize a Today line (no IndexError, no empty + # "Today ." fragment) — just the current-conditions sentence survives. + data: dict[str, object] = {"current": {"temperature_2m": 10.0, "weather_code": 0}, "daily": {}} + report = weather_tool.format_report("Testville", data) + assert report.startswith("In Testville it's 10°C") + assert "Today" not in report + + +def test_format_report_omits_today_line_when_one_today_array_is_empty(): + # The high/low arrays are present but the weather_code array is empty: the Today + # guard is an `and` over all three, so a missing one means "no today data" rather + # than indexing an empty list. Kills the `and`->`or` mutation in the guard. + data: dict[str, object] = { + "current": {"temperature_2m": 10.0, "weather_code": 0}, + "daily": { + "temperature_2m_max": [20.0], + "temperature_2m_min": [8.0], + "weather_code": [], + }, + } + report = weather_tool.format_report("Testville", data) + assert "Today" not in report + + +# --- build_weather_tool (end to end via the seam) ---------------------------- + + +def test_tool_name_and_happy_path(): + tool = weather_tool.build_weather_tool(fetch=_fake_fetch()) + assert tool.name == weather_tool.WEATHER_TOOL_NAME == "get_weather" + out = tool.invoke({"location": "Paris"}) + assert "In Paris it's 14°C (58°F), feels like 13°C, partly cloudy." in out + assert "Today 14 to 24°C, 30% chance of rain, partly cloudy." in out + assert "Tomorrow 9 to 17°C, light rain." in out + + +def test_tool_location_not_found_message(): + tool = weather_tool.build_weather_tool(fetch=lambda _url: {"results": []}) + assert tool.invoke({"location": "Nowhereville"}) == ( + "I couldn't find a place called 'Nowhereville'." + ) + + +def test_tool_network_error_is_graceful(): + def boom(_url: str) -> object: + raise RuntimeError("open-meteo down") + + tool = weather_tool.build_weather_tool(fetch=boom) + assert tool.invoke({"location": "Paris"}) == "I couldn't get the weather right now." + + +# --- _forecast_lines length guard ------------------------------------------- + + +def test_format_report_skips_a_day_when_a_daily_array_is_short(): + # weather_code shorter than the temp arrays: the length guard must skip the + # missing days rather than IndexError. Kills the `and`->`or` guard mutation. + data: dict[str, object] = { + "current": {"temperature_2m": 10.0, "weather_code": 0}, + "daily": { + "temperature_2m_max": [12.0, 13.0, 14.0], + "temperature_2m_min": [5.0, 6.0, 7.0], + "weather_code": [0], # only today's code present + }, + } + report = weather_tool.format_report("Testville", data) + assert "In Testville it's 10°C" in report + assert "Tomorrow" not in report + assert "Then" not in report + + +# --- _WMO_DESCRIPTIONS table pin -------------------------------------------- + + +def test_wmo_descriptions_table_is_exact(): + # Pin the whole code->phrase table: a mutated integer key makes the dict differ + # from this literal, failing the test. (The table is only import-time evaluated, + # so the mutation gate reruns the full suite and relies on this test to kill it.) + assert weather_tool._WMO_DESCRIPTIONS == { + 0: "clear sky", + 1: "mainly clear", + 2: "partly cloudy", + 3: "overcast", + 45: "fog", + 48: "freezing fog", + 51: "light drizzle", + 53: "drizzle", + 55: "heavy drizzle", + 61: "light rain", + 63: "rain", + 65: "heavy rain", + 66: "freezing rain", + 67: "heavy freezing rain", + 71: "light snow", + 73: "snow", + 75: "heavy snow", + 77: "snow grains", + 80: "light showers", + 81: "showers", + 82: "heavy showers", + 85: "light snow showers", + 86: "heavy snow showers", + 95: "thunderstorms", + 96: "thunderstorms with hail", + 99: "severe thunderstorms with hail", + } + + +def test_get_json_fetches_and_parses_via_httpx(monkeypatch): + # Exercises the default network seam (httpx GET -> raise_for_status -> json), mocking + # httpx so no socket opens. Asserts the URL/timeout passthrough and that the response is + # status-checked, so the mutation gate can't drop any of those lines silently. + import httpx + + calls: dict[str, object] = {} + + class _Resp: + def raise_for_status(self) -> None: + calls["raised"] = True + + def json(self) -> object: + return {"ok": True} + + def fake_get(url: str, timeout: float) -> _Resp: + calls["url"] = url + calls["timeout"] = timeout + return _Resp() + + monkeypatch.setattr(httpx, "get", fake_get) + + assert weather_tool._get_json("https://example.test/x") == {"ok": True} + assert calls["url"] == "https://example.test/x" + assert calls["timeout"] == weather_tool._TIMEOUT + assert calls["raised"] is True diff --git a/tests/test_agent_cascade_webpage.py b/tests/test_agent_cascade_webpage.py new file mode 100644 index 00000000..92c014fa --- /dev/null +++ b/tests/test_agent_cascade_webpage.py @@ -0,0 +1,87 @@ +"""Tests for the keyless read-a-URL tool behind `assembly live`. + +The tool's only network seam is the injected ``read`` callable, so the whole +fetch -> format flow runs with no sockets (pytest-socket stays armed). +""" + +from __future__ import annotations + +from aai_cli.agent_cascade import webpage_tool +from aai_cli.core.errors import APIError, UsageError +from aai_cli.core.webpage import Article + + +def _article(text: str = "Body text.", title: str | None = "Title") -> Article: + return Article(text=text, title=title, url="https://example.com/post") + + +# --- _format ----------------------------------------------------------------- + + +def test_format_leads_with_title_then_body(): + out = webpage_tool._format(_article(text="Hello world.", title="My Post")) + assert out == "My Post\n\nHello world." + + +def test_format_without_title_is_body_only(): + out = webpage_tool._format(_article(text="Just the body.", title=None)) + assert out == "Just the body." + + +def test_format_truncates_long_body_with_marker(): + long = "x" * (webpage_tool._MAX_CHARS + 50) + out = webpage_tool._format(_article(text=long, title=None)) + assert out == "x" * webpage_tool._MAX_CHARS + "\n…[truncated]" + + +def test_format_keeps_short_body_untruncated(): + out = webpage_tool._format(_article(text="short", title=None)) + assert "[truncated]" not in out + assert out == "short" + + +# --- _read (default seam delegates to core.webpage.fetch_article) ------------ + + +def test_read_delegates_to_fetch_article(monkeypatch): + captured = {} + + def fake_fetch_article(url: str) -> Article: + captured["url"] = url + return _article() + + monkeypatch.setattr("aai_cli.core.webpage.fetch_article", fake_fetch_article) + result = webpage_tool._read("https://example.com/post") + assert captured["url"] == "https://example.com/post" + assert result.title == "Title" + + +# --- build_read_url_tool ----------------------------------------------------- + + +def test_tool_is_named_read_url(): + tool = webpage_tool.build_read_url_tool(read=lambda url: _article()) + assert tool.name == webpage_tool.READ_URL_TOOL_NAME + + +def test_read_url_happy_path_returns_formatted_text(): + tool = webpage_tool.build_read_url_tool(read=lambda url: _article(text="Article.", title="T")) + assert tool.invoke({"url": "https://example.com"}) == "T\n\nArticle." + + +def test_read_url_usage_error_returns_no_readable_text_message(): + def read(url: str) -> Article: + raise UsageError("Couldn't find readable text.") + + tool = webpage_tool.build_read_url_tool(read=read) + assert tool.invoke({"url": "https://example.com"}) == ( + "I couldn't find readable text on that page." + ) + + +def test_read_url_fetch_failure_returns_could_not_read_message(): + def read(url: str) -> Article: + raise APIError("DNS boom") + + tool = webpage_tool.build_read_url_tool(read=read) + assert tool.invoke({"url": "https://example.com"}) == ("I couldn't read that page right now.") diff --git a/tests/test_code_agent.py b/tests/test_code_agent.py deleted file mode 100644 index 2ac9d2a0..00000000 --- a/tests/test_code_agent.py +++ /dev/null @@ -1,409 +0,0 @@ -"""End-to-end tests for the `assembly code` coding agent. - -A fake chat model drives the *real* deepagents graph offline (pytest-socket stays -armed), so the filesystem/shell tools, approval interrupt/resume, event rendering, and -REPL loop are all exercised without a network or a TTY. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage -from langchain_core.outputs import ChatGeneration, ChatResult - -from aai_cli.code_agent import ( - ask_tool, - cli_tool, - docs_mcp, - events, - fetch_tool, - firecrawl_search, - memory, - skills, - store, -) -from aai_cli.code_agent.agent import MUTATING_TOOLS, build_agent -from aai_cli.code_agent.events import AssistantText, ErrorText, ToolCall, ToolResult -from aai_cli.code_agent.prompt import build_system_prompt -from aai_cli.code_agent.render import RichRenderer -from aai_cli.code_agent.session import QUIT_COMMANDS, CodeSession, run_repl - - -class FakeChatModel(BaseChatModel): - """A tool-calling chat model that replays a scripted list of AIMessages.""" - - responses: list[AIMessage] - index: int = 0 - - @property - def _llm_type(self) -> str: - return "fake-code-model" - - def bind_tools(self, tools, **kwargs): - del tools, kwargs - return self - - def _generate(self, messages, stop=None, run_manager=None, **kwargs): - del messages, stop, run_manager, kwargs - message = self.responses[self.index] - self.index += 1 - return ChatResult(generations=[ChatGeneration(message=message)]) - - -def _write_call(path: str, content: str) -> AIMessage: - return AIMessage( - content="", - tool_calls=[ - {"name": "write_file", "args": {"file_path": path, "content": content}, "id": "c1"} - ], - ) - - -def _session( - model: BaseChatModel, work: Path, *, approver, auto_approve=False -) -> tuple[CodeSession, list[object]]: - sink_events: list[object] = [] - agent = build_agent(model=model, root_dir=work, auto_approve=auto_approve) - session = CodeSession( - agent=agent, sink=sink_events.append, approver=approver, auto_approve=auto_approve - ) - return session, sink_events - - -def test_approved_write_creates_file_and_emits_events(tmp_path: Path) -> None: - model = FakeChatModel( - responses=[_write_call("hello.txt", "hi there"), AIMessage(content="Done.")] - ) - session, sink = _session(model, tmp_path, approver=lambda name, args: True) - - session.send("create hello.txt") - - assert (tmp_path / "hello.txt").read_text() == "hi there" - assert any(isinstance(e, ToolResult) for e in sink) - assert any(isinstance(e, AssistantText) and "Done." in e.text for e in sink) - - -def test_rejected_write_does_not_create_file(tmp_path: Path) -> None: - model = FakeChatModel(responses=[_write_call("no.txt", "x"), AIMessage(content="Skipped.")]) - seen: list[str] = [] - - def reject(name: str, args: dict[str, object]) -> bool: - seen.append(name) - return False - - session, _ = _session(model, tmp_path, approver=reject) - - session.send("create no.txt") - - assert not (tmp_path / "no.txt").exists() - assert seen == ["write_file"] # the approver was consulted for the gated tool - - -def test_auto_approve_runs_without_approver_and_announces_calls(tmp_path: Path) -> None: - model = FakeChatModel(responses=[_write_call("auto.txt", "data"), AIMessage(content="ok")]) - - def deny(name, args): # the approver must never be called under --auto - raise AssertionError("approver called under auto_approve") - - session, sink = _session(model, tmp_path, approver=deny, auto_approve=True) - session.send("go") - - assert (tmp_path / "auto.txt").read_text() == "data" - assert any(isinstance(e, ToolCall) and e.name == "write_file" for e in sink) - - -def test_run_repl_sends_initial_then_lines_until_quit(tmp_path: Path) -> None: - model = FakeChatModel(responses=[AIMessage(content="a"), AIMessage(content="b")]) - session, sink = _session(model, tmp_path, approver=lambda name, args: True) - lines = iter(["", "second", "/quit", "never"]) - run_repl(session, read_line=lambda: next(lines), initial="first") - - texts = [e.text for e in sink if isinstance(e, AssistantText)] - assert texts == ["a", "b"] # initial + "second"; blank skipped, stops at /quit - - -def test_system_prompt_steers_concise_speech() -> None: - prompt = build_system_prompt("/work") - assert "/work" in prompt # anchored to the working directory - # The prose is read aloud, so the prompt must steer the model to concise, speech-ready - # replies with code kept out of the spoken text. - assert "read aloud" in prompt - assert "fenced code blocks" in prompt - lowered = prompt.lower() - assert "concise" in lowered and "spoken" in lowered - - -def test_mutating_tools_include_cli_shell_and_fetch() -> None: - assert set(MUTATING_TOOLS) == {"write_file", "edit_file", "execute", "assembly", "fetch_url"} - assert "exit" in QUIT_COMMANDS and "/exit" in QUIT_COMMANDS - - -def test_fetch_tool_invokes_fetcher() -> None: - tool = fetch_tool.build_fetch_tool(lambda url: f"body of {url}") - assert tool.name == "fetch_url" - assert tool.invoke({"url": "https://x.test"}) == "body of https://x.test" - - -def test_ask_tool_uses_bridge_handler() -> None: - bridge = ask_tool.AskBridge() - assert "no user" in bridge.ask("q?").lower() # default before a front-end attaches - bridge.handler = lambda question: f"answer to {question}" - tool = ask_tool.build_ask_tool(bridge) - assert tool.invoke({"question": "deploy now?"}) == "answer to deploy now?" - - -def test_memory_middleware_creates_dir(tmp_path: Path) -> None: - root = tmp_path / "mem" - middleware = memory.build_memory_middleware(root) - assert root.is_dir() - assert middleware is not None - - -def test_checkpointer_in_memory_vs_sqlite(tmp_path, monkeypatch): # untyped: touches saver.conn - from langgraph.checkpoint.memory import InMemorySaver - - assert isinstance(store.build_checkpointer(persist=False), InMemorySaver) - - monkeypatch.setattr(store, "sessions_db_path", lambda: tmp_path / "s.sqlite") - saver = store.build_checkpointer(persist=True) - assert not isinstance(saver, InMemorySaver) # a SQLite-backed saver instead - # Close the underlying connection so it isn't GC'd mid-suite — an unclosed - # sqlite3.Connection raises PytestUnraisableExceptionWarning on py3.13/Windows, - # which `filterwarnings=error` turns into a failure in an unrelated later test. - saver.conn.close() - - -def test_new_session_id_is_unique_and_short() -> None: - a = store.new_session_id() - b = store.new_session_id() - assert a != b # each run gets its own thread id (no silent resume of a shared default) - assert len(a) == 12 and a.isalnum() # short hex, readable off the splash to resume later - - -def test_cli_tool_invokes_runner_with_args() -> None: - captured: list[list[str]] = [] - - def runner(args: list[str]) -> str: - captured.append(args) - return "ran" - - tool = cli_tool.build_cli_tool(runner) - out = tool.invoke({"arguments": ["transcribe", "a.mp3"]}) - assert out == "ran" - assert captured == [["transcribe", "a.mp3"]] - - -def test_run_assembly_passes_key_via_env_not_argv(monkeypatch: pytest.MonkeyPatch) -> None: - import subprocess - - cmd_seen: list[str] = [] - env_seen: dict[str, str] = {} - - def fake_run(cmd, **kwargs): - cmd_seen.extend(cmd) - env_seen.update(kwargs["env"]) - return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") - - monkeypatch.setattr("aai_cli.code_agent.cli_tool.subprocess.run", fake_run) - result = cli_tool.run_assembly(["transcripts", "list"], api_key="secret-key") - - assert "secret-key" not in " ".join(cmd_seen) # never on argv - assert env_seen["ASSEMBLYAI_API_KEY"] == "secret-key" # passed via env - assert "exit code: 0" in result and "ok" in result - - -def test_docs_mcp_load_failure_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None: - def boom(url): - raise RuntimeError("blocked host") - - # Replace the coroutine factory with a sync raiser so no un-awaited coroutine is - # created; load_docs_tools must swallow the failure and report no docs tools. - monkeypatch.setattr(docs_mcp, "_fetch", boom) - assert docs_mcp.load_docs_tools("https://example.invalid") == [] - - -def test_build_skills_present_and_absent(tmp_path: Path) -> None: - assert skills.build_skills(tmp_path) is None # empty dir -> no skills, no tool - - skill_dir = tmp_path / "assemblyai" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("---\nname: assemblyai\ndescription: x\n---\nbody") - bundle = skills.build_skills(tmp_path) - assert bundle is not None # constructing the middleware also validates the custom prompt - _middleware, reader = bundle - assert reader.name == skills.READ_SKILL_TOOL_NAME - - -def test_read_skill_tool_reads_under_root_and_blocks_escape(tmp_path: Path) -> None: - skill_dir = tmp_path / "assemblyai" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("the skill body") - (tmp_path.parent / "secret.md").write_text("top secret") - - reader = skills.build_skill_reader(tmp_path) - # The path is the prompt's backend-virtual form (leading slash, relative to root). - assert reader.invoke({"path": "/assemblyai/SKILL.md"}) == "the skill body" - # A traversal out of the skills dir is refused (not the neighbouring file's contents). - escaped = reader.invoke({"path": "/../secret.md"}) - assert "outside the skills directory" in escaped and "top secret" not in escaped - # A missing skill file reports an error rather than raising. - assert "not found" in reader.invoke({"path": "/assemblyai/MISSING.md"}) - - -def test_web_search_tool_gated_on_api_key(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) - assert firecrawl_search.build_web_search_tool() is None - - monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-key") - tool = firecrawl_search.build_web_search_tool() - assert tool is not None and tool.name == "firecrawl_search" - - -def test_message_events_coerces_list_content() -> None: - msg = AIMessage(content=[{"type": "text", "text": "foo"}, {"type": "text", "text": "bar"}]) - out = events.message_events(msg, announce_calls=False) - assert out == [AssistantText("foobar")] - - -def test_rich_renderer_smoke(capsys: pytest.CaptureFixture[str]) -> None: - renderer = RichRenderer() - renderer(AssistantText("hi")) - renderer(ToolCall(name="write_file", args={"file_path": "a"})) - renderer(ToolResult(name="write_file", content="Updated a")) - out = capsys.readouterr().out - assert "hi" in out and "write_file" in out - - -# --- slice-unit edge cases (cover the lazy bodies + error/guard branches) ----- - - -def test_fetch_url_fetches_and_truncates(monkeypatch: pytest.MonkeyPatch) -> None: - import httpx - - class Resp: - def __init__(self, text: str) -> None: - self.text = text - - def raise_for_status(self) -> None: - return None - - monkeypatch.setattr(httpx, "get", lambda url, **kw: Resp("body")) - assert fetch_tool.fetch_url("https://x.test") == "body" - - big = "y" * (fetch_tool._MAX_CHARS + 10) - monkeypatch.setattr(httpx, "get", lambda url, **kw: Resp(big)) - out = fetch_tool.fetch_url("https://x.test") - assert out.endswith("…[truncated]") and len(out) < len(big) + 20 - - -def test_load_docs_tools_success(monkeypatch): # untyped: tools list compares to str sentinels - class FakeClient: - def __init__(self, connections): - self.connections = connections - - async def get_tools(self): - return ["docs-tool"] - - monkeypatch.setattr("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient) - assert docs_mcp.load_docs_tools("https://docs.test") == ["docs-tool"] - - -def test_config_root_helpers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/tmp/cfg") - assert memory.memory_root() == Path("/tmp/cfg/code-memory") - assert skills.skills_root() == Path("/tmp/cfg/skills") - monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) - assert memory.memory_root() == Path.home() / ".claude" / "code-memory" - assert skills.skills_root() == Path.home() / ".claude" / "skills" - - monkeypatch.setattr("platformdirs.user_data_dir", lambda app: str(tmp_path)) - db = store.sessions_db_path() - assert db == tmp_path / "code-sessions" / "sessions.sqlite" - assert db.parent.is_dir() - - -def test_event_helpers_fallbacks() -> None: - assert events._text_of(123) == "123" # neither str nor list - assert events.new_messages({}, 0) == [] # no "messages" key - assert events.interrupt_request({}) is None - - -def test_session_surfaces_turn_failure_as_error_event() -> None: - class Boom: - def invoke(self, *a, **k): - raise RuntimeError("gateway 500") - - seen: list[object] = [] - session = CodeSession(agent=Boom(), sink=seen.append, approver=lambda n, a: True) - session.send("go") - assert any(isinstance(e, ErrorText) and "gateway 500" in e.text for e in seen) - - -def test_session_propagates_keyboard_interrupt() -> None: - class Stop: - def invoke(self, *a, **k): - raise KeyboardInterrupt - - session = CodeSession(agent=Stop(), sink=lambda e: None, approver=lambda n, a: True) - with pytest.raises(KeyboardInterrupt): - session.send("go") - - -def test_decide_coerces_non_dict_args() -> None: - seen: dict[str, object] = {} - - class Dummy: - def invoke(self, *a, **k): - return {"messages": []} - - session = CodeSession( - agent=Dummy(), sink=lambda e: None, approver=lambda n, a: seen.update(a=a) or True - ) - decision = session._decide({"name": "t", "args": "not-a-dict"}) - assert decision == {"type": "approve"} and seen["a"] == {} - - -def test_run_repl_stops_on_eof() -> None: - class Dummy: - def invoke(self, *a, **k): - return {"messages": []} - - session = CodeSession(agent=Dummy(), sink=lambda e: None, approver=lambda n, a: True) - run_repl(session, read_line=lambda: None) # immediate EOF -> returns without error - - -def test_rich_renderer_renders_error(capsys: pytest.CaptureFixture[str]) -> None: - RichRenderer()(ErrorText("boom happened")) - assert "boom happened" in capsys.readouterr().err - - -def test_cli_tool_truncates_and_includes_stderr() -> None: - import subprocess - - long = "z" * (cli_tool._MAX_OUTPUT_CHARS + 50) - assert cli_tool._truncate(long).endswith("…[output truncated]") - proc = subprocess.CompletedProcess(["x"], 1, stdout="out", stderr="boom") - rendered = cli_tool._format_result(proc) - assert "exit code: 1" in rendered and "stderr:\nboom" in rendered - - -def test_rich_renderer_notice(capsys: pytest.CaptureFixture[str]) -> None: - RichRenderer().notice("heads up") - assert "heads up" in capsys.readouterr().err - - -def test_rich_renderer_escapes_markup(capsys: pytest.CaptureFixture[str]) -> None: - renderer = RichRenderer() - renderer(AssistantText("[bold]x[/bold]")) - renderer(ToolCall(name="t", args={"a": "[red]"})) - renderer(ToolResult(name="t", content="[u]z[/u]")) - renderer(ErrorText("[i]e[/i]")) - captured = capsys.readouterr() - combined = captured.out + captured.err - # Without escaping, Rich would consume these as style tags (and strip the brackets); - # escaped, the literal brackets survive in the output. - assert "[bold]" in combined and "[red]" in combined - assert "[u]" in combined and "[i]" in combined diff --git a/tests/test_code_command.py b/tests/test_code_command.py deleted file mode 100644 index ab2c6051..00000000 --- a/tests/test_code_command.py +++ /dev/null @@ -1,358 +0,0 @@ -"""Tests for the `assembly code` command wiring (commands/code/* + _exec). - -The functions here are intentionally unannotated: they drive the command through -lightweight fakes (SimpleNamespace state, string agent sentinels) that the strict -type-checker would otherwise reject — the test suite skips untyped bodies by design. -""" - -from __future__ import annotations - -import builtins -import dataclasses -from pathlib import Path -from types import SimpleNamespace - -import pytest -from typer.testing import CliRunner - -from aai_cli.code_agent.ask_tool import AskBridge -from aai_cli.commands.code import _exec -from aai_cli.core.errors import CLIError -from aai_cli.main import app - -runner = CliRunner() - -_DEFAULTS = _exec.CodeOptions(prompt=None) - - -def _opts(**over) -> _exec.CodeOptions: - return dataclasses.replace(_DEFAULTS, **over) - - -def test_command_parses_flags_into_options(monkeypatch): - captured = {} - monkeypatch.setattr( - _exec, "run_code", lambda opts, state, *, json_mode: captured.update(o=opts) - ) - result = runner.invoke( - app, ["code", "build a thing", "--auto", "--no-web", "--session", "s1", "--fresh"] - ) - assert result.exit_code == 0 - opts = captured["o"] - assert opts.prompt == "build a thing" - assert opts.auto is True and opts.web is False - assert opts.session == "s1" and opts.persist is False # an explicit --session is honored - - -def test_command_defaults_to_a_fresh_unique_session_each_run(monkeypatch): - # No --session: each invocation gets its own id (so a run never silently resumes the - # previous conversation), and two runs differ. - seen = [] - monkeypatch.setattr( - _exec, "run_code", lambda opts, state, *, json_mode: seen.append(opts.session) - ) - assert runner.invoke(app, ["code"]).exit_code == 0 - assert runner.invoke(app, ["code"]).exit_code == 0 - assert seen[0] != "default" # not the old shared, auto-resumed thread - assert seen[0] and seen[1] and seen[0] != seen[1] # a distinct id per run - - -def test_run_code_dispatches_to_tui_with_voice_by_default_when_tty(monkeypatch): - # The default (voice + tui in a TTY) now routes voice *into* the TUI: spoken turns are - # entered into the prompt there, rather than running the separate voice REPL. - calls = {} - monkeypatch.setattr(_exec, "_build_agent", lambda key, opts, bridge: "AGENT") - monkeypatch.setattr(_exec, "build_voice_session", lambda key: f"VOICE:{key}") - monkeypatch.setattr( - _exec, "_run_tui", lambda agent, opts, bridge, *, voice: calls.update(tui=(agent, voice)) - ) - monkeypatch.setattr(_exec, "_run_voice", lambda *a: calls.update(voice=True)) - monkeypatch.setattr(_exec, "_run_repl", lambda *a: calls.update(repl=True)) - monkeypatch.setattr("aai_cli.core.stdio.stdout_is_tty", lambda: True) - monkeypatch.setattr("aai_cli.core.stdio.stdin_is_tty", lambda: True) - state = SimpleNamespace(resolve_api_key=lambda: "k") - - _exec.run_code(_opts(), state, json_mode=False) - assert calls == {"tui": ("AGENT", "VOICE:k")} # voice session handed to the TUI - - -def test_run_code_uses_voice_repl_when_tui_off(monkeypatch): - # --no-tui keeps the plain voice REPL (speak, hear the reply) instead of the TUI. - calls = {} - monkeypatch.setattr(_exec, "_build_agent", lambda key, opts, bridge: "AGENT") - monkeypatch.setattr( - _exec, "_run_voice", lambda agent, opts, bridge, key: calls.update(voice=(agent, key)) - ) - monkeypatch.setattr(_exec, "_run_tui", lambda *a, **k: calls.update(tui=True)) - monkeypatch.setattr(_exec, "_run_repl", lambda *a: calls.update(repl=True)) - monkeypatch.setattr("aai_cli.core.stdio.stdout_is_tty", lambda: True) - monkeypatch.setattr("aai_cli.core.stdio.stdin_is_tty", lambda: True) - state = SimpleNamespace(resolve_api_key=lambda: "k") - - _exec.run_code(_opts(tui=False), state, json_mode=False) - assert calls == {"voice": ("AGENT", "k")} - - -def test_run_code_dispatches_to_tui_when_voice_off(monkeypatch): - calls = {} - monkeypatch.setattr(_exec, "_build_agent", lambda key, opts, bridge: "AGENT") - monkeypatch.setattr(_exec, "_run_voice", lambda *a: calls.update(voice=True)) - monkeypatch.setattr(_exec, "_run_tui", lambda agent, opts, bridge: calls.update(tui=agent)) - monkeypatch.setattr(_exec, "_run_repl", lambda *a: calls.update(repl=True)) - monkeypatch.setattr("aai_cli.core.stdio.stdout_is_tty", lambda: True) - monkeypatch.setattr("aai_cli.core.stdio.stdin_is_tty", lambda: True) - state = SimpleNamespace(resolve_api_key=lambda: "k") - - _exec.run_code(_opts(voice=False), state, json_mode=False) - assert calls == {"tui": "AGENT"} - - -def test_run_code_repl_when_voice_and_tui_off(monkeypatch): - calls = {} - monkeypatch.setattr(_exec, "_build_agent", lambda key, opts, bridge: "AGENT") - monkeypatch.setattr(_exec, "_run_voice", lambda *a: calls.update(voice=True)) - monkeypatch.setattr(_exec, "_run_tui", lambda *a: calls.update(tui=True)) - monkeypatch.setattr(_exec, "_run_repl", lambda agent, opts, bridge: calls.update(repl=agent)) - monkeypatch.setattr("aai_cli.core.stdio.stdout_is_tty", lambda: True) - monkeypatch.setattr("aai_cli.core.stdio.stdin_is_tty", lambda: True) - state = SimpleNamespace(resolve_api_key=lambda: "k") - - _exec.run_code(_opts(voice=False, tui=False), state, json_mode=False) - assert calls == {"repl": "AGENT"} - - -def test_run_code_falls_back_to_repl_off_tty(monkeypatch): - calls = {} - monkeypatch.setattr(_exec, "_build_agent", lambda key, opts, bridge: "AGENT") - monkeypatch.setattr(_exec, "_run_tui", lambda *a: calls.update(tui=True)) - monkeypatch.setattr(_exec, "_run_repl", lambda agent, opts, bridge: calls.update(repl=agent)) - monkeypatch.setattr("aai_cli.core.stdio.stdout_is_tty", lambda: False) - monkeypatch.setattr("aai_cli.core.stdio.stdin_is_tty", lambda: True) - state = SimpleNamespace(resolve_api_key=lambda: "k") - - _exec.run_code(_opts(), state, json_mode=False) - assert calls == {"repl": "AGENT"} - - -def test_run_code_maps_keyboard_interrupt_to_exit_130(monkeypatch): - import typer - - from aai_cli.core import errors - - monkeypatch.setattr(_exec, "_build_agent", lambda key, opts, bridge: "AGENT") - monkeypatch.setattr("aai_cli.core.stdio.stdout_is_tty", lambda: True) - monkeypatch.setattr("aai_cli.core.stdio.stdin_is_tty", lambda: True) - - def boom(*a, **k): - raise KeyboardInterrupt - - monkeypatch.setattr(_exec, "build_voice_session", lambda key: "VOICE") - monkeypatch.setattr(_exec, "_run_tui", boom) # the default front-end in a TTY - state = SimpleNamespace(resolve_api_key=lambda: "k") - - with pytest.raises(typer.Exit) as exc: - _exec.run_code(_opts(), state, json_mode=False) - assert exc.value.exit_code == errors.CANCELLED_EXIT_CODE - - -def test_assemble_tools_includes_cli_fetch_ask_and_optional_extras(monkeypatch): - monkeypatch.setattr(_exec, "load_docs_tools", lambda: ["docs"]) - monkeypatch.setattr(_exec, "build_web_search_tool", lambda: "search") - tools = _exec._assemble_tools("k", _opts(docs=True, web=True), AskBridge()) - assert [getattr(t, "name", t) for t in tools[:3]] == ["assembly", "fetch_url", "ask_user"] - assert "docs" in tools and "search" in tools - - monkeypatch.setattr(_exec, "build_web_search_tool", lambda: None) - tools = _exec._assemble_tools("k", _opts(docs=False, web=True), AskBridge()) - assert [t.name for t in tools] == ["assembly", "fetch_url", "ask_user"] - - -def test_assemble_middlewares_memory_only(monkeypatch): - # Skills are wired in _build_agent now (they pair a middleware with a tool); this - # assembler only handles the optional memory middleware. - monkeypatch.setattr(_exec, "build_memory_middleware", lambda: "MEM") - assert _exec._assemble_middlewares(_opts(memory=True)) == ["MEM"] - assert _exec._assemble_middlewares(_opts(memory=False)) == [] - - -def test_build_agent_wires_model_tools_and_checkpointer(monkeypatch): - seen = {} - monkeypatch.setattr(_exec, "build_model", lambda key, *, model: f"model:{model}") - monkeypatch.setattr(_exec, "_assemble_tools", lambda key, opts, bridge: ["t"]) - monkeypatch.setattr(_exec, "_assemble_middlewares", lambda opts: ["m"]) - # --no-skills: build_skills must not be consulted, so the sentinel never lands. - monkeypatch.setattr(_exec, "build_skills", lambda: ("X_MW", "X_TOOL")) - monkeypatch.setattr(_exec, "build_checkpointer", lambda *, persist: f"ckpt:{persist}") - monkeypatch.setattr(_exec, "build_agent", lambda **kw: seen.update(kw) or "AGENT") - - agent = _exec._build_agent("k", _opts(model="gpt-5", persist=False, skills=False), AskBridge()) - assert agent == "AGENT" - assert seen["model"] == "model:gpt-5" - assert seen["tools"] == ["t"] and seen["middlewares"] == ["m"] # no skills sentinel added - assert seen["checkpointer"] == "ckpt:False" - - -def test_build_agent_inserts_skills_middleware_and_read_tool(monkeypatch): - seen = {} - monkeypatch.setattr(_exec, "build_model", lambda key, *, model: "model") - monkeypatch.setattr(_exec, "_assemble_tools", lambda key, opts, bridge: ["base"]) - monkeypatch.setattr(_exec, "_assemble_middlewares", lambda opts: ["mem"]) - monkeypatch.setattr(_exec, "build_skills", lambda: ("skills_mw", "read_skill_tool")) - monkeypatch.setattr(_exec, "build_checkpointer", lambda *, persist: "ckpt") - monkeypatch.setattr(_exec, "build_agent", lambda **kw: seen.update(kw) or "AGENT") - - _exec._build_agent("k", _opts(skills=True), AskBridge()) - assert seen["middlewares"] == ["skills_mw", "mem"] # skills loaded ahead of memory - assert seen["tools"] == ["base", "read_skill_tool"] # read_skill tool appended - - -def test_web_note_only_without_key(monkeypatch): - monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) - assert _exec._web_note(_opts(web=True)) is not None - assert _exec._web_note(_opts(web=False)) is None - monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-x") - assert _exec._web_note(_opts(web=True)) is None - - -def test_confirm_reads_yes_no(monkeypatch): - monkeypatch.setattr(builtins, "input", lambda *a: "y") - assert _exec._confirm("write_file", {"file_path": "a"}) is True - monkeypatch.setattr(builtins, "input", lambda *a: "n") - assert _exec._confirm("write_file", {}) is False - - def eof(*a): - raise EOFError - - monkeypatch.setattr(builtins, "input", eof) - assert _exec._confirm("write_file", {}) is False - - -def test_ask_repl_and_read_line(monkeypatch): - monkeypatch.setattr(builtins, "input", lambda *a: "the answer") - assert _exec._ask_repl("q?") == "the answer" - assert _exec._read_line() == "the answer" - - def eof(*a): - raise EOFError - - monkeypatch.setattr(builtins, "input", eof) - assert _exec._ask_repl("q?") == "" - assert _exec._read_line() is None - - -def test_run_repl_prints_banner_and_runs(monkeypatch): - class Dummy: - def invoke(self, *a, **k): - return {"messages": []} - - def eof(*a): - raise EOFError - - monkeypatch.setattr(builtins, "input", eof) # immediate EOF ends the loop - bridge = AskBridge() - _exec._run_repl(Dummy(), _opts(session="s2"), bridge) - assert bridge.handler is _exec._ask_repl # the REPL wired the ask handler - - -def test_run_tui_invokes_app_run(monkeypatch): - seen = {} - - class FakeApp: - def __init__(self, **kw): - seen.update(kw) - - def run(self, **kw): - seen["run_kw"] = kw - - monkeypatch.setattr("aai_cli.code_agent.tui.CodeAgentApp", FakeApp) - _exec._run_tui("AGENT", _opts(prompt="hi", session="s", root_dir=Path()), AskBridge()) - assert seen["agent"] == "AGENT" and seen["thread_id"] == "s" - assert seen["run_kw"] == {"mouse": False} - - -def test_voice_sink_renders_all_events_and_speaks_only_assistant_text(): - from aai_cli.code_agent.events import AssistantText, ToolCall - - rendered, spoken = [], [] - voice = SimpleNamespace(speak=spoken.append) - - def renderer(event): - rendered.append(event) - - sink = _exec._voice_sink(renderer, voice) - sink(AssistantText("here you go")) - sink(ToolCall(name="write_file", args={})) - - assert [type(e).__name__ for e in rendered] == ["AssistantText", "ToolCall"] - assert spoken == ["here you go"] # only the assistant's prose is read back - - -def test_announce_voice_message_depends_on_readback(): - notes = [] - renderer = SimpleNamespace(notice=notes.append) - - _exec._announce_voice(renderer, SimpleNamespace(readback=True)) - assert "read back" in notes[-1] - - _exec._announce_voice(renderer, SimpleNamespace(readback=False)) - assert "sandbox" in notes[-1] and "text" in notes[-1] - - -def test_voice_read_line_returns_spoken_line(): - notes = [] - renderer = SimpleNamespace(notice=notes.append) - voice = SimpleNamespace(listen=lambda: "add a flag") - - read_line = _exec._voice_read_line(voice, renderer) - assert read_line() == "add a flag" - assert any("Heard: add a flag" in n for n in notes) - - -def test_voice_read_line_passes_through_none_for_eof(): - renderer = SimpleNamespace(notice=lambda *a: None) - voice = SimpleNamespace(listen=lambda: None) - assert _exec._voice_read_line(voice, renderer)() is None - - -def test_voice_read_line_falls_back_to_typed_input_when_no_mic(monkeypatch): - notes = [] - renderer = SimpleNamespace(notice=notes.append) - calls = {"listen": 0} - - def flaky_mic(): - calls["listen"] += 1 - if calls["listen"] == 1: - raise CLIError("no device", error_type="mic_missing", exit_code=2) - return "SPOKEN AGAIN" # would leak through only if the mic were retried - - voice = SimpleNamespace(listen=flaky_mic) - monkeypatch.setattr(builtins, "input", lambda *a: "typed instead") - - read_line = _exec._voice_read_line(voice, renderer) - assert read_line() == "typed instead" # first call: mic fails -> typed input - assert read_line() == "typed instead" # stays typed; the mic is not retried - assert calls["listen"] == 1 # the latch flipped, so listen() was attempted only once - assert any("switching to typed input" in n.lower() for n in notes) - - -def test_voice_read_line_reraises_non_audio_errors(): - renderer = SimpleNamespace(notice=lambda *a: None) - - def boom(): - raise CLIError("gateway down", error_type="api_error", exit_code=1) - - voice = SimpleNamespace(listen=boom) - with pytest.raises(CLIError): - _exec._voice_read_line(voice, renderer)() - - -def test_run_voice_wires_ask_handler_and_drives_repl(monkeypatch): - class Dummy: - def invoke(self, *a, **k): - return {"messages": []} - - voice = SimpleNamespace(readback=False, listen=lambda: None, speak=lambda *a: None) - monkeypatch.setattr(_exec, "build_voice_session", lambda key: voice) - bridge = AskBridge() - _exec._run_voice(Dummy(), _opts(session="s3"), bridge, "k") - assert bridge.handler is _exec._ask_repl diff --git a/tests/test_code_messages.py b/tests/test_code_messages.py deleted file mode 100644 index 99e7fe58..00000000 --- a/tests/test_code_messages.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Tests for the mounted-widget transcript of the `assembly code` TUI. - -Drives the real Textual app (headless) and asserts on the mounted message widgets: the reply -streams into one AssistantMessage in place and renders as Markdown, and a long tool result is -a collapsible ToolOutput (Ctrl-O / click). Split from test_code_tui.py to stay under the -file-length gate. -""" - -from __future__ import annotations - -import asyncio - -from aai_cli.code_agent.events import AssistantDelta, AssistantText, ToolResult -from aai_cli.code_agent.messages import AssistantMessage, ToolOutput, UserMessage -from aai_cli.code_agent.tui import CodeAgentApp - - -class FakeAgent: - """Replays scripted invoke() results so a turn can complete without a model.""" - - def __init__(self, results: list[dict[str, object]]) -> None: - self._results = results - self.calls = 0 - - def invoke(self, *args, **kwargs): - result = self._results[self.calls] - self.calls += 1 - return result - - -def _run(coro) -> None: - asyncio.run(coro) - - -def test_assistant_reply_renders_as_markdown_widget() -> None: - # The reply mounts an AssistantMessage rendered as Markdown — the fence markers are - # consumed and the code shows; the raw text is kept for clipboard copy. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - reply = "Here you go:\n\n```python\nprint('hi')\n```" - app._write_event(AssistantText(reply)) - await pilot.pause() - msg = app.query_one(AssistantMessage) - text = "\n".join(msg.render_line(y).text for y in range(msg.size.height)) - assert "```" not in text # markdown consumed the fence markers - assert "print('hi')" in text # the code itself renders - assert app._last_reply == reply # raw markdown kept for clipboard copy - - _run(go()) - - -def test_assistant_deltas_stream_in_place_then_finalize() -> None: - # Tokens stream into a single AssistantMessage in place (no separate region); the final - # AssistantText finalizes that same widget rather than mounting a second one. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._write_event(AssistantDelta("Hello, ")) - app._write_event(AssistantDelta("world!")) - await pilot.pause() - assert len(app.query(AssistantMessage)) == 1 # one widget, updated in place - assert app.query_one(AssistantMessage).text == "Hello, world!" - streaming = app._streaming_msg # local: asserting on the attr would poison the - assert streaming is not None # later `is None` check (mypy can't see the reset) - app._write_event(AssistantText("Hello, world!")) - await pilot.pause() - assert app._streaming_msg is None # finalized - assert app._last_reply == "Hello, world!" - assert len(app.query(AssistantMessage)) == 1 # finalized in place, not a 2nd widget - - _run(go()) - - -def test_finish_turn_finalizes_a_dangling_streamed_reply() -> None: - # A turn cancelled mid-generation leaves a streamed-but-unfinalized reply; finishing the - # turn commits what streamed in (so it isn't lost) and clears the streaming reference. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._write_event(AssistantDelta("partial repl")) - await pilot.pause() - streaming = app._streaming_msg # local so the later `is None` check stays reachable - assert streaming is not None - app._finish_turn() - assert app._streaming_msg is None # finalized, not left dangling - assert app.query_one(AssistantMessage).text == "partial repl" # kept what streamed - - _run(go()) - - -def test_user_message_prefixes_and_set_text_replaces_in_place() -> None: - # The prompt echo carries the "» " prefix; set_text() swaps the body in place (used to grow - # an interim voice transcript), keeping the same widget rather than mounting a new line. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - msg = UserMessage("hi") - await app.query_one("#log").mount(msg) - assert "» hi" in str(msg.render()) - msg.set_text("hi there friend") - assert "» hi there friend" in str(msg.render()) # body replaced, not appended - - _run(go()) - - -def test_short_tool_output_is_not_expandable() -> None: - # Output that already fits has no expand affordance and Ctrl-O is a no-op on it. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._write_event(ToolResult(name="execute", content="ok")) - await pilot.pause() - out = app.query_one(ToolOutput) - before = str(out.render()) - assert "Ctrl+O" not in before # nothing to expand -> no hint - out.toggle() - assert str(out.render()) == before # toggle is a no-op when it all fits - - _run(go()) - - -def test_tool_output_toggles_on_click_and_ctrl_o_is_safe_with_no_output() -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.action_toggle_output() # no tool output yet -> safe no-op - app._write_event( - ToolResult(name="execute", content="\n".join(f"x{i}" for i in range(20))) - ) - await pilot.pause() - out = app.query_one(ToolOutput) - assert "x19" not in str(out.render()) - out.on_click() # clicking expands - assert "x19" in str(out.render()) - - _run(go()) - - -def test_tool_output_expands_and_collapses_on_ctrl_o() -> None: - # A long tool result mounts a collapsed ToolOutput (preview + "more lines"); Ctrl-O - # expands it to the full content and toggles back. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._write_event( - ToolResult(name="execute", content="\n".join(f"ln{i}" for i in range(20))) - ) - await pilot.pause() - out = app.query_one(ToolOutput) - collapsed = str(out.render()) - assert "ln0" in collapsed and "more lines" in collapsed and "ln19" not in collapsed - app.action_toggle_output() # Ctrl-O expands the most recent output - assert "ln19" in str(out.render()) # full content now shown - app.action_toggle_output() # toggles back to the preview - assert "ln19" not in str(out.render()) - - _run(go()) diff --git a/tests/test_code_modals.py b/tests/test_code_modals.py deleted file mode 100644 index 80f62137..00000000 --- a/tests/test_code_modals.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Tests for the spoken/voice-answerable approval and ask modals. - -The pure ``approval_from_speech`` mapping is unit-tested directly; the screen wiring (speak the -prompt, listen, dismiss with the mapped decision) is driven through the real app headless with -a scripted voice double — no mic, speaker, or socket. -""" - -from __future__ import annotations - -import asyncio - -import pytest -from textual.widgets import Input - -from aai_cli.code_agent.modals import ApprovalScreen, AskScreen, approval_from_speech -from aai_cli.code_agent.tui import CodeAgentApp -from aai_cli.core.errors import CLIError - - -class FakeAgent: - def invoke(self, *a, **k): - return {} - - -class FakeVoice: - """Scripted voice IO: speak() records, listen() replays one transcript (or raises).""" - - def __init__(self, transcript: str | None = None, *, error: CLIError | None = None) -> None: - self._transcript = transcript - self._error = error - self.spoken: list[str] = [] - - def speak(self, text: str) -> None: - self.spoken.append(text) - - def listen(self) -> str | None: - if self._error is not None: - raise self._error - return self._transcript - - def cancel(self) -> None: - """No-op: the modal voice path never interrupts an in-flight leg.""" - - -def _run(coro) -> None: - asyncio.run(coro) - - -@pytest.mark.parametrize( - ("said", "decision"), - [ - ("yes please", "approve"), - ("approve that", "approve"), - ("go ahead", "approve"), - ("auto approve", "auto"), - ("always do this", "auto"), - ("no", "reject"), - ("reject it", "reject"), - ("don't", "reject"), - ("yes but no", "reject"), # reject wins over approve when both are heard (safer) - ("uhh what", "reject"), # unclear -> safe default - ], -) -def test_approval_from_speech(said: str, decision: str) -> None: - assert approval_from_speech(said) == decision - - -async def _push_and_wait(app, pilot, screen) -> object: - box: dict[str, object] = {} - app.push_screen(screen, lambda result: box.update(value=result)) - for _ in range(300): - await pilot.pause(0.01) - if "value" in box: - break - return box.get("value", "__pending__") - - -def test_spoken_approval_speaks_prompt_and_maps_answer() -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - voice = FakeVoice(transcript="yes go for it") - result = await _push_and_wait( - app, pilot, ApprovalScreen("execute", {"command": "rm -rf build"}, voice=voice) - ) - assert result == "approve" # spoken "yes" mapped to approve - prompt = voice.spoken[0] - assert "Run execute" in prompt and "rm -rf build" in prompt - assert "Warning:" in prompt # the risky command is read aloud - assert "approve, auto-approve, or reject" in prompt - - _run(go()) - - -def test_spoken_approval_rejects_on_no() -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - result = await _push_and_wait( - app, pilot, ApprovalScreen("write_file", {"file_path": "x"}, voice=FakeVoice("no")) - ) - assert result == "reject" - - _run(go()) - - -def test_spoken_ask_speaks_question_and_returns_transcript() -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - voice = FakeVoice(transcript="use port 8080") - result = await _push_and_wait(app, pilot, AskScreen("Which port?", voice=voice)) - assert result == "use port 8080" # spoken answer returned verbatim - assert "The agent asks: Which port?" in voice.spoken[0] - - _run(go()) - - -def test_silence_does_not_auto_reject() -> None: - # No speech (listen -> None) must not auto-decide — the modal waits for speech or a keypress - # rather than rejecting a tool on a pause. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - box: dict[str, object] = {} - app.push_screen( - ApprovalScreen("execute", {"command": "ls"}, voice=FakeVoice(None)), - lambda result: box.update(value=result), - ) - for _ in range(50): - await pilot.pause(0.01) - assert "value" not in box # silence -> not dismissed - - _run(go()) - - -def test_voice_failure_falls_back_to_keyboard() -> None: - # If the mic/STT fails, the modal isn't auto-dismissed — the user can still press a key. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - voice = FakeVoice(error=CLIError("no mic", error_type="mic_missing", exit_code=2)) - box: dict[str, object] = {} - app.push_screen( - ApprovalScreen("execute", {"command": "ls"}, voice=voice), - lambda result: box.update(value=result), - ) - for _ in range(50): - await pilot.pause(0.01) - assert "value" not in box # voice failed -> not auto-dismissed - await pilot.press("n") # keyboard still works - await pilot.pause() - assert box.get("value") == "reject" - - _run(go()) - - -def test_ask_voice_failure_falls_back_to_typing() -> None: - # An ask modal whose voice fails isn't dismissed; the user types the answer instead. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - voice = FakeVoice(error=CLIError("no mic", error_type="mic_missing", exit_code=2)) - box: dict[str, object] = {} - app.push_screen(AskScreen("Which port?", voice=voice), lambda r: box.update(value=r)) - for _ in range(50): - await pilot.pause(0.01) - assert "value" not in box # voice failed -> not auto-dismissed - app.screen.query_one("#answer", Input).value = "8080" - await pilot.press("enter") - await pilot.pause() - assert box.get("value") == "8080" - - _run(go()) - - -def test_spoken_prompt_omits_detail_when_no_args() -> None: - # A tool with no identifying arg reads as just "Run <tool>. Say approve…" (no detail clause). - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - voice = FakeVoice(transcript="yes") - result = await _push_and_wait(app, pilot, ApprovalScreen("noop", {}, voice=voice)) - assert result == "approve" - assert "Run noop. Say approve" in voice.spoken[0] # straight to the options - - _run(go()) - - -def test_ask_silence_does_not_dismiss() -> None: - # No spoken answer (listen -> None) leaves the ask modal up for typing. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - box: dict[str, object] = {} - app.push_screen(AskScreen("Q?", voice=FakeVoice(None)), lambda r: box.update(value=r)) - for _ in range(50): - await pilot.pause(0.01) - assert "value" not in box # silence -> not dismissed - - _run(go()) - - -def test_decide_and_answer_are_idempotent() -> None: - # A spoken reply and a keypress can race; the second one is ignored so the modal dismisses - # exactly once with the first decision. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - approval: dict[str, object] = {} - screen = ApprovalScreen("execute", {"command": "ls"}) - app.push_screen(screen, lambda r: approval.update(value=r)) - await pilot.pause() - screen._decide("approve") # first decision dismisses - await pilot.pause() - screen._decide("reject") # second is ignored (already answered) - await pilot.pause() - assert approval["value"] == "approve" - - answer: dict[str, object] = {} - ask = AskScreen("Q?") - app.push_screen(ask, lambda r: answer.update(value=r)) - await pilot.pause() - ask._answer("first") - await pilot.pause() - ask._answer("second") # ignored - await pilot.pause() - assert answer["value"] == "first" - - _run(go()) diff --git a/tests/test_code_session_stream.py b/tests/test_code_session_stream.py deleted file mode 100644 index 5c59803b..00000000 --- a/tests/test_code_session_stream.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for `CodeSession`'s dual-mode streaming and cooperative cancellation. - -Split from `test_code_agent.py` (which drives the real graph) to keep each file under the -500-line gate. These exercise the streaming loop with lightweight fakes: the session renders -from per-super-step ``"values"`` snapshots and checks the cancel flag on the frequent -per-token ``"messages"`` deltas, so a long generation can be interrupted promptly. -""" - -from __future__ import annotations - -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - -from aai_cli.code_agent.events import AssistantDelta, AssistantText, assistant_delta -from aai_cli.code_agent.session import CodeSession - - -class StreamingAgent: - """A double exercising the dual-mode streaming path. - - Mirrors langgraph's ``stream_mode=["values", "messages"]`` contract: each scripted state - snapshot is yielded tagged as ``("values", snapshot)``, optionally preceded by - ``("messages", delta)`` per-token deltas (the fine-grained cancellation checkpoints). - """ - - def __init__( - self, chunks: list[dict[str, object]], *, token_deltas: tuple[str, ...] = () - ) -> None: - self._chunks = chunks - self._token_deltas = token_deltas - - def stream(self, graph_input, config=None, *, stream_mode=("values", "messages")): - del graph_input, config, stream_mode - for delta in self._token_deltas: - yield ("messages", delta) - for chunk in self._chunks: - yield ("values", chunk) - - def invoke(self, *a, **k): # the streaming branch is taken, so invoke is never used - raise AssertionError("a streaming agent must not be invoked") - - -def test_assistant_delta_is_frozen_hashable() -> None: - # frozen=True makes it immutable+hashable; a non-frozen eq dataclass sets __hash__=None, - # so hash() would raise — this keeps the event safe to dedupe/compare and pins `frozen`. - assert hash(AssistantDelta("x")) == hash(AssistantDelta("x")) - - -def test_assistant_delta_extracts_only_ai_text() -> None: - # messages-mode yields (message, metadata); only AI text becomes a delta. - assert assistant_delta((AIMessage("tok"), {"node": "agent"})) == AssistantDelta("tok") - assert assistant_delta(AIMessage("bare")) == AssistantDelta("bare") # untupled is fine too - assert assistant_delta((AIMessage(""), {})) is None # empty content (e.g. a tool-call turn) - assert assistant_delta((ToolMessage("result", tool_call_id="1"), {})) is None # not assistant - assert assistant_delta(()) is None # defensive: empty payload - - -def test_send_emits_assistant_deltas_from_messages_stream() -> None: - # The per-token messages chunks are surfaced as AssistantDelta (live preview), and the - # values snapshot still yields the authoritative AssistantText. - seen: list[object] = [] - - class TokenAgent: - def stream(self, graph_input, config=None, *, stream_mode=("values", "messages")): - del graph_input, config, stream_mode - yield ("messages", (AIMessage("Hello, "), {})) - yield ("messages", (AIMessage("world"), {})) - yield ("values", {"messages": [AIMessage("Hello, world")]}) - - def invoke(self, *a, **k): - raise AssertionError("a streaming agent must not be invoked") - - session = CodeSession(agent=TokenAgent(), sink=seen.append, approver=lambda n, a: True) - session.send("go") - - deltas = [e.text for e in seen if isinstance(e, AssistantDelta)] - finals = [e.text for e in seen if isinstance(e, AssistantText)] - assert deltas == ["Hello, ", "world"] # streamed tokens - assert finals == ["Hello, world"] # authoritative full reply from the values snapshot - - -def test_send_streams_each_step_and_cancel_stops_the_loop() -> None: - # Three successive graph states (messages grow by one each step); a stream_mode="values" - # graph yields exactly these snapshots, so the session must emit incrementally. - chunks: list[dict[str, object]] = [ - {"messages": [HumanMessage("go")]}, - {"messages": [HumanMessage("go"), AIMessage("first")]}, - {"messages": [HumanMessage("go"), AIMessage("first"), AIMessage("second")]}, - ] - seen: list[object] = [] - session = CodeSession( - agent=StreamingAgent(chunks), sink=seen.append, approver=lambda n, a: True - ) - - def sink(event: object) -> None: - seen.append(event) - if isinstance(event, AssistantText) and event.text == "first": - session.request_cancel() # cancel mid-stream, before the "second" chunk is consumed - - session.sink = sink - session.send("go") - - texts = [e.text for e in seen if isinstance(e, AssistantText)] - # "first" streamed out as its step landed; the cancel then broke the loop, so the later - # "second" step was never emitted — proving both incremental rendering and cancellation. - assert texts == ["first"] - - -def test_cancel_within_a_step_breaks_on_a_token_delta() -> None: - # A single model generation is one super-step, so a values-only loop can't break until the - # whole reply lands. Streaming the per-token "messages" deltas alongside gives a frequent - # cancel checkpoint: a Ctrl-C mid-generation breaks before the reply ("late") is ever - # rendered. Modeled by an agent that requests cancel between two token deltas. - seen: list[object] = [] - - class TokenStreamAgent: - session: CodeSession - - def stream(self, graph_input, config=None, *, stream_mode=("values", "messages")): - del graph_input, config, stream_mode - yield ("messages", "par") # first token arrives — loop sees no cancel yet - self.session.request_cancel() # user hits Ctrl-C mid-generation - yield ("messages", "tial") # next token: the loop's top-of-iteration check breaks - yield ("values", {"messages": [AIMessage("late")]}) # must never be rendered - - def invoke(self, *a, **k): - raise AssertionError("a streaming agent must not be invoked") - - agent = TokenStreamAgent() - session = CodeSession(agent=agent, sink=seen.append, approver=lambda n, a: True) - agent.session = session - session.send("go") - - texts = [e.text for e in seen if isinstance(e, AssistantText)] - assert texts == [] # the post-cancel "late" reply was dropped, not rendered - - -def test_only_values_chunks_are_rendered_not_messages_deltas() -> None: - # The dual-mode stream tags each yield by mode; only "values" snapshots are rendered (the - # "messages" deltas exist purely as cancel checkpoints). A messages delta that happens to - # be a dict must NOT be emitted — guards the `mode == "values" and ...` guard against an - # `and`->`or` slip that would render it. - seen: list[object] = [] - - class DualModeAgent: - def stream(self, graph_input, config=None, *, stream_mode=("values", "messages")): - del graph_input, config, stream_mode - yield ("messages", {"messages": [AIMessage("ghost")]}) # dict, but messages-mode - yield ("values", {"messages": [AIMessage("real")]}) - - def invoke(self, *a, **k): - raise AssertionError("a streaming agent must not be invoked") - - session = CodeSession(agent=DualModeAgent(), sink=seen.append, approver=lambda n, a: True) - session.send("go") - - texts = [e.text for e in seen if isinstance(e, AssistantText)] - assert texts == ["real"] # the messages-mode dict ("ghost") was not rendered diff --git a/tests/test_code_tui.py b/tests/test_code_tui.py deleted file mode 100644 index b280ccc9..00000000 --- a/tests/test_code_tui.py +++ /dev/null @@ -1,472 +0,0 @@ -"""Tests for the `assembly code` Textual TUI. - -Pilot tests drive the real Textual app (headless) with a fake agent, so compose, -splash, the worker turn, event rendering, and the approval/ask modals are all -exercised without a network or a real terminal. -""" - -from __future__ import annotations - -import asyncio -import threading -import time - -import pytest -from langchain_core.messages import AIMessage, HumanMessage -from textual.containers import Horizontal, VerticalScroll -from textual.widgets import Input, Label, Static - -from aai_cli.code_agent.events import AssistantText, ErrorText, ToolCall, ToolResult -from aai_cli.code_agent.modals import ApprovalScreen, AskScreen -from aai_cli.code_agent.tui import CodeAgentApp - - -class FakeAgent: - """Replays scripted invoke() results (turn + interrupt-resume).""" - - def __init__(self, results: list[dict[str, object]]) -> None: - self._results = results - self.calls = 0 - - def invoke(self, *args, **kwargs): - result = self._results[self.calls] - self.calls += 1 - return result - - -class _Interrupt: - def __init__(self, value: dict[str, object]) -> None: - self.value = value - - -# --- pilot tests -------------------------------------------------------------- - - -def _run(coro) -> None: - asyncio.run(coro) - - -def test_mount_renders_splash_and_focuses_input() -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), web_note="no key", thread_id="t1") - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - log = app.query_one("#log", VerticalScroll) - assert len(log.children) >= 1 # the splash is mounted into the transcript - assert "Ready to code" in str(log.children[0].render()) # splash intro shown - assert app.focused is app.query_one("#prompt", Input) - # The bordered prompt bar must fit inside the screen so its right border isn't - # clipped off-edge — `width: 100%` honors the side margins where the docked - # default (`1fr`) would overflow to x=1..101 on a 100-wide screen. - assert app.query_one("#promptbar", Horizontal).region.right <= 100 - - _run(go()) - - -def test_voicebar_render_after_the_bar_is_gone_is_a_safe_noop() -> None: - # The 0.3s animation timer drives _render_voicebar and can fire one last tick during teardown, - # after #voicebar is removed but before the interval is cancelled; it must no-op, not raise the - # NoMatches that surfaced as a py3.13 CI flake. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - await app.query_one("#voicebar", Static).remove() - assert len(app.query("#voicebar")) == 0 - app._render_voicebar() # must not raise now that the bar is gone - - _run(go()) - - -def test_initial_prompt_runs_a_turn_on_mount() -> None: - async def go() -> None: - agent = FakeAgent([{"messages": [HumanMessage("seed"), AIMessage("seeded reply")]}]) - app = CodeAgentApp(agent=agent, initial="kick off") - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - await app.workers.wait_for_complete() - await pilot.pause() - assert agent.calls == 1 # the initial prompt drove one turn - - _run(go()) - - -def test_submit_runs_turn_and_renders_reply() -> None: - async def go() -> None: - agent = FakeAgent([{"messages": [HumanMessage("go"), AIMessage("all done")]}]) - app = CodeAgentApp(agent=agent) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - # "[build" contains unbalanced Rich markup: without escaping, _submit's - # log.write would raise MarkupError, so this also guards the escape(). - app.query_one("#prompt", Input).value = "[build" - await pilot.press("enter") - await app.workers.wait_for_complete() - await pilot.pause() - assert app.query_one("#prompt", Input).disabled is False # re-enabled - - _run(go()) - - -def test_write_event_each_type_and_copy(monkeypatch: pytest.MonkeyPatch) -> None: - copied: list[str] = [] - monkeypatch.setattr("pyperclip.copy", copied.append) - - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - # Each value carries unbalanced "[" markup: without escaping, RichLog.write - # would raise MarkupError here, so these calls also guard the escape() paths. - app._write_event(AssistantText("[reply")) - app._write_event(ToolCall(name="write_file", args={"file_path": "[a"})) - app._write_event(ToolResult(name="write_file", content="[unclosed")) - app._write_event(ErrorText("[boom")) - assert app._last_reply == "[reply" - app.action_copy_last() - assert copied == ["[reply"] - - _run(go()) - - -def _drive_modal(app, call, keys: list[str]): - """Run ``call`` (which blocks on a modal) on a thread; dismiss with ``keys``.""" - - async def go(): - box: dict[str, object] = {} - thread = threading.Thread(target=lambda: box.update(result=call())) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - thread.start() - for _ in range(200): - await pilot.pause(0.01) - if len(app.screen_stack) > 1: - break - await pilot.press(*keys) - thread.join(timeout=3) - await pilot.pause() - return box.get("result") - - return asyncio.run(go()) - - -def test_approval_modal_approve_and_reject() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - assert _drive_modal(app, lambda: app._approve("write_file", {"file_path": "a"}), ["y"]) is True - - app2 = CodeAgentApp(agent=FakeAgent([])) - assert _drive_modal(app2, lambda: app2._approve("execute", {"cmd": "ls"}), ["n"]) is False - - -def test_ask_modal_returns_typed_answer() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - answer = _drive_modal(app, lambda: app._ask("which port?"), ["8", "0", "8", "0", "enter"]) - assert answer == "8080" - - -def test_approval_modal_dismisses_on_escape_or_ctrl_c() -> None: - # Escape / Ctrl-C decline the tool (the safe cancel), like pressing "n". - app = CodeAgentApp(agent=FakeAgent([])) - assert _drive_modal(app, lambda: app._approve("execute", {"cmd": "ls"}), ["escape"]) is False - app2 = CodeAgentApp(agent=FakeAgent([])) - assert _drive_modal(app2, lambda: app2._approve("execute", {"cmd": "ls"}), ["ctrl+c"]) is False - - -def test_ask_modal_dismisses_on_escape_or_ctrl_c_with_no_answer() -> None: - # Escape / Ctrl-C cancel the question; the agent gets an empty answer. - app = CodeAgentApp(agent=FakeAgent([])) - assert _drive_modal(app, lambda: app._ask("which port?"), ["escape"]) == "" - app2 = CodeAgentApp(agent=FakeAgent([])) - assert _drive_modal(app2, lambda: app2._ask("which port?"), ["ctrl+c"]) == "" - - -def test_full_turn_with_approval_interrupt() -> None: - async def go() -> None: - agent = FakeAgent( - [ - { - "__interrupt__": [ - _Interrupt({"action_requests": [{"name": "write_file", "args": {}}]}) - ] - }, - {"messages": [HumanMessage("go"), AIMessage("written")]}, - ] - ) - app = CodeAgentApp(agent=agent) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.query_one("#prompt", Input).value = "write it" - await pilot.press("enter") - for _ in range(200): - await pilot.pause(0.01) - if len(app.screen_stack) > 1: - break - await pilot.press("y") # approve - await app.workers.wait_for_complete() - await pilot.pause() - assert agent.calls == 2 # initial + resume - - _run(go()) - - -def test_approval_prompt_renders_keyboard_hint() -> None: - # The prompt is a plain y/a/n keyboard hint, not clickable buttons — assert each - # option's copy renders so dropping one is caught. The bracketed name/args also guard - # the compose() escape(): without it, Label markup parsing would raise on mount. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.push_screen(ApprovalScreen("exec[", {"cmd": "[ls"})) - await pilot.pause() - rendered = " ".join(str(label.render()) for label in app.screen.query(Label)) - assert "approve" in rendered - assert "auto-approve" in rendered - assert "reject" in rendered - - _run(go()) - - -def test_approval_expands_args_on_e() -> None: - # Collapsed, the prompt shows only the identifying arg (the filename); pressing `e` - # expands it to the full args, revealing the file content that was elided. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.push_screen( - ApprovalScreen("write_file", {"file_path": "x.py", "content": "SECRET"}) - ) - await pilot.pause() - detail = app.screen.query_one("#approvaldetail", Label) - assert "SECRET" not in str(detail.render()) # collapsed: content elided - await pilot.press("e") - await pilot.pause() - assert "SECRET" in str(detail.render()) # expanded: full args shown - await pilot.press("e") # toggles back - await pilot.pause() - assert "SECRET" not in str(detail.render()) - - _run(go()) - - -def test_approval_shows_risk_warning_for_dangerous_command() -> None: - # A destructive shell command carries a one-line warning above the prompt; a benign one - # mounts no warning label at all. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.push_screen(ApprovalScreen("execute", {"command": "rm -rf build/"})) - await pilot.pause() - warn = app.screen.query("#approvalwarn") - assert warn # warning present - assert "deletes files" in str(warn.first().render()) - app.pop_screen() - await pilot.pause() - app.push_screen(ApprovalScreen("execute", {"command": "ls -la"})) - await pilot.pause() - assert not app.screen.query("#approvalwarn") # benign: no warning mounted - - _run(go()) - - -def test_approval_box_is_compact_and_bottom_docked() -> None: - # Regression guard: the approval prompt must not take over the whole screen — it - # docks a short box at the bottom so the transcript stays visible above it. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.push_screen(ApprovalScreen("write_file", {"file_path": "x.py"})) - await pilot.pause() - box = app.screen.query_one("#approvalbox") - assert box.region.height <= 8 # a handful of rows, not the full 30 - assert box.region.bottom <= 30 # anchored within the bottom of the screen - assert box.region.y >= 15 # sits in the lower half, transcript visible above - # The box must fit inside the screen so its rounded border isn't clipped off the - # right edge: a docked `width: 1fr` container ignores horizontal margin and - # overflows to x=1..101 on a 100-wide screen (the bug `width: 100%` fixes). - assert box.region.right <= 100 - - _run(go()) - - -def test_modals_are_transparent_so_transcript_stays_visible() -> None: - # Regression guard: the app's `Screen { background: #000000 }` canvas rule matches every - # Screen subclass, and app CSS beats a widget's DEFAULT_CSS — so without the explicit - # `ModalScreen { background: transparent }` app rule, the modal paints opaque black and - # blanks the transcript behind it. Assert each modal resolves to a see-through background - # (alpha 0); an opaque modal (alpha 1.0) — the bug — fails here. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.push_screen(ApprovalScreen("write_file", {"file_path": "x.py"})) - await pilot.pause() - assert app.screen.styles.background.a == 0 # approval modal is see-through - app.pop_screen() - await pilot.pause() - app.push_screen(AskScreen("which port?")) - await pilot.pause() - assert app.screen.styles.background.a == 0 # ask modal is see-through - - _run(go()) - - -def test_approval_auto_approve_flips_mode_and_skips_later_prompts() -> None: - # Picking "Auto-approve (a)" approves this call, flips the badge manual→auto, and - # makes every later _approve return True without ever pushing a modal. - app = CodeAgentApp(agent=FakeAgent([])) - assert _drive_modal(app, lambda: app._approve("execute", {"cmd": "ls"}), ["a"]) is True - assert app._auto_approve is True - assert app._session.auto_approve is True - # A second decision short-circuits: it returns True even though no modal can be driven. - assert app._approve("write_file", {"file_path": "x"}) is True - - -def test_refresh_status_rerenders_badge() -> None: - # _enable_auto_approve (worker thread) marshals a _refresh_status onto the UI thread; - # this drives that re-render directly, asserting the badge tracks the mode flip. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - assert "manual" in str(app.query_one("#status", Static).render()) - app._auto_approve = True - app._refresh_status() - await pilot.pause() - assert "auto" in str(app.query_one("#status", Static).render()) - - _run(go()) - - -def test_escape_interrupts_a_running_turn() -> None: - # While a turn is in flight (prompt disabled), Escape signals the session to stop its - # agent loop; it never quits the app. Drives the real "escape" binding end to end. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.query_one("#prompt", Input).disabled = True # simulate a turn in progress - await pilot.press("escape") - await pilot.pause() - assert app._session._cancel.is_set() # the loop was asked to stop - - _run(go()) - - -def test_escape_is_a_noop_when_idle() -> None: - # Idle (prompt enabled): Escape does nothing — no cancel signal, no quit. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.action_interrupt() # idle: nothing to interrupt - assert app._session._cancel.is_set() is False - - _run(go()) - - -def test_ctrl_c_interrupts_running_turn_and_does_not_arm_quit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - exited: list[bool] = [] - monkeypatch.setattr(app, "exit", lambda *a, **k: exited.append(True)) - app.query_one("#prompt", Input).disabled = True # a turn is running - app.action_quit_or_interrupt() - assert app._session._cancel.is_set() # interrupted the turn - assert exited == [] # did NOT quit, because a turn was in flight - assert app._quit_pending is False # interrupting never arms the quit hint - - _run(go()) - - -def test_ctrl_c_needs_a_double_press_to_quit_when_idle(monkeypatch: pytest.MonkeyPatch) -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - exited: list[bool] = [] - monkeypatch.setattr(app, "exit", lambda *a, **k: exited.append(True)) - app.action_quit_or_interrupt() # first idle press: arms, does not quit - assert exited == [] - assert app._quit_pending is True - app.action_quit_or_interrupt() # second press confirms the quit - assert exited == [True] - assert app._session._cancel.is_set() is False # nothing was cancelled - - _run(go()) - - -def test_clear_quit_pending_resets_the_flag() -> None: - # The timer-fired reset (covered directly since the timer won't fire within the test). - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._quit_pending = True - app._clear_quit_pending() - assert app._quit_pending is False - - _run(go()) - - -def test_spinner_starts_ticks_and_stops(monkeypatch: pytest.MonkeyPatch) -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - # Re-query for each display check: a stored `spinner.display` would let mypy - # narrow the bool across the start/stop calls and flag the next assert dead. - assert app.query_one("#spinner", Static).display is False # hidden at rest - app._start_spinner() - await pilot.pause() - assert app.query_one("#spinner", Static).display is True - # _tick wires the elapsed seconds off the start time; pin "now" to assert it. - # Stop the live interval first so only this deterministic tick writes the - # readout — otherwise a real-time auto-tick can race the assert on a loaded - # runner, which flaked CI with "(6s)" vs "(7s)". update()->render() is - # synchronous, so no pilot.pause() is needed (and pausing here deadlocks). - assert app._spin_timer is not None - app._spin_timer.stop() - monkeypatch.setattr(time, "monotonic", lambda: app._turn_started + 7.0) - app._tick() - assert "Working… (7s)" in str(app.query_one("#spinner", Static).render()) - app._stop_spinner() - assert app.query_one("#spinner", Static).display is False - assert app._spin_timer is None - - _run(go()) - - -def test_stop_spinner_is_a_noop_when_not_started() -> None: - # The timer-None branch of _stop_spinner: stopping before any turn just hides. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._stop_spinner() - assert app.query_one("#spinner", Static).display is False - - _run(go()) - - -def test_ask_screen_compose_escapes_markup() -> None: - # Mounting AskScreen with a bracketed question exercises its compose() escape(); - # without it, the Label markup parse would raise MarkupError on mount. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.push_screen(AskScreen("which port [x?"), lambda answer: None) - await pilot.pause() - app.screen.query_one("#answer", Input).value = "8080" - await pilot.press("enter") - await pilot.pause() - - _run(go()) diff --git a/tests/test_code_tui_voice.py b/tests/test_code_tui_voice.py deleted file mode 100644 index 5e6eb618..00000000 --- a/tests/test_code_tui_voice.py +++ /dev/null @@ -1,497 +0,0 @@ -"""Tests for the `assembly code` TUI's voice integration. - -Drives the real Textual app (headless) with a fake agent and a scripted voice double, so -the listen→enter-into-the-prompt→submit cycle and the spoken-summary readback are exercised -without a microphone, speaker, or socket. Split from test_code_tui.py to keep each file under -the 500-line gate. -""" - -from __future__ import annotations - -import asyncio -from types import SimpleNamespace - -import pytest -from langchain_core.messages import AIMessage, HumanMessage -from textual.widgets import Input, Static - -from aai_cli.code_agent.tui import CodeAgentApp -from aai_cli.core.errors import CLIError - - -class FakeAgent: - """Replays scripted invoke() results so a turn can complete without a model.""" - - def __init__(self, results: list[dict[str, object]]) -> None: - self._results = results - self.calls = 0 - - def invoke(self, *args, **kwargs): - result = self._results[self.calls] - self.calls += 1 - return result - - -class FakeVoice: - """A scripted voice I/O double: listen() replays transcripts, speak() records text.""" - - def __init__(self, transcripts: list[str] | None = None, *, error: CLIError | None = None): - self._transcripts = list(transcripts or []) - self._error = error - self.spoken: list[str] = [] - self.listens = 0 - self.cancels = 0 - - def listen(self) -> str | None: - self.listens += 1 - if self._error is not None: - raise self._error - return self._transcripts.pop(0) if self._transcripts else None - - def speak(self, text: str) -> None: - self.spoken.append(text) - - def cancel(self) -> None: - self.cancels += 1 - - -def _run(coro) -> None: - asyncio.run(coro) - - -def _wait_until(pilot, predicate): - """Pump the event loop until ``predicate`` holds (lets a voice worker thread land).""" - - async def loop() -> bool: - for _ in range(200): - await pilot.pause(0.01) - if predicate(): - return True - return False - - return loop() - - -def test_voice_active_requires_a_session_and_an_available_mic() -> None: - async def go() -> None: - no_voice = CodeAgentApp(agent=FakeAgent([])) - async with no_voice.run_test(size=(100, 30)) as pilot: - await pilot.pause() - assert no_voice._voice_active() is False # no voice session at all - - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - assert app._voice_active() is True - app._voice_typed = True - assert app._voice_active() is False # mic ruled out -> inactive - - _run(go()) - - -def test_enter_and_submit_fills_prompt_then_clears_and_submits( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - submitted: list[str] = [] - monkeypatch.setattr(app, "_submit", submitted.append) - app._enter_and_submit("add a verbose flag") - assert submitted == ["add a verbose flag"] # the spoken turn was submitted - assert app.query_one("#prompt", Input).value == "" # prompt cleared afterwards - - _run(go()) - - -def test_voice_on_mount_listens_and_submits_the_spoken_turn() -> None: - async def go() -> None: - agent = FakeAgent([{"messages": [HumanMessage("do x"), AIMessage("done")]}]) - voice = FakeVoice(transcripts=["do x"]) - app = CodeAgentApp(agent=agent, voice=voice) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - # on_mount (no initial prompt) starts listening; the captured turn drives the agent. - assert await _wait_until(pilot, lambda: agent.calls >= 1) - assert voice.listens >= 1 - - _run(go()) - - -def test_finished_worker_is_ignored_once_the_app_stops_running(): # untyped: duck-typed event - # A turn worker can finish *after* the app starts tearing down; driving _finish_turn then - # queries an unmounted DOM (NoMatches on #spinner — a Windows CI flake). on_worker_state_changed - # must skip it when the app isn't running, and handle it when it is. - app = CodeAgentApp(agent=FakeAgent([])) - calls: list[bool] = [] - app._finish_turn = lambda: calls.append(True) # spy - finished = SimpleNamespace(worker=SimpleNamespace(is_finished=True)) - - assert app.is_running is False # never mounted -> torn-down-equivalent - app.on_worker_state_changed(finished) # duck-typed event stands in for Worker.StateChanged - assert calls == [] # guarded out: no _finish_turn against a dead DOM - - async def go() -> None: - async with app.run_test(size=(100, 30)): - app.on_worker_state_changed(finished) - - _run(go()) - assert calls == [True] # running -> the finished turn is handled - - -def test_interrupt_during_speaking_stops_readback_and_ctrl_c_can_always_quit(): # untyped: internals - # Both Escape and Ctrl-C stop the readback and re-listen (not text); Ctrl-C also arms the - # quit, and a SECOND Ctrl-C exits even mid-speech — so a spoken turn can never trap you. - async def go(): - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - exited: list[bool] = [] - app.exit = lambda *a, **k: exited.append(True) # capture the quit without tearing down - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._voice_phase = "speaking" - app.action_interrupt() # Escape - assert app._voice.cancels >= 1 and app._voice_paused is False # stopped, re-listens - assert app._quit_pending is False # Escape never quits - - app._voice_phase = "speaking" - app.action_quit_or_interrupt() # Ctrl-C - assert app._voice.cancels >= 2 and app._quit_pending is True # stopped + armed - assert exited == [] - app.action_quit_or_interrupt() # second Ctrl-C - assert exited == [True] # quits even mid-speech — never trapped - - _run(go()) - - -def test_capture_voice_turn_is_a_noop_once_typed() -> None: - async def go() -> None: - voice = FakeVoice(transcripts=["ignored"]) - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - app._voice_typed = True # set before mount so on_mount never auto-listens - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._capture_voice_turn() # typed -> returns before listen (safe on the UI thread) - assert voice.listens == 0 - - _run(go()) - - -def test_voice_degrades_to_typed_on_capture_error() -> None: - async def go() -> None: - voice = FakeVoice(error=CLIError("no mic", error_type="mic_missing", exit_code=2)) - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - assert await _wait_until(pilot, lambda: app._voice_typed) - assert app._voice_typed is True # a capture failure drops voice for the session - - _run(go()) - - -def test_voice_followup_reads_a_summary_of_the_last_reply() -> None: - async def go() -> None: - voice = FakeVoice() - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._voice_typed = True # isolate the readback: the post-speak listen is a no-op - app._last_reply = "Here is the plan.\n```py\ncode\n```" - app._voice_followup() - assert await _wait_until(pilot, lambda: bool(voice.spoken)) - assert voice.spoken == ["Here is the plan."] # summary only — the code is stripped - - _run(go()) - - -def test_voice_followup_is_a_noop_without_voice() -> None: - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) # no voice session - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._voice_followup() # returns immediately without speaking or listening - assert app._voice is None - - _run(go()) - - -def test_toggle_voice_pauses_and_resumes_capture() -> None: - # Ctrl-V flips voice off (no capture, no readback) and back on; the state badge tracks it. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - # Assert via the methods, not the `_voice_paused` attribute: mypy narrows the - # attribute and can't see action_toggle_voice() flip it back, flagging the second - # check unreachable. The method calls reflect the same state without that trap. - assert app._voice_active() - assert app._voice_state() == "on" - app.action_toggle_voice() # pause - assert not app._voice_active() - assert app._voice_state() == "off" - app.action_toggle_voice() # resume - assert app._voice_active() - assert app._voice_state() == "on" - - _run(go()) - - -def test_paused_voice_skips_followup_readback() -> None: - # While paused, the post-turn followup neither speaks a summary nor listens. - async def go() -> None: - voice = FakeVoice(transcripts=["ignored"]) - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - app._voice_paused = True # set before mount so on_mount never auto-listens - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._last_reply = "a reply" - app._voice_followup() - await pilot.pause() - assert voice.spoken == [] # paused: no readback - assert voice.listens == 0 # paused: no capture - - _run(go()) - - -def test_voice_mode_swaps_text_input_for_listening_affordance() -> None: - # While voice capture is on, the text prompt is hidden and a "listening" bar shows; - # toggling voice off (Ctrl-V) brings the text box back. (Re-query each check so mypy - # doesn't narrow a stored display bool across the toggles.) - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - app._voice_paused = True # start paused so on_mount doesn't race a capture thread - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - assert app.query_one("#promptbar").display is True # paused -> text box visible - assert app.query_one("#voicebar").display is False - app.action_toggle_voice() # voice on - await pilot.pause() - assert app.query_one("#promptbar").display is False # text box hidden - assert app.query_one("#voicebar").display is True # listening affordance shown - app.action_toggle_voice() # voice off - await pilot.pause() - assert app.query_one("#promptbar").display is True # text box back - assert app.query_one("#voicebar").display is False - - _run(go()) - - -def test_voice_capture_failure_restores_the_text_input() -> None: - # When the mic is ruled out mid-session, the listening bar is replaced by the text box. - async def go() -> None: - voice = FakeVoice(error=CLIError("no mic", error_type="mic_missing", exit_code=2)) - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - assert await _wait_until(pilot, lambda: app._voice_typed) - await pilot.pause() - assert app.query_one("#promptbar").display is True # text box restored on failure - assert app.query_one("#voicebar").display is False - - _run(go()) - - -def test_voice_bar_distinguishes_phases() -> None: - # The bar shows a distinct label per phase; only the listening phase carries the type hint. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - app._voice_paused = True # quiet the auto-listen; drive phases directly - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._set_voice_phase("listening") - bar = str(app.query_one("#voicebar", Static).render()) - assert "Listening" in bar and "Ctrl-V to type" in bar - app._set_voice_phase("thinking") - bar = str(app.query_one("#voicebar", Static).render()) - assert "Thinking" in bar and "Ctrl-V to type" not in bar # hint is listening-only - app._set_voice_phase("speaking") - assert "Speaking" in str(app.query_one("#voicebar", Static).render()) - - _run(go()) - - -def test_spinner_suppressed_in_voice_mode() -> None: - # In voice mode the bar carries the "thinking" state, so the separate spinner stays hidden; - # pausing voice brings the spinner back. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._start_spinner() - assert app.query_one("#spinner", Static).display is False # voice active -> no spinner - app._voice_paused = True - app._start_spinner() - assert app.query_one("#spinner", Static).display is True # paused -> spinner shows - - _run(go()) - - -def test_voice_bar_animation_timer_runs_and_advances() -> None: - # The meter animation timer runs only while the bar is shown, and a tick changes the frame. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - app._voice_paused = True - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - # Read into fresh locals each time: asserting `is None`/`is not None` on the same - # attribute across the opaque toggle would make mypy flag the later check unreachable. - paused_timer = app._voice_timer - assert paused_timer is None # paused -> no animation - app.action_toggle_voice() # voice on -> bar shown, timer running - await pilot.pause() - running_timer = app._voice_timer - assert running_timer is not None - before = str(app.query_one("#voicebar", Static).render()) - app._render_voicebar() - assert str(app.query_one("#voicebar", Static).render()) != before # meter advanced - app.action_toggle_voice() # voice off -> timer stopped - await pilot.pause() - stopped_timer = app._voice_timer - assert stopped_timer is None - - _run(go()) - - -def test_submit_sets_thinking_phase() -> None: - async def go() -> None: - agent = FakeAgent([{"messages": [HumanMessage("go"), AIMessage("done")]}]) - app = CodeAgentApp(agent=agent, voice=FakeVoice()) - app._voice_paused = True # keep the post-turn followup from flipping the phase - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._submit("go") - assert app._voice_phase == "thinking" # set synchronously when the turn starts - await app.workers.wait_for_complete() - - _run(go()) - - -def test_run_leg_swallows_callback_error_after_the_app_stops() -> None: - # A voice leg still in flight when the app tears down calls back onto a dead UI thread; - # the resulting RuntimeError must be dropped (the spoken turn is moot), not surface as an - # unhandled thread exception. This app was never started, so is_running is False. - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - assert app.is_running is False - ran: list[bool] = [] - - def boom() -> None: - ran.append(True) - raise RuntimeError("App is not running") - - app._run_leg(boom) # returns without raising — the teardown-race error is swallowed - assert ran == [True] # the leg body did run; only its post-teardown error was dropped - - -def test_run_leg_reraises_a_genuine_failure_while_the_app_is_live() -> None: - # While the app is running, a real exception in a leg is a bug and must propagate (so it's - # reported), not be silently swallowed like the teardown race above. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - app._voice_paused = True # no auto-listen thread racing this assertion - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - assert app.is_running is True - - def boom() -> None: - raise ValueError("genuine bug") - - with pytest.raises(ValueError, match="genuine bug"): - app._run_leg(boom) - - _run(go()) - - -def test_ctrl_c_interrupts_active_voice_then_quits_on_second_press( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # In voice mode the agent is listening/speaking (not a "running turn"), so the first Ctrl-C - # stops that voice activity and goes idle; a second Ctrl-C then confirms the quit. - async def go() -> None: - voice = FakeVoice() - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - app._voice_paused = True # keep on_mount from racing a real listen thread - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - exited: list[bool] = [] - monkeypatch.setattr(app, "exit", lambda *a, **k: exited.append(True)) - app._voice_paused = False # voice now active (listening) - app.action_quit_or_interrupt() # first press: stop the voice, go idle - assert voice.cancels == 1 # the in-flight listen/readback was cancelled - assert app._voice_paused is True # paused -> idle, the text prompt returns - assert app._quit_pending is True # quit armed so the next press confirms - assert exited == [] # did NOT quit on the first press - app.action_quit_or_interrupt() # second press: now idle -> quits - assert exited == [True] - assert voice.cancels == 1 # the idle press didn't re-cancel - - _run(go()) - - -def test_ctrl_c_quits_when_a_quit_is_pending_even_with_active_voice( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # A pending quit takes priority over active voice: a second Ctrl-C (quit already armed) - # exits even while the agent is listening/speaking — otherwise a voice turn could trap - # the user with no way out. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([]), voice=FakeVoice()) - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - exited: list[bool] = [] - monkeypatch.setattr(app, "exit", lambda *a, **k: exited.append(True)) - app._voice_paused = False # voice active (listening/speaking) - app._quit_pending = True # a quit hint was already armed by a prior press - app.action_quit_or_interrupt() # Ctrl-C: with quit armed, exit - assert exited == [True] # quits — never trapped - - _run(go()) - - -def test_escape_interrupts_active_voice_without_arming_quit() -> None: - # Escape stops in-flight voice the same way, but (unlike Ctrl-C) never arms the quit hint. - async def go() -> None: - voice = FakeVoice() - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - app._voice_paused = True - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app._voice_paused = False # active - app.action_interrupt() # Escape - assert voice.cancels == 1 # voice stopped - assert app._voice_paused is True # idle - assert app._quit_pending is False # Escape is not a quit key - - _run(go()) - - -def test_stop_voice_activity_is_a_noop_when_voice_inactive() -> None: - # No voice session, or a paused one, is not "active": _stop_voice_activity cancels nothing - # (and doesn't crash on the missing session), so the interrupt defers to the quit path. - async def go() -> None: - no_voice = CodeAgentApp(agent=FakeAgent([])) - async with no_voice.run_test(size=(100, 30)) as pilot: - await pilot.pause() - no_voice._stop_voice_activity() # no voice session -> no-op, no error - - voice = FakeVoice() - paused = CodeAgentApp(agent=FakeAgent([]), voice=voice) - paused._voice_paused = True - async with paused.run_test(size=(100, 30)) as pilot: - await pilot.pause() - paused._stop_voice_activity() # paused -> inactive - assert voice.cancels == 0 # a paused session is never cancelled - - _run(go()) - - -def test_toggle_voice_without_session_notifies_and_stays_off() -> None: - # With no voice front-end the toggle is a no-op (notice only) and never marks a pause. - async def go() -> None: - app = CodeAgentApp(agent=FakeAgent([])) # no voice - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.action_toggle_voice() - assert app._voice_paused is False # nothing to pause - assert app._voice_state() is None # no badge without a session - - _run(go()) diff --git a/tests/test_code_tui_voice_switch.py b/tests/test_code_tui_voice_switch.py deleted file mode 100644 index 9687b791..00000000 --- a/tests/test_code_tui_voice_switch.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Tests for switching between voice and text mode in the `assembly code` TUI. - -Switching input mode (Ctrl-V) and interrupting (Escape / Ctrl-C) both have to stop an -in-flight microphone capture so it neither keeps the mic open behind the text prompt nor -submits a turn the user no longer wants. These cancel-safety cases are split out of -test_code_tui_voice.py to keep each file under the 500-line gate, reusing that module's -app/voice doubles. -""" - -from __future__ import annotations - -import threading - -import pytest - -from aai_cli.code_agent.tui import CodeAgentApp -from tests.test_code_tui_voice import FakeAgent, FakeVoice, _run, _wait_until - - -def test_toggle_voice_off_cancels_in_flight_capture() -> None: - # Switching to text (Ctrl-V) must release the mic now — cancel the blocking listen() - # rather than leaving a capture running unseen behind the text prompt. - async def go() -> None: - voice = FakeVoice() - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - app._voice_paused = True # start paused so on_mount doesn't race a capture thread - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - app.action_toggle_voice() # voice on - assert voice.cancels == 0 # turning on never cancels - app.action_toggle_voice() # voice off -> must cancel the in-flight capture - assert voice.cancels == 1 - - _run(go()) - - -def test_capture_after_switching_to_text_is_not_submitted(monkeypatch: pytest.MonkeyPatch) -> None: - # A turn that finalizes in the window between the user pressing Ctrl-V and the capture - # unwinding must NOT be submitted — otherwise a spoken phrase lands as a turn after the - # user already switched to typing. - async def go() -> None: - voice = FakeVoice() - app = CodeAgentApp(agent=FakeAgent([]), voice=voice) - app._voice_paused = True # block the on_mount auto-listen - async with app.run_test(size=(100, 30)) as pilot: - await pilot.pause() - submitted: list[str] = [] - monkeypatch.setattr(app, "_submit", submitted.append) # spy: _enter_and_submit calls it - - def listen() -> str: - voice.listens += 1 - app._voice_paused = True # user switched to text DURING the capture - return "late turn" - - monkeypatch.setattr(voice, "listen", listen) - app._voice_paused = False # active when the capture starts - thread = threading.Thread(target=app._capture_voice_turn) - thread.start() - assert await _wait_until(pilot, lambda: not thread.is_alive()) - assert submitted == [] # the late turn was dropped, not submitted - - _run(go()) diff --git a/tests/test_code_voice.py b/tests/test_code_voice.py deleted file mode 100644 index 6233036d..00000000 --- a/tests/test_code_voice.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Tests for the `assembly code` voice I/O (code_agent/voice.py + _exec voice helpers). - -The bodies are intentionally unannotated: they drive the voice session through -lightweight fakes (a fake mic, stream_fn, synth_fn, and player) so no microphone, -speaker, or socket is ever touched — the strict type-checker skips untyped test bodies. -""" - -from __future__ import annotations - -from types import SimpleNamespace - -from aai_cli.code_agent import voice as voicemod -from aai_cli.code_agent.voice import VoiceSession, build_voice_session, spoken_summary - - -class FakeMic: - def __init__(self, chunks, sample_rate=16000): - self._chunks = list(chunks) - self.sample_rate = sample_rate - - def __iter__(self): - return iter(self._chunks) - - -def _turn(text, *, end_of_turn): - return SimpleNamespace(transcript=text, end_of_turn=end_of_turn) - - -def test_listen_returns_final_turn_and_gates_mic_after_it(): - seen = {} - - def fake_stream(api_key, source, *, params, on_turn): - seen["key"] = api_key - seen["params"] = params - it = iter(source) - seen["before"] = next(it) # the first chunk flows before the turn finalizes - on_turn(_turn("add a verbose flag", end_of_turn=True)) - seen["after"] = list(it) # gated() must stop now, yielding nothing more - - session = VoiceSession( - api_key="k", - readback=False, - mic_factory=lambda: FakeMic([b"a", b"b", b"c"]), - stream_fn=fake_stream, - ) - assert session.listen() == "add a verbose flag" - assert seen["key"] == "k" - assert seen["before"] == b"a" - assert seen["after"] == [] # the mic was gated shut the instant the turn finalized - assert seen["params"].format_turns is True - assert seen["params"].sample_rate == 16000 - - -def test_listen_stops_capturing_when_cancelled(): - seen = {} - holder = {} - - def fake_stream(api_key, source, *, params, on_turn): - it = iter(source) - seen["first"] = next(it) # one chunk flows before the interrupt - holder["session"].cancel() # the TUI's Ctrl-C, from another thread - seen["rest"] = list(it) # gated() must stop the instant cancel() fires - - session = VoiceSession( - api_key="k", - readback=False, - mic_factory=lambda: FakeMic([b"a", b"b", b"c"]), - stream_fn=fake_stream, - ) - holder["session"] = session - assert session.listen() is None # cancelled mid-capture -> no turn finalized - assert seen["first"] == b"a" - assert seen["rest"] == [] # the mic was gated shut by cancel(), not drained - - -def test_listen_clears_a_stale_cancel_before_capturing(): - # A cancel() that fired outside a capture must not preempt the next listen — listen() - # clears the flag on entry, so the gate is open and the turn is captured normally. - def fake_stream(api_key, source, *, params, on_turn): - it = iter(source) - next(it) # if the stale cancel weren't cleared, gated() would yield nothing here - on_turn(_turn("hello", end_of_turn=True)) - list(it) - - session = VoiceSession( - api_key="k", - readback=False, - mic_factory=lambda: FakeMic([b"a", b"b"]), - stream_fn=fake_stream, - ) - session.cancel() # a stale cancel set before the capture begins - assert session.listen() == "hello" # cleared on entry -> capture proceeds - - -def test_listen_ignores_partials_and_returns_none_without_a_final_turn(): - def fake_stream(api_key, source, *, params, on_turn): - on_turn(_turn("typing in progr", end_of_turn=False)) # interim only - on_turn(_turn("", end_of_turn=True)) # finalized but empty -> not captured - on_turn(SimpleNamespace(transcript="no end_of_turn field")) # missing attr -> not final - list(source) - - session = VoiceSession( - api_key="k", readback=False, mic_factory=lambda: FakeMic([b"a"]), stream_fn=fake_stream - ) - # A turn is captured only when end_of_turn is truthy; a partial, an empty final, and an - # event lacking the field entirely (the getattr default is False) all leave it None. - assert session.listen() is None - - -class FakePlayer: - def __init__(self): - self.fed = [] - self.exit_exc_type = None - - def __enter__(self): - return self - - def __exit__(self, exc_type, *exc): - self.exit_exc_type = exc_type # records the abort path (an exception on the way out) - return False - - def feed(self, pcm, sample_rate, *, cancelled=None): - self.fed.append((pcm, sample_rate)) - - -def test_speak_synthesizes_and_plays_when_readback_on(): - player = FakePlayer() - captured = {} - - def fake_synth(api_key, config, *, on_audio): - captured["text"] = config.text - captured["rate"] = config.sample_rate - on_audio(b"pcm", 24000) - return SimpleNamespace(pcm=b"pcm", sample_rate=24000, audio_duration_seconds=0.0) - - session = VoiceSession( - api_key="k", readback=True, synth_fn=fake_synth, player_factory=lambda: player - ) - session.speak(" hello there ") - assert captured["text"] == "hello there" # stripped - assert captured["rate"] == 24000 - assert player.fed == [(b"pcm", 24000)] - - -def test_speak_stops_synthesis_and_aborts_player_when_cancelled(): - player = FakePlayer() - holder = {} - reached_after_cancel = [] - - def fake_synth(api_key, config, *, on_audio): - on_audio(b"one", 24000) # first chunk plays - holder["session"].cancel() # the user interrupts the readback - on_audio(b"two", 24000) # the feed must raise here, ending synthesis - reached_after_cancel.append(True) # so this line is never reached - - session = VoiceSession( - api_key="k", readback=True, synth_fn=fake_synth, player_factory=lambda: player - ) - holder["session"] = session - session.speak("hello there") # returns cleanly — the cancel sentinel is swallowed - assert player.fed == [(b"one", 24000)] # only the pre-cancel chunk played - assert reached_after_cancel == [] # synthesis stopped at the cancelled feed - assert player.exit_exc_type is not None # player saw the exception -> aborted, not drained - - -def test_speak_hands_player_a_live_cancel_poll_for_midchunk_stop(): - # In the TUI the readback plays on a daemon thread, so the only way to stop a chunk - # mid-playback is a flag set from another thread. speak() must hand the player a live - # poll of that flag (not just check it between synth chunks). - seen = {} - holder = {} - - class PollPlayer(FakePlayer): - def feed(self, pcm, sample_rate, *, cancelled=None): - seen["poll"] = cancelled - seen["before"] = cancelled() if cancelled else None - holder["session"].cancel() # another thread interrupts mid-playback - seen["after"] = cancelled() if cancelled else None - super().feed(pcm, sample_rate) - - def fake_synth(api_key, config, *, on_audio): - on_audio(b"chunk", 24000) - - session = VoiceSession( - api_key="k", readback=True, synth_fn=fake_synth, player_factory=PollPlayer - ) - holder["session"] = session - session.speak("hello there") # returns cleanly — the post-chunk cancel is swallowed - assert callable(seen["poll"]) - assert seen["before"] is False # not cancelled when the chunk starts playing - assert seen["after"] is True # the poll reflects a cancel raised mid-playback - - -def test_speak_clears_a_stale_cancel_before_playing(): - # A cancel() left set from a prior interrupt must not abort the next readback before it - # starts — speak() clears the flag on entry, so the chunk plays normally. - player = FakePlayer() - - def fake_synth(api_key, config, *, on_audio): - on_audio(b"pcm", 24000) - - session = VoiceSession( - api_key="k", readback=True, synth_fn=fake_synth, player_factory=lambda: player - ) - session.cancel() # a stale cancel set before this readback - session.speak("hello") - assert player.fed == [(b"pcm", 24000)] # cleared on entry -> the chunk still played - - -def test_speak_is_a_noop_when_readback_off_or_text_blank(): - def boom(*a, **k): - raise AssertionError("synthesize must not be called") - - off = VoiceSession(api_key="k", readback=False, synth_fn=boom, player_factory=FakePlayer) - off.speak("hi") # readback off -> no synthesis - - blank = VoiceSession(api_key="k", readback=True, synth_fn=boom, player_factory=FakePlayer) - blank.speak(" ") # blank text -> no synthesis - - -def test_spoken_summary_strips_code_and_keeps_prose(): - text = ( - "Here's the fix.\n\n```python\ndef f():\n return 1\n```\n\n" - "Call it with `f()` when ready." - ) - summary = spoken_summary(text) - # The fenced block and the inline `f()` are gone; only the prose is read aloud. - assert "def f" not in summary and "return 1" not in summary - assert "`" not in summary - assert summary == "Here's the fix. Call it with when ready." - - -def test_spoken_summary_falls_back_when_reply_is_all_code(): - # A reply that is nothing but a code block leaves no prose -> a generic spoken note, - # never an empty utterance. - assert spoken_summary("```\nprint('hi')\n```") == voicemod._ALL_CODE_READBACK - - -def test_spoken_summary_truncates_long_prose(): - long_prose = "word " * 400 # far over the cap - summary = spoken_summary(long_prose) - assert summary.endswith("…") - assert len(summary) <= voicemod._MAX_SPOKEN_CHARS + 1 # capped prose plus the ellipsis - - -def test_spoken_summary_leaves_short_prose_unchanged(): - # Below the cap: returned verbatim, with no truncation ellipsis appended. - assert spoken_summary("Done — added the flag.") == "Done — added the flag." - - -def test_build_voice_session_readback_tracks_tts_availability(monkeypatch): - monkeypatch.setattr(voicemod.tts_session, "is_available", lambda: True) - assert build_voice_session("k").readback is True - monkeypatch.setattr(voicemod.tts_session, "is_available", lambda: False) - assert build_voice_session("k").readback is False diff --git a/tests/test_live_modals.py b/tests/test_live_modals.py new file mode 100644 index 00000000..7250fc0c --- /dev/null +++ b/tests/test_live_modals.py @@ -0,0 +1,233 @@ +"""Tests for the keyboard approval modal used by the live voice agent TUI. + +The ``ApprovalScreen`` keyboard path is driven through the real Textual app headless. +The voice-answerable path (``approval_from_speech``, ``AskScreen``) lives in +``agent_cascade/modals.py`` and is tested there. +""" + +from __future__ import annotations + +import asyncio + +from textual.widgets import Label + +from aai_cli.agent_cascade.modals import ApprovalScreen +from aai_cli.agent_cascade.tui import LiveAgentApp + + +class _NoOpApp(LiveAgentApp): + """A LiveAgentApp whose cascade worker never starts, so the modal test can drive it directly.""" + + def _start(self) -> None: + pass + + +def _app() -> _NoOpApp: + return _NoOpApp( + run_conversation=lambda renderer: None, + on_stop=lambda: None, + on_toggle_listen=lambda: True, + ) + + +def _run(coro) -> None: + asyncio.run(coro) + + +async def _push_and_wait(app, pilot, screen) -> object: + box: dict[str, object] = {} + app.push_screen(screen, lambda result: box.update(value=result)) + for _ in range(300): + await pilot.pause(0.01) + if "value" in box: + break + return box.get("value", "__pending__") + + +def test_keyboard_y_approves() -> None: + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + result_box: dict[str, object] = {} + app.push_screen( + ApprovalScreen("write_file", {"file_path": "x.py"}), + lambda r: result_box.update(value=r), + ) + await pilot.press("y") + await pilot.pause() + assert result_box.get("value") == "approve" + + _run(go()) + + +def test_keyboard_n_rejects() -> None: + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + result_box: dict[str, object] = {} + app.push_screen( + ApprovalScreen("write_file", {"file_path": "x.py"}), + lambda r: result_box.update(value=r), + ) + await pilot.press("n") + await pilot.pause() + assert result_box.get("value") == "reject" + + _run(go()) + + +def test_keyboard_a_auto_approves() -> None: + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + result_box: dict[str, object] = {} + app.push_screen( + ApprovalScreen("write_file", {"file_path": "x.py"}), + lambda r: result_box.update(value=r), + ) + await pilot.press("a") + await pilot.pause() + assert result_box.get("value") == "auto" + + _run(go()) + + +def test_escape_rejects() -> None: + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + result_box: dict[str, object] = {} + app.push_screen( + ApprovalScreen("execute", {"command": "ls"}), + lambda r: result_box.update(value=r), + ) + await pilot.press("escape") + await pilot.pause() + assert result_box.get("value") == "reject" + + _run(go()) + + +def test_decide_is_idempotent() -> None: + # A double call to _decide must not dismiss twice — the second is ignored. + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + approval: dict[str, object] = {} + screen = ApprovalScreen("execute", {"command": "ls"}) + app.push_screen(screen, lambda r: approval.update(value=r)) + await pilot.pause() + screen._decide("approve") + await pilot.pause() + screen._decide("reject") # ignored: already answered + await pilot.pause() + assert approval["value"] == "approve" + + _run(go()) + + +def test_expand_toggles_detail_markup() -> None: + # The modal opens collapsed (the compact identifying arg only) and ``e`` toggles to the full + # args — a collapsed-by-default start is what keeps a big write from rendering inline on open. + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + screen = ApprovalScreen( + "write_file", {"file_path": "app.py", "content": "PORT = 8080\nDEBUG = 1"} + ) + app.push_screen(screen) + await pilot.pause() + # Collapsed by default: just the identifying path, never the file body. + collapsed = str(screen.query_one("#approvaldetail", Label).render()) + assert "app.py" in collapsed + assert "PORT = 8080" not in collapsed + # Expanded view: pressing e reveals the full args (the content). + await pilot.press("e") + await pilot.pause() + assert "PORT = 8080" in str(screen.query_one("#approvaldetail", Label).render()) + + _run(go()) + + +def test_risky_command_shows_warning() -> None: + # A destructive shell command renders the risk warning label. + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + result = await _push_and_wait( + app, + pilot, + ApprovalScreen("execute", {"command": "rm -rf build/"}), + ) + # The screen was dismissed (keyboard test above confirms the UI path; this just + # drives a press to confirm the warning-label compose path ran without error). + _ = result # dismissed — not the point of this test (the visual golden covers it) + + _run(go()) + + +def test_approval_screen_starts_unanswered() -> None: + # _answered is the double-dismiss guard; it must start False so the first y/a/n decision + # actually dismisses. (A synchronous check so the mutation gate attributes the line here, + # not only to the async keyboard pilots where coverage-context can miss it.) + screen = ApprovalScreen("write_file", {"file_path": "x.py"}) + assert screen._answered is False + + +def test_voice_affirmative_resolves_a_benign_modal_to_approve() -> None: + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + box: dict[str, object] = {} + screen = ApprovalScreen("write_file", {"file_path": "x.py"}) + app.push_screen(screen, lambda r: box.update(value=r)) + await pilot.pause() + screen.try_voice("yes, run it") # spoken approval resolves the open modal + await pilot.pause() + assert box.get("value") == "approve" + + _run(go()) + + +def test_voice_non_affirmative_resolves_a_benign_modal_to_reject() -> None: + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + box: dict[str, object] = {} + screen = ApprovalScreen("write_file", {"file_path": "x.py"}) + app.push_screen(screen, lambda r: box.update(value=r)) + await pilot.pause() + screen.try_voice("hmm what was that") # unrecognized -> fail-safe reject + await pilot.pause() + assert box.get("value") == "reject" + + _run(go()) + + +def test_voice_is_ignored_for_a_destructive_modal() -> None: + # A destructive command ignores a spoken "approve"; the modal stays open until a keypress. + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + box: dict[str, object] = {} + screen = ApprovalScreen("execute", {"command": "rm -rf build"}) + app.push_screen(screen, lambda r: box.update(value=r)) + await pilot.pause() + screen.try_voice("approve") # ignored: destructive tier needs the keyboard + await pilot.pause() + assert "value" not in box # not dismissed by voice + await pilot.press("y") # the keyboard still works + await pilot.pause() + assert box.get("value") == "approve" + + _run(go()) diff --git a/tests/test_code_model.py b/tests/test_live_model.py similarity index 98% rename from tests/test_code_model.py rename to tests/test_live_model.py index 2cc8c70e..51c7d96d 100644 --- a/tests/test_code_model.py +++ b/tests/test_live_model.py @@ -1,6 +1,6 @@ -"""Unit tests for the `assembly code` gateway model wiring (code_agent/model.py). +"""Unit tests for the `assembly live` gateway model wiring (agent_cascade/model.py). -Split out of test_code_agent.py to stay under the 500-line file gate. These cover the +Split out to stay under the 500-line file gate. These cover the ``_GatewayChatOpenAI`` subclass and its helpers that paper over the LLM Gateway's OpenAI-incompatible quirks: content flattening, streamed tool-call id hoisting, dropping the gateway's spurious blank tool-call deltas, and filling empty tool-call arguments. @@ -8,7 +8,7 @@ from __future__ import annotations -from aai_cli.code_agent import model as model_mod +from aai_cli.agent_cascade import model as model_mod from aai_cli.core import environments diff --git a/tests/test_code_risk.py b/tests/test_live_risk.py similarity index 93% rename from tests/test_code_risk.py rename to tests/test_live_risk.py index 40f24658..4b4d348b 100644 --- a/tests/test_code_risk.py +++ b/tests/test_live_risk.py @@ -1,10 +1,10 @@ -"""Tests for the approval-prompt risk heuristics (`aai_cli.code_agent.risk`).""" +"""Tests for the approval-prompt risk heuristics (`aai_cli.agent_cascade.risk`).""" from __future__ import annotations import pytest -from aai_cli.code_agent.risk import risk_warning +from aai_cli.agent_cascade.risk import risk_warning @pytest.mark.parametrize( diff --git a/tests/test_code_summarize.py b/tests/test_live_summarize.py similarity index 94% rename from tests/test_code_summarize.py rename to tests/test_live_summarize.py index ebf0eb24..7b329993 100644 --- a/tests/test_code_summarize.py +++ b/tests/test_live_summarize.py @@ -1,12 +1,12 @@ -"""Tests for the shared tool-activity summarizers (`aai_cli.code_agent.summarize`). +"""Tests for the shared tool-activity summarizers (`aai_cli.agent_cascade.summarize`). -These keep the coding-agent transcript scannable: a tool call shows its identifying arg +These keep the live-agent transcript scannable: a tool call shows its identifying arg (not the whole file being written), and tool output is previewed with a hidden-line tail. """ from __future__ import annotations -from aai_cli.code_agent.summarize import ( +from aai_cli.agent_cascade.summarize import ( describe_args, full_args, summarize_call, diff --git a/tests/test_live_tui.py b/tests/test_live_tui.py index a7c939df..b02dae76 100644 --- a/tests/test_live_tui.py +++ b/tests/test_live_tui.py @@ -13,14 +13,15 @@ from textual.widgets import Static -from aai_cli.agent_cascade.tui import LiveAgentApp, _TuiRenderer -from aai_cli.code_agent.messages import ( +from aai_cli.agent_cascade.messages import ( AssistantMessage, ErrorMessage, Note, ToolAffordance, UserMessage, ) +from aai_cli.agent_cascade.modals import ApprovalScreen +from aai_cli.agent_cascade.tui import LiveAgentApp, _TuiRenderer from aai_cli.core.errors import CLIError @@ -41,7 +42,7 @@ async def loop() -> bool: return loop() -def _app(run_conversation=None, on_stop=None, web_note=None): +def _app(run_conversation=None, on_stop=None, on_toggle_listen=None, web_note=None): """A LiveAgentApp whose worker stays alive for the test, releasing on teardown. The real ``run_conversation`` blocks on the live mic; the default here blocks on an event @@ -61,6 +62,7 @@ def block(renderer) -> None: return LiveAgentApp( run_conversation=run_conversation or block, on_stop=stop, + on_toggle_listen=on_toggle_listen or (lambda: True), web_note=web_note, ) @@ -164,6 +166,66 @@ async def go() -> None: _run(go()) +def test_begin_reply_defers_the_widget_so_the_answer_lands_below_the_tools() -> None: + # The reply widget is mounted lazily on the first streamed sentence, never eagerly at + # begin_reply — so a tool call that fires *after* the reply starts (begin_reply runs during + # the first tool's spoken filler) still lands above the answer, and there's no empty + # placeholder widget sitting in the gap. This is the live tool-call ordering fix. + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + # Mirror the engine's call order for a two-tool turn (see _handle_tool_notice). + app.show_tool_call("Checking the weather") # tool 1 + app.begin_reply() # reply_started fires during tool 1's filler + assert len(app.query(AssistantMessage)) == 0 # nothing mounted yet — deferred + assert "Speaking" in _voicebar(app) # but the phase still flips to speaking + app.show_tool_call("Checking the weather") # tool 2, after the reply "started" + app.show_agent_sentence("It's 87 degrees and clear.") # the answer, flushed last + # The transcript order is the two tool affordances, then the answer — never the + # answer wedged between them. + log = app.query_one("#log") + kinds = [ + type(w).__name__ + for w in log.children + if isinstance(w, ToolAffordance | AssistantMessage) + ] + assert kinds == ["ToolAffordance", "ToolAffordance", "AssistantMessage"] + assert app.query_one(AssistantMessage).text == "It's 87 degrees and clear. " + + _run(go()) + + +def test_reply_after_greeting_is_a_separate_widget_below_the_tool() -> None: + # The greeting streams through show_agent_sentence with no reply_done after it, so _reply_msg + # still points at the greeting when the first turn begins. begin_reply must drop it, so the + # answer opens its OWN widget (below the tool affordance) instead of being appended to the + # greeting line. Regression guard for the greeting+answer concatenation bug. + async def go() -> None: + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + app.show_agent_sentence("Hi! What can I help you with?") # the greeting (no reply_done) + app.show_user_final("what files are here?") + app.show_tool_call("Listing files") + app.begin_reply() # the turn starts: must drop the greeting widget + app.show_agent_sentence("The directory is empty.") # the answer — its own widget + replies = list(app.query(AssistantMessage)) + assert len(replies) == 2 # greeting and answer are distinct widgets, not concatenated + assert replies[0].text == "Hi! What can I help you with? " + assert replies[1].text == "The directory is empty. " + # The answer widget mounts below the tool affordance (tools never under the answer). + log = app.query_one("#log") + tail = [ + type(w).__name__ + for w in log.children + if isinstance(w, ToolAffordance | AssistantMessage) + ] + assert tail[-2:] == ["ToolAffordance", "AssistantMessage"] + + _run(go()) + + def test_interrupted_reply_notes_the_barge_in() -> None: async def go() -> None: app = _app() @@ -238,6 +300,39 @@ def hook() -> bool: _run(go()) +def test_space_toggles_listening_and_paints_paused() -> None: + # Space starts/stops listening: it drives the duplex mic mute (the returned state) and + # repaints the voice bar to "Paused" while muted, then back to "Listening" on resume. + async def go() -> None: + state = {"on": True} + + def toggle() -> bool: + state["on"] = not state["on"] + return state["on"] + + app = _app(on_toggle_listen=toggle) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + assert "Listening" in _voicebar(app) # opens listening + await pilot.press("space") # the Space binding -> action_toggle_listen -> stop + # Read into locals: `state` is mutated opaquely through the Textual binding, which + # mypy can't see, so asserting `state["on"] is …` directly narrows it for the rest + # of the scope and makes the later resume assertions look unreachable. + muted, muted_flag = state["on"], app._listening + assert muted is False and muted_flag is False # mic muted + assert "Paused" in _voicebar(app) # muted shows paused, not listening + # Muting only gates the user's input: a reply still in flight keeps "Speaking". + app._set_phase("speaking") + assert "Speaking" in _voicebar(app) and "Paused" not in _voicebar(app) + app._set_phase("listening") + await pilot.press("space") # resume listening + resumed, resumed_flag = state["on"], app._listening + assert resumed is True and resumed_flag is True + assert "Listening" in _voicebar(app) + + _run(go()) + + def test_ctrl_c_interrupts_a_playing_reply_without_quitting(monkeypatch) -> None: # While a reply is playing (the hook returns True), Ctrl-C interrupts it and stays — it # must NOT quit, so a long answer can be cut off without ending the session. @@ -369,3 +464,34 @@ def test_tui_renderer_drops_calls_after_the_app_stops() -> None: renderer = _TuiRenderer(app) renderer.user_final("ignored") # returns without raising renderer.reply_done(interrupted=False) + + +def test_submit_voice_approval_feeds_an_open_modal_and_no_ops_otherwise() -> None: + # During a --files approval pause the engine routes the next final transcript here from the + # STT reader thread; it must hop to the UI thread and feed the open ApprovalScreen's try_voice. + # With no modal open it's a safe no-op (a stray transcript before/after the pause is dropped). + async def go() -> None: + heard: list[str] = [] + + class _SpyApproval(ApprovalScreen): + def try_voice(self, transcript: str) -> None: + heard.append(transcript) + + app = _app() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + # No modal open -> the None guard short-circuits, nothing is routed. + no_modal = threading.Thread(target=app.submit_voice_approval, args=("nothing open",)) + no_modal.start() + no_modal.join() + # Open a modal on the UI thread, then resolve it by voice from a worker thread (the hop + # call_from_thread requires a non-UI thread). + app._approval_screen = _SpyApproval("write_file", {"file_path": "x.py"}) + worker = threading.Thread(target=app.submit_voice_approval, args=("yes, run it",)) + worker.start() + assert await _wait_until(pilot, lambda: heard == ["yes, run it"]) + worker.join() + # Only the open-modal call reached try_voice; the no-modal call added nothing. + assert heard == ["yes, run it"] + + _run(go()) diff --git a/tests/test_code_tui_status.py b/tests/test_live_tui_status.py similarity index 51% rename from tests/test_code_tui_status.py rename to tests/test_live_tui_status.py index 9d6de031..59cead6e 100644 --- a/tests/test_code_tui_status.py +++ b/tests/test_live_tui_status.py @@ -1,4 +1,4 @@ -"""Tests for the coding-agent TUI's pure status/text helpers (`tui_status`). +"""Tests for the live voice-agent TUI's pure text helpers (`tui_status`). Split from test_code_tui.py (which drives the Textual app) to keep each file under the 500-line gate; these need no pilot, just the plain functions. @@ -6,40 +6,12 @@ from __future__ import annotations -from pathlib import Path - import pyperclip -from aai_cli.code_agent import tui_status +from aai_cli.agent_cascade import tui_status from aai_cli.ui import theme -def test_spinner_text_formats_frame_and_elapsed() -> None: - assert tui_status._spinner_text(46, "✶") == "✶ Working… (46s)" - assert tui_status._spinner_text(0, "✷") == "✷ Working… (0s)" - - -def test_abbrev_home() -> None: - assert tui_status._abbrev_home(Path.home() / "proj") == "~/proj" - # A path outside home renders as-is; compare to the platform-native string so this - # holds on Windows (where str(Path(...)) uses backslashes) as well as POSIX. - outside = Path("/etc/hosts") - assert tui_status._abbrev_home(outside) == str(outside) - - -def test_git_branch_and_status(tmp_path: Path) -> None: - assert tui_status._git_branch(tmp_path) is None # no .git - (tmp_path / ".git").mkdir() - (tmp_path / ".git" / "HEAD").write_text("ref: refs/heads/feature-x\n") - assert tui_status._git_branch(tmp_path) == "feature-x" - (tmp_path / ".git" / "HEAD").write_text("a1b2c3d4e5f6\n") # detached - assert tui_status._git_branch(tmp_path) == "a1b2c3d4" - - status = tui_status._status_text(tmp_path, auto_approve=True) - assert "auto" in status and "a1b2c3d4" in status - assert "manual" in tui_status._status_text(tmp_path, auto_approve=False) - - def test_voicebar_markup_per_phase_carries_label_meter_accent_and_hint() -> None: # Each phase renders its own label + accent color; the meter frame and any trailing hint # are passed through verbatim. Assert the literal accents (not the dict value) so a mutated @@ -51,6 +23,13 @@ def test_voicebar_markup_per_phase_carries_label_meter_accent_and_hint() -> None assert "Thinking" in thinking and "#f59e0b" in thinking # amber, no hint speaking = tui_status.voicebar_markup("speaking", "▅▇▆") assert "Speaking" in speaking and "#22c55e" in speaking # green + # `live`'s muted-mic state (Space stops listening): a dim grey "Paused" with a resume hint. + paused = tui_status.voicebar_markup("paused", "▁▃▅") + assert "Paused" in paused and "resume listening" in paused and "#6b7280" in paused + # A paused mic shows a flat at-rest meter, not the animated frame it was handed. Assert the + # literal "▁▁▁" (not tui_status.VOICE_FLAT, which would mutate in lockstep) so the override + # and the constant are both pinned. + assert "▁▁▁" in paused and "▁▃▅" not in paused def test_copy_note_copies_and_confirms() -> None: @@ -89,27 +68,3 @@ def test_keyhints_lists_shortcuts_and_gates_voice_on_availability() -> None: without_voice = tui_status.keyhints_text(voice=False) assert "voice" not in without_voice # no Ctrl-V hint without a voice front-end assert "copy" in without_voice and "quit" in without_voice - - -def test_status_text_appends_the_key_legend(tmp_path: Path) -> None: - # The footer is two rows: the status info, then the dim key legend beneath it. - footer = tui_status._status_text(tmp_path, auto_approve=False) - info, _, hints = footer.partition("\n") - assert "manual" in info # row one is the status info - assert "quit" in hints and "copy" in hints # row two is the key legend - assert "voice" not in hints # no voice front-end -> the legend omits the Ctrl-V hint - # With a voice front-end the legend's second row gains the Ctrl-V hint. - voiced = tui_status._status_text(tmp_path, auto_approve=False, voice_state="on") - assert "voice" in voiced.partition("\n")[2] - - -def test_status_text_renders_voice_badge(tmp_path: Path) -> None: - # No voice front-end -> no voice badge (the dot glyphs are absent); on/off render the - # state so the Ctrl-V toggle shows. (Asserts on the dots, not the word — the tmp_path name - # itself can contain "voice".) - none = tui_status._status_text(tmp_path, auto_approve=False) - assert "●" not in none and "○" not in none - on = tui_status._status_text(tmp_path, auto_approve=False, voice_state="on") - off = tui_status._status_text(tmp_path, auto_approve=False, voice_state="off") - assert "voice on" in on and "●" in on # filled dot when on - assert "voice off" in off and "○" in off # hollow dot when off diff --git a/tests/test_live_tui_launch.py b/tests/test_live_tui_wiring.py similarity index 62% rename from tests/test_live_tui_launch.py rename to tests/test_live_tui_wiring.py index 24c984b3..42da17be 100644 --- a/tests/test_live_tui_launch.py +++ b/tests/test_live_tui_wiring.py @@ -1,10 +1,15 @@ -"""Tests for run_agent_cascade's TUI selection + wiring. +"""Wiring tests for `assembly live`: TUI selection + command integration. -Split out of test_live_tui.py to keep both files under the 500-line file-length gate. +Covers run_agent_cascade's TUI-vs-fallback selection, the firecrawl web-search +note, interactive-human launch, keyboard-interrupt exit, the worker-driven +run_conversation path, fatal-leg-error propagation, and the --files approve-write +modal wiring. Pilot helpers are reused from tests/test_live_tui. """ from __future__ import annotations +import asyncio +import threading import types import pytest @@ -17,6 +22,7 @@ from aai_cli.core import config, stdio from aai_cli.core.errors import CLIError from tests.test_agent_cascade_command import _opts +from tests.test_live_tui import _app # --- run_agent_cascade -> TUI selection + wiring ----------------------------- @@ -47,7 +53,9 @@ def _wire_tui(monkeypatch): monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k") monkeypatch.setattr(stdio, "stdout_is_tty", lambda: True) monkeypatch.setattr(stdio, "stdin_is_tty", lambda: True) - fake_duplex = types.SimpleNamespace(mic=object(), player=object(), close=lambda: None) + fake_duplex = types.SimpleNamespace( + mic=object(), player=object(), close=lambda: None, toggle_listening=lambda: True + ) monkeypatch.setattr(_exec, "DuplexAudio", lambda **kwargs: fake_duplex) monkeypatch.setattr(engine.CascadeDeps, "real", lambda *a, **k: "deps") return fake_duplex @@ -61,9 +69,10 @@ def test_interactive_human_run_launches_the_tui(monkeypatch) -> None: class FakeApp: error = None # no fatal leg failure -> the launcher re-raises nothing - def __init__(self, *, run_conversation, on_stop, web_note): + def __init__(self, *, run_conversation, on_stop, on_toggle_listen, web_note): captured["run_conversation"] = run_conversation captured["on_stop"] = on_stop + captured["on_toggle_listen"] = on_toggle_listen def run(self, **kwargs): captured["ran"] = kwargs @@ -76,6 +85,8 @@ def run(self, **kwargs): run_agent_cascade(_opts(), AppState(), json_mode=False) assert callable(captured["run_conversation"]) # the TUI was launched with a cascade closure assert captured["on_stop"] is fake_duplex.close # quit closes the audio + # Space toggles listening through the duplex's in-place mic mute (no reconnect). + assert captured["on_toggle_listen"] is fake_duplex.toggle_listening assert captured["ran"] == {"mouse": False} # mouse off so transcript text stays selectable @@ -109,7 +120,7 @@ def fake_run_cascade(**kw): class FakeApp: error = None # the conversation completes cleanly here - def __init__(self, *, run_conversation, on_stop, web_note): + def __init__(self, *, run_conversation, on_stop, on_toggle_listen, web_note): self._rc = run_conversation def run(self, **kwargs): @@ -118,6 +129,9 @@ def run(self, **kwargs): def set_interrupt(self, interrupt): captured["interrupt"] = interrupt + def submit_voice_approval(self, transcript): # the engine's spoken-approval sink + captured["voice"] = transcript + monkeypatch.setattr("aai_cli.agent_cascade.tui.LiveAgentApp", FakeApp) run_agent_cascade(_opts(), AppState(), json_mode=False) assert captured["player"] is fake_duplex.player @@ -125,6 +139,8 @@ def set_interrupt(self, interrupt): assert captured["renderer"] == "renderer-sentinel" # The session's interrupt_reply was wired onto the app (so Escape/Ctrl-C can use it). assert captured["interrupt"] == "session-interrupt" + # The app's spoken-approval sink is wired so the engine can resolve a write by voice. + assert getattr(captured["on_approval_voice"], "__name__", "") == "submit_voice_approval" def test_tui_reraises_a_fatal_leg_error_for_the_exit_code(monkeypatch) -> None: @@ -137,7 +153,7 @@ def test_tui_reraises_a_fatal_leg_error_for_the_exit_code(monkeypatch) -> None: class FakeApp: error = boom # the worker thread recorded a fatal cascade error - def __init__(self, *, run_conversation, on_stop, web_note): + def __init__(self, *, run_conversation, on_stop, on_toggle_listen, web_note): pass def run(self, **kwargs): @@ -147,3 +163,70 @@ def run(self, **kwargs): with pytest.raises(CLIError) as exc: run_agent_cascade(_opts(), AppState(), json_mode=False) assert exc.value is boom + + +def _drive_approval(app, keys): + """Run app.approve_write on a thread and dismiss the pushed modal with ``keys``.""" + box: dict[str, object] = {} + + async def go(): + thread = threading.Thread( + target=lambda: box.update( + result=app.approve_write("write_file", {"file_path": "n.txt"}) + ) + ) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + thread.start() + for _ in range(200): + await pilot.pause(0.01) + if len(app.screen_stack) > 1: # the ApprovalScreen mounted + break + await pilot.press(*keys) + thread.join(timeout=3) + await pilot.pause() + return box.get("result") + + return asyncio.run(go()) + + +def test_approve_write_modal_y_approves_and_n_rejects(): + # The --files write gate pauses the turn on a bottom-docked modal; y allows, n declines. + assert _drive_approval(_app(), ["y"]) is True + assert _drive_approval(_app(), ["n"]) is False + + +def test_approve_write_auto_latches_and_skips_later_prompts(): + app = _app() + # "a" (auto) approves this write and latches, so a later write needs no modal at all. + assert _drive_approval(app, ["a"]) is True + assert app.approve_write("edit_file", {"file_path": "b.txt"}) is True + + +def test_tui_path_wires_app_approve_write(monkeypatch) -> None: + # The TUI launch must hand CascadeDeps.real an approver that delegates to the live app's + # approve_write (the y/n modal), so a gated --files write is confirmed by keypress. + _wire_tui(monkeypatch) + captured: dict[str, object] = {} + + def capture_real(*_a, approver=None, **_k): + captured["approver"] = approver + return "deps" + + monkeypatch.setattr(engine.CascadeDeps, "real", capture_real) + + class FakeApp: + error = None + + def __init__(self, **_kw): + self.approve_write = lambda name, args: ("routed", name) + + def run(self, **_kw): + pass + + monkeypatch.setattr("aai_cli.agent_cascade.tui.LiveAgentApp", FakeApp) + run_agent_cascade(_opts(files=True), AppState(), json_mode=False) + # The approver routes straight to the app's approve_write. + approver = captured["approver"] + assert callable(approver) + assert approver("write_file", {}) == ("routed", "write_file") diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a66e2929..334a3c55 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -150,8 +150,6 @@ def test_help_lists_commands_in_workflow_order(): assert names == [ # Quick Start "onboard", - # Coding Agent - "code", # Build an App "init", "dev", diff --git a/tests/test_transcribe_show_code.py b/tests/test_transcribe_show_code.py index c5ce4ee1..24c18dac 100644 --- a/tests/test_transcribe_show_code.py +++ b/tests/test_transcribe_show_code.py @@ -128,6 +128,31 @@ def _boom(*a, **k): assert 'print(f"Speaker {utt.speaker}: {utt.text}")' in result.output +def test_transcribe_show_code_defaults_to_universal_3_5_pro(monkeypatch): + # With no --speech-model, the batch request defaults to universal-3-5-pro, + # delivered via the plural speech_models list (it's not in the SpeechModel enum). + def _boom(*a, **k): + raise AssertionError("must not transcribe") + + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", _boom) + result = runner.invoke(app, ["transcribe", "--sample", "--show-code"]) + assert result.exit_code == 0 + assert "speech_models=['universal-3-5-pro']" in result.output + + +def test_transcribe_show_code_explicit_model_suppresses_default(monkeypatch): + # An explicit --speech-model wins: the singular speech_model is emitted and the + # universal-3-5-pro speech_models default is not injected alongside it. + def _boom(*a, **k): + raise AssertionError("must not transcribe") + + monkeypatch.setattr("aai_cli.app.transcribe.run.client.transcribe", _boom) + result = runner.invoke(app, ["transcribe", "--sample", "--speech-model", "best", "--show-code"]) + assert result.exit_code == 0 + assert "speech_model='best'" in result.output + assert "speech_models" not in result.output + + def test_transcribe_show_code_rejects_bucket_urls(monkeypatch): # Generated SDK code can't fetch s3://-style URLs (the API only reads http(s)), # so a bucket source is rejected up front instead of emitting a broken script. diff --git a/tests/test_tui_snapshots.py b/tests/test_tui_snapshots.py index e06e4e59..53e39a10 100644 --- a/tests/test_tui_snapshots.py +++ b/tests/test_tui_snapshots.py @@ -1,11 +1,10 @@ -"""Visual-regression snapshots for the `assembly code` and `assembly live` Textual TUIs. +"""Visual-regression snapshots for the `assembly live` Textual TUI. -Each test renders an app (or a pushed modal) to an SVG via ``pytest-textual-snapshot``'s -``snap_compare`` fixture and diffs it against a committed golden under -``tests/__snapshots__/test_tui_snapshots/``. This pins the *painted frame* — the splash, the -prompt bar, the docked status line, the voice bar, the message widgets, and the compact -approval/ask modals — so a CSS, layout, or docking regression that the per-widget pilot tests -(``test_code_tui.py`` / ``test_live_tui.py``) can't see fails loudly here instead. +Each test renders the app to an SVG via ``pytest-textual-snapshot``'s ``snap_compare`` +fixture and diffs it against a committed golden under +``tests/__snapshots__/test_tui_snapshots/``. This pins the *painted frame* — the splash, +the voice bar, and the message widgets — so a CSS, layout, or docking regression that the +per-widget pilot tests (``test_live_tui.py``) can't see fails loudly here instead. Regenerate after an intentional UI change with ``uv run pytest tests/test_tui_snapshots.py --snapshot-update`` and **eyeball every changed SVG** before committing — a snapshot only @@ -19,25 +18,14 @@ from typing import TYPE_CHECKING import pytest -from textual.widgets import Static from aai_cli.agent_cascade.tui import LiveAgentApp -from aai_cli.code_agent.events import AssistantDelta, AssistantText, ErrorText, ToolCall, ToolResult -from aai_cli.code_agent.messages import UserMessage -from aai_cli.code_agent.modals import ApprovalScreen, AskScreen -from aai_cli.code_agent.tui import _SPIN_FRAMES, CodeAgentApp -from aai_cli.code_agent.tui_status import _spinner_text from tests import _tui_snapshot as h if TYPE_CHECKING: from textual.pilot import Pilot -# More than the 4-line preview budget, so summarize_result clips it and the ToolOutput -# row becomes expandable — the collapsed/expanded snapshots below pin both states. -_LONG_OUTPUT = "\n".join(f"tests/test_module_{i}.py .... [ {i * 10}%]" for i in range(8)) - - @pytest.fixture(autouse=True) def _pin_version(monkeypatch: pytest.MonkeyPatch) -> None: h.pin_banner_version(monkeypatch) @@ -48,223 +36,6 @@ def test_fake_agent_returns_empty_state() -> None: assert h.FakeAgent().invoke("prompt") == {} -def test_fake_voice_is_inert() -> None: - """The voice double satisfies _VoiceIO without capturing or speaking anything.""" - voice = h.FakeVoice() - assert voice.listen() is None - voice.speak("hello") - voice.cancel() - - -# --- assembly code ----------------------------------------------------------- - - -def test_code_splash(snap_compare, tmp_path, monkeypatch) -> None: - """The idle startup frame: ASSEMBLY wordmark splash, prompt bar, and `manual` status line.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - h.freeze_animation(pilot.app) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_status_auto_approve(snap_compare, tmp_path, monkeypatch) -> None: - """Auto-approve flips the bottom badge from `manual` to `auto` — a one-glyph status diff.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - h.freeze_animation(pilot.app) - - assert snap_compare( - h.build_code_app(cwd=cwd, auto_approve=True), - terminal_size=h.TERMINAL_SIZE, - run_before=run_before, - ) - - -def test_code_transcript(snap_compare, tmp_path, monkeypatch) -> None: - """A populated transcript: the user echo, a Markdown reply, a tool-call line, tool output.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - app = pilot.app - assert isinstance(app, CodeAgentApp) - h.freeze_animation(app) - app._mount(UserMessage("add a /health endpoint")) - app._write_event(AssistantText("Adding a **health check**:\n\n1. New route\n2. A test")) - app._write_event(ToolCall(name="write_file", args={"file_path": "app.py"})) - app._write_event(ToolResult(name="write_file", content="wrote 8 lines to app.py")) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_approval_modal(snap_compare, tmp_path, monkeypatch) -> None: - """The compact, bottom-docked approval prompt for a risky command (warning + y/a/n hint).""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - h.freeze_animation(pilot.app) - pilot.app.push_screen(ApprovalScreen("execute", {"command": "rm -rf build/"})) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_ask_modal(snap_compare, tmp_path, monkeypatch) -> None: - """The bottom-docked ask prompt: the agent's question above a text input.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - h.freeze_animation(pilot.app) - pilot.app.push_screen(AskScreen("Which port should the dev server use?")) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_approval_modal_expanded(snap_compare, tmp_path, monkeypatch) -> None: - """`e` expands the approval prompt from the identifying arg to the full args. - - Collapsed, a write_file call shows only the filename; expanded, it reveals the file - content that was elided — a taller box, pinned so the reveal can't regress. - """ - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - h.freeze_animation(pilot.app) - pilot.app.push_screen( - ApprovalScreen( - "write_file", {"file_path": "app.py", "content": "PORT = 8080\nDEBUG = 1"} - ) - ) - - assert snap_compare( - h.build_code_app(cwd=cwd), press=["e"], terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_tool_output_collapsed(snap_compare, tmp_path, monkeypatch) -> None: - """Long tool output clips to a preview with a `(Ctrl+O to expand)` hint.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - app = pilot.app - assert isinstance(app, CodeAgentApp) - h.freeze_animation(app) - app._mount(UserMessage("run the tests")) - app._write_event(ToolCall(name="execute", args={"command": "pytest -q"})) - app._write_event(ToolResult(name="execute", content=_LONG_OUTPUT)) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_tool_output_expanded(snap_compare, tmp_path, monkeypatch) -> None: - """Ctrl+O expands the clipped tool output to the full content with a collapse hint.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - app = pilot.app - assert isinstance(app, CodeAgentApp) - h.freeze_animation(app) - app._mount(UserMessage("run the tests")) - app._write_event(ToolCall(name="execute", args={"command": "pytest -q"})) - app._write_event(ToolResult(name="execute", content=_LONG_OUTPUT)) - await pilot.pause() # let the ToolOutput mount before toggling it - app.action_toggle_output() # Ctrl+O - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_working_spinner(snap_compare, tmp_path, monkeypatch) -> None: - """The working indicator: a spinner glyph + elapsed seconds, docked just above the prompt.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - app = pilot.app - assert isinstance(app, CodeAgentApp) - h.freeze_animation(app) - app._mount(UserMessage("build a web scraper")) - spinner = app.query_one("#spinner", Static) - spinner.display = True - # Render a fixed elapsed/frame through the real formatter — driving the live _tick - # would tie the readout to wall-clock timing and flake. - spinner.update(_spinner_text(7, _SPIN_FRAMES[0])) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_streaming_reply(snap_compare, tmp_path, monkeypatch) -> None: - """A reply mid-stream is plain text (literal markdown) before finalize swaps it to Markdown.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - app = pilot.app - assert isinstance(app, CodeAgentApp) - h.freeze_animation(app) - app._mount(UserMessage("explain the plan")) - app._write_event(AssistantDelta("Here's the plan. First **scaffold** the project, ")) - app._write_event(AssistantDelta("then wire up the tests.")) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_approval_modal_benign(snap_compare, tmp_path, monkeypatch) -> None: - """A benign command mounts no warning label — the no-warning variant of the approval prompt.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - h.freeze_animation(pilot.app) - pilot.app.push_screen(ApprovalScreen("execute", {"command": "ls -la"})) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_error(snap_compare, tmp_path, monkeypatch) -> None: - """A failed turn renders as a red ✗ error line instead of crashing the UI.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - app = pilot.app - assert isinstance(app, CodeAgentApp) - h.freeze_animation(app) - app._mount(UserMessage("deploy to prod")) - app._write_event(ErrorText("gateway unreachable: connection refused")) - - assert snap_compare( - h.build_code_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - -def test_code_voice_listening(snap_compare, tmp_path, monkeypatch) -> None: - """Voice mode swaps the prompt for the listening bar (with a Ctrl-V hint) and shows the - green `● voice on` status badge — the whole alternate-input chrome.""" - cwd = h.stable_workdir(tmp_path, monkeypatch) - - async def run_before(pilot: Pilot[None]) -> None: - h.freeze_animation(pilot.app) - - assert snap_compare( - h.build_code_voice_app(cwd=cwd), terminal_size=h.TERMINAL_SIZE, run_before=run_before - ) - - # --- assembly live ----------------------------------------------------------- @@ -317,6 +88,20 @@ async def run_before(pilot: Pilot[None]) -> None: assert snap_compare(h.build_live_app(), terminal_size=h.TERMINAL_SIZE, run_before=run_before) +def test_live_paused(snap_compare) -> None: + """A muted mic (Space stops listening) shows a flat, non-animating meter and a grey + `Paused` label, so a paused session reads as idle rather than actively listening.""" + + async def run_before(pilot: Pilot[None]) -> None: + app = pilot.app + assert isinstance(app, LiveAgentApp) + h.freeze_animation(app) + app._listening = False # Space muted the mic while idle -> the paused phase + app._render_voicebar() + + assert snap_compare(h.build_live_app(), terminal_size=h.TERMINAL_SIZE, run_before=run_before) + + def test_live_tool_call_note(snap_compare) -> None: """Tool calls mid-turn show the friendly label plus its identifying detail; the block is lifted off the prompt by a blank line, and a consecutive call stays tight beneath it.""" @@ -332,6 +117,28 @@ async def run_before(pilot: Pilot[None]) -> None: assert snap_compare(h.build_live_app(), terminal_size=h.TERMINAL_SIZE, run_before=run_before) +def test_live_tool_then_answer_ordering(snap_compare) -> None: + """A tool-using turn: the answer lands *below* every tool affordance, with no empty gap. + + Mirrors the engine's call order — ``reply_started`` (begin_reply) fires during the first + tool's spoken filler, so a second tool call follows it before the answer streams. The reply + widget is deferred to the first sentence, so it mounts beneath both tool lines rather than + wedging between them with an empty placeholder in the gap (the live tool-call ordering fix).""" + + async def run_before(pilot: Pilot[None]) -> None: + app = pilot.app + assert isinstance(app, LiveAgentApp) + h.freeze_animation(app) + app.show_user_final("what's the weather like in Boston?") + app.show_tool_call("Checking the weather · Boston") + app.begin_reply() # fires during the first tool's filler — must not mount the reply yet + app.show_tool_call("Checking the weather · Boston forecast") + app.show_agent_sentence("It's sunny and about sixty degrees right now.") + h.freeze_animation(app) # begin_reply switched the phase, which repainted the bar + + assert snap_compare(h.build_live_app(), terminal_size=h.TERMINAL_SIZE, run_before=run_before) + + def test_live_interrupted(snap_compare) -> None: """An interrupted reply is finalized and tagged `(interrupted)`, then returns to listening.""" diff --git a/uv.lock b/uv.lock index ec69ea98..4bb06320 100644 --- a/uv.lock +++ b/uv.lock @@ -28,13 +28,13 @@ dependencies = [ { name = "httpx2" }, { name = "jiwer" }, { name = "keyring" }, + { name = "langchain" }, { name = "langchain-core" }, { name = "langchain-firecrawl" }, { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langchain-text-splitters" }, { name = "langgraph" }, - { name = "langgraph-checkpoint-sqlite" }, { name = "openai" }, { name = "packaging" }, { name = "platformdirs" }, @@ -86,7 +86,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "assemblyai", specifier = ">=0.64.4" }, + { name = "assemblyai", specifier = ">=0.64.21" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2" }, { name = "deepagents", specifier = ">=0.6.10" }, { name = "feedparser", specifier = ">=6.0.11" }, @@ -94,13 +94,13 @@ requires-dist = [ { name = "httpx2", specifier = ">=2.0.0" }, { name = "jiwer", specifier = ">=4.0" }, { name = "keyring", specifier = ">=25.7.0" }, + { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.4.7" }, { name = "langchain-firecrawl", specifier = ">=0.1.0" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=1.3.2" }, { name = "langchain-text-splitters", specifier = ">=1.0.0" }, { name = "langgraph", specifier = ">=1.2.2" }, - { name = "langgraph-checkpoint-sqlite", specifier = ">=3.1.0" }, { name = "openai", specifier = ">=2.41.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.10.0" }, @@ -272,15 +272,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] -[[package]] -name = "aiosqlite" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -333,7 +324,7 @@ wheels = [ [[package]] name = "assemblyai" -version = "0.64.4" +version = "0.64.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -342,9 +333,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/b7/e3e515476c3589cbf18b3b935fc869ba4e7c4fddde1bf697d8f27440dae2/assemblyai-0.64.4.tar.gz", hash = "sha256:f0d8d17d083bed93fc90e5494e8bd7546fdab3c3c96092761495fecd53c25ed2", size = 71630, upload-time = "2026-05-28T18:45:22.094Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/2e/51726cb411af2972336c754742234a8113401d5e047afbe9395bb414378c/assemblyai-0.64.21.tar.gz", hash = "sha256:51c650c601c8be4bad8a20f3bbc3619f6f914fb6bc66ed6b867eb7c80b341873", size = 93156, upload-time = "2026-06-16T22:13:43.301Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/60/218ccc68b665a64b876507bf926179aff07f46091c1db9f63fdc902e86c7/assemblyai-0.64.4-py3-none-any.whl", hash = "sha256:ba5c1eba9e5b9aa87c99e4be12eee0a2f81ae56bb9798f3d36a30fec99cc5205", size = 63127, upload-time = "2026-05-28T18:45:20.722Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e1/a107d6824dc80894fcc1b6c5324a638994509955d50d2ca92c107cc02b8c/assemblyai-0.64.21-py3-none-any.whl", hash = "sha256:3604635990ec4d95878879e816d4300df13e6507c566054d2cee39a6c595ebae", size = 81832, upload-time = "2026-06-16T22:13:41.937Z" }, ] [[package]] @@ -1716,20 +1707,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, ] -[[package]] -name = "langgraph-checkpoint-sqlite" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiosqlite" }, - { name = "langgraph-checkpoint" }, - { name = "sqlite-vec" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ea/83917c2369acf8a10a894d4247655fd063c07924ba5bc4e83c85d2eaeded/langgraph_checkpoint_sqlite-3.1.0.tar.gz", hash = "sha256:f926916ebc1b985d802cc9c820026036e84db9d910d62c97b57e4ba64f67d5ae", size = 147902, upload-time = "2026-05-12T03:34:52.503Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/07/b342811a16327900af2747c752ea19676172fcddf9b592cc384031076623/langgraph_checkpoint_sqlite-3.1.0-py3-none-any.whl", hash = "sha256:cc9b40df0076feae8a9ad42ae713621b148b00ac23adc09dc1dc66090a46e5ad", size = 38587, upload-time = "2026-05-12T03:34:51.231Z" }, -] - [[package]] name = "langgraph-prebuilt" version = "1.1.0" @@ -3404,18 +3381,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, ] -[[package]] -name = "sqlite-vec" -version = "0.1.9" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, - { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, - { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, -] - [[package]] name = "sse-starlette" version = "3.4.4"