Skip to content
Back to skills

Manual E2e Qa

BSecurity

Manual end-to-end QA playbook for decode against a real provider — what to type at each surface (chat, gated read/write, bash, todo, web_fetch, ask_user, lsp, agent subagents, docker/modal sandboxing, headless `decode run`, model override, a benchmark trial, the local Kitaru server, session recording, worker replay, mid-turn steer/abort, persistence/memory, /ship, sandbox git token) and what "working" looks like. Use when manually testing or verifying a decode surface end-to-end.

  • 613 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 8, 2026
ai-agentspythongoshellbashnodedockertestinggitapibackend

Works with

  • terminal
  • cli
  • api

Security analysis

B85/100
  • highPerforms destructive filesystem operations

Pro shows the line behind each finding and how to fix it

Scanned October 8, 2026

npx -y skills add decodingai-magazine/building-a-coding-agent-from-scratch-course --skill manual-e2e-qa --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Manual E2e Qa?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Manual E2e Qa
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/decodingai-magazine-manual-e2e-qa/badge)](https://www.skillsdirectory.com/skills/decodingai-magazine-manual-e2e-qa)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: manual-e2e-qa
description: Manual end-to-end QA playbook for decode against a real provider — what to type at each surface (chat, gated read/write, bash, todo, web_fetch, ask_user, lsp, agent subagents, docker/modal sandboxing, headless `decode run`, model override, a benchmark trial, the local Kitaru server, session recording, worker replay, mid-turn steer/abort, persistence/memory, /ship, sandbox git token) and what "working" looks like. Use when manually testing or verifying a decode surface end-to-end.
---

# Testing E2E

Automated M1 proof: capstone [`tests/integration/test_milestone1_capstone.py`](../../../tests/integration/test_milestone1_capstone.py) — six-step conversation (read → gated write approve → gated write deny → todo_write → ask_user → web_fetch) through **real** `build_agent()` + `Runner` + `render_event` + session log + memory write-back; only network boundary swapped (`FunctionModel` for model, `httpx.MockTransport` for web tool). Run: `make integration-tests` or `make ci`. No API key, no network.

Below: **manual** e2e vs real Gemini — exercise each surface, then try to break it (adversarial half = Tester's job).

**Launch** (one env var, no service):

```bash
export GEMINI_API_KEY=…        # the only required secret (see .env.example); or put it in .env
uv run decode                  # the REPL: a "> " prompt + a footer hint render
```

No `GEMINI_API_KEY` → one friendly stderr line — `decode: set GEMINI_API_KEY in your environment or .env to start (see .env.example).` — exit non-zero, no traceback (task-004 guard in `cli.py`).

Per surface: what to type + what "working" looks like.

| Surface | Type this | Working looks like |
|---|---|---|
| **Plain chat** | `what can you do?` | answer **streams** token-by-token above prompt; prompt pinned at bottom. |
| **Read (gated)** | `read pyproject.toml` | `permission? read …` prompt; `y` → panel with numbered file contents; `n` → model told denied, adapts. |
| **Write (gated, approve)** | `create a file hello.txt that says hi` | `permission? write …` → `y` → file **appears** on disk (`cat hello.txt`), model confirms. |
| **Write (gated, deny)** | repeat write, answer `n` | file **not** created (`ls hello.txt` → absent); model told denied (doesn't pretend it wrote). |
| **Bash** | `run the tests with make unit-tests` | `permission? bash …` → `y` → panel with stdout/stderr (truncated past cap); runaway command bounded by `bash_timeout_s`. |
| **Todo checklist** | `make a 3-step plan to add a CLI flag and track it` | blue **tasks** panel renders checklist (`[ ]` / `[~]` / `[x]`), re-renders as statuses update. |
| **web_fetch** | `fetch https://example.com and summarize it` | `permission? web_fetch …` → `y` → page as Markdown (HTML stripped), model summarizes. |
| **ask_user** | `deploy my app` (underspecified) | model calls `ask_user`; `ask: …` question + `type your answer:` cue; next typed line **is** the answer, turn resumes. |
| **lsp (code intelligence)** | `where is build_agent defined?` | model calls `lsp` (`definition`); **auto-allows** (read-only, no prompt); answer cites `src/decode/agent/factory.py:68:5`. Then `write a broken bad.py with a syntax error` → approved write's result carries appended `LSP diagnostics (ty) — fix these:` block; model corrects. |
| **agent (Explore subagents)** | `explore how permissions and sandboxing each work across the repo, in parallel` | **ONE** `agent(prompts=[…])` call — one prompt per angle — **auto-allows** (READ_ONLY — "can only cause reads"); result panel = ONE **labelled aggregate**, a `## Subagent i — "<prompt>"` section per child, in prompt order (no synthesis LLM call — the parent model synthesizes). Child = read-only **Explore subagent** (`read`/`glob`/`grep`/`lsp` only — no `write`/`edit`/`bash`/`web_fetch`). The **harness** guarantees the parallelism (`asyncio.gather` inside the tool, not the model volunteering N calls): width cap **6 prompts** per call (`ModelRetry` → "consolidate" above it; empty list → `ModelRetry`), concurrency capped `subagent_max_parallel` (default 4 — a 6-wide fan-out runs 4, then 2). Child bounded `subagent_max_requests` (default 25); reports **share** the fold budget — each truncated to `subagent_result_max_bytes // len(prompts)` (default 16000 total, so the fold costs the same at any width). **Input guarded** pre-spawn: a prompt under **8 words** (`explore the repo`) nags the model back (`ModelRetry` naming `Prompt i ("…")` as *too terse*) and spawns NOTHING — rewrite and it fans out (a nag costs one retry leg; `agent` registers `retries=3`, so a stubborn model is coached, not aborted). **Output validated**: a report that is empty or backed by **zero tool calls** (child answered from memory) buys that child **exactly one** re-spawn with a nudge — two attempts, never three — then its section carries `The subagent returned no usable report.`; a child that *raises* gets `This subagent failed before producing a report.` Either way the siblings still fold. Every result ends with the **Synthesis Footer** — the harness telling the parent to compile the sections into ONE answer: prose **+ a text diagram** (ASCII/box-drawing; Mermaid only for a genuine graph), so a good fan-out answer *shows you a diagram* in the terminal. Children **silent-until-done** (no-op event sink), transcripts ephemeral — `--resume` carries only the one spawn call + the folded aggregate (ADR-0013, ADR-0017). |
| **Opik tracing (observability)** | `export OPIK_API_KEY=<comet-key>` (free at comet.com), relaunch `uv run decode`, run any turn; open it in Opik UI | on launch `Decode - Opik tracing on (project 'decode-local').` prints **once, before banner** — the project is DERIVED `decode-<DECODE_ENV>` unless `OPIK_PROJECT_NAME` is set (ADR-0015 §8), so `DECODE_ENV=prod` files traces under `decode-prod`; unset key → **byte-identical** (zero spans, no network, no line). One REPL **turn** = one **Trace** (root **Span** `chat_turn`); session's traces = one **Thread** keyed on session id — gated tool's approve/resume leg + follow-ups ride the **same** trace (latency honestly includes gate wait). Every LLM + tool call = **Span**: inputs/outputs, latency, tokens (`gen_ai.usage.*`), cost for models Opik prices (Gemini yes; OpenRouter/Modal may be tokens-only). Memory write-back + compaction ride the one global `instrument_pydantic_ai()`; subagent `agent(...)` child **nests inside** parent turn's trace, child tokens visible (closes ADR-0013 §9). `--resume` mints fresh session id → **new** Thread. **Headless:** a `decode run` = one Trace (root span `decode_run`), Thread = the run's session id; activation surfaces only in LOG — **stdout stays exactly the answer** (pipe-clean), stderr carries only guards/notices. Opik and Kitaru are independent instrumentation (OTLP vs the recording adapter) and compose — recording a run changes no span (ADR-0019 §6). Evals/experiments = M13 (ADR-0014). |
| **decode run (headless)** | `decode run "list the python files"` (separate command, not in REPL) | agent tool-loops **headlessly** on a plain `asyncio.run` around the same `build_agent()` — every tool inline under bypass, no prompt, `ask_user` a no-op — prints the answer on **stdout** (pipe-clean: `\| pbcopy` is safe), exit `0`. **No durability, no pause, no wait** — a crash is a re-run (ADR-0019 §1). `RUNTIME_ENABLED=false` → one friendly stderr line, non-zero exit, nothing built. A missing TASK argument (and no Worker Task env) → one line naming both ways to supply one. |
| **Recording Seam (Kitaru session)** | `export KITARU_API_URL=https://<workspace>.cloudinfra.zenml.io; export KITARU_AGENT_ID=<agent-uuid>`, then `decode run "explain this repo"` — A/B: re-run with `env -u KITARU_AGENT_ID` | recorded run → LOG line `[kitaru] recording this run on <url> (agent_id=…, session_name=<run session id>)`, and `uv run kitaru session get <id>` shows every LLM + tool call as nodes; the REPL records the same way with `session_name` = decode session id, so a conversation's turns group. **B side (unset):** byte-identical run, **no kitaru module imported at all**. `KITARU_API_URL` only in `.env` (never exported) = NOT configured — decode never reads it, the adapter's client does (`set -a && . .env && set +a`). **Degrade:** point `KITARU_API_URL` at a closed port → ONE stderr line `[kitaru] not recording this run: … continuing on the bare agent`, the run still answers and exits `0` (recording is an observer, never an availability dependency). **Hard-fail A/B:** the same with `KITARU_TASK_ID=<uuid>` in the env (a Kitaru Worker Task) → non-zero exit instead, because an unrecorded replay is a lying experiment (ADR-0019 §3). |
| **decode run --model (model override)** | `decode run --model gemini-2.5-pro "list the python files"` | same bypass run; **Model Override** swaps only the active provider's model id for this run (provider stays `LLM_PROVIDER`-selected — no cross-provider swap). Answer → **stdout**, stderr carries only guards/notices. Presence, not correctness — a wrong model id for the provider fails at the first model request. A replay-time model swap is Kitaru's own `--override`, NOT this flag. |
| **Worker replay (baseline)** | `uv run python scripts/bootstrap_kitaru.py --dry-run` then without it; `set -a && . .env && set +a && kitaru worker start` in a second shell; then `kitaru replay create <SESSION_ID> --agent decode@<VERSION> --tool-policy '{"default":{"type":"history","scope":"baseline","on_miss":"error_result"}}'` | `--dry-run` prints the exact `kitaru agent version register` argv and registers **nothing**; a real run adds a **version** to the EXISTING `decode` agent (never a second agent). `kitaru worker list` → one `live` Worker. `kitaru replay get <REPLAY_ID>` → `status: completed` + `result_session_id`; `kitaru session get <RESULT>` → `origin: replay`, the node counts, and `inputs` = the baseline's own recorded payload (`{"input": …}` or a bare prompt string — all three shapes resolve to the prompt, anything else hard-fails; ADR-0019 Amendments §1). Isolation is the point: the spawn's cwd is Harness Home `~/.decode-kitaru-worker` (sessions, logs, `.decode/sandbox` clone all land there — `docker ps` during the run, `tail -f ~/.decode-kitaru-worker/.decode/logs/decode.log`), and `git status` in YOUR checkout stays clean. Provider keys come from the **Worker's shell**, not the run spec (`secret_ids: []`) — a Worker started without them fails at the first model request. Full runbook: [`06_evals_replays.md`](../../../running_the_code/06_evals_replays.md). |

**Sandboxing** (ADR-0012 — see invariants above for the seam / Workspace / fresh-exec / Harness Home model). Default `SANDBOX_MODE=none` keeps every row above byte-unchanged. In `docker` / `modal` the **whole tool scope** — file/search tools **and** `bash` — is the one Workspace; decode's artifacts stay at Harness Home. `--repo`/`SANDBOX_REPO` needs a sandbox mode; with `SANDBOX_MODE=none` → `Decode: --repo/SANDBOX_REPO clones a repo into the isolated sandbox Workspace, which only exists in a sandbox mode …`, non-zero, no traceback.

| Surface | Type this | Working looks like |
|---|---|---|
| **file tools + bash (docker Sandbox)** | launch `SANDBOX_MODE=docker decode --repo <url-or-local-path>` (needs running Docker daemon; `--local` = fast local-path clone), then `glob **/*.py, read one of them, write a NOTES.md, then run 'echo hi > side.txt && cat side.txt'` | launch: `Decode - cloning <repo> into the workspace…` (only when repo given AND Workspace still empty), then `Decode - starting docker sandbox (ghcr.io/astral-sh/uv:python3.12-bookworm-slim)…` + `sandbox:docker` banner segment — `docker ps` shows a `sleep infinity` container before any bash (clone failure degrades to empty Workspace + one friendly line). File/search tools + `bash` share the one `/workspace` tree — **live bind mount**, always truthful: bash-written file → `read`; bash `rm` → `glob`. `bash` still gates; fresh-exec (chain: `cd /workspace/app && …`); timeout kills only that `docker exec` — container + filesystem survive, reply says timed out. `lsp` + post-edit diagnostics **run** (host `ty` reads bind mount). **`git` installed into container at startup** (slim base ships none), identity preconfigured (`SANDBOX_GIT_USER_*`, default `decode`/`decode@localhost`) → model `git commit` in `/workspace` works; push/PR creds stay host-side (hand-back) unless you opt into `SANDBOX_GIT_TOKEN` (own row). `web_fetch` stays **gated** (host network). Exit or `/ship` → hand-back (see `/ship` row). **QA peek:** `docker exec -it <id> bash` (filter `ancestor=ghcr.io/astral-sh/uv:python3.12-bookworm-slim`) → `/workspace` = host `.decode/sandbox`. **Guard:** daemon stopped → `Decode: SANDBOX_MODE=docker but the Docker daemon is not reachable — start Docker and retry (see .env.example).`, non-zero, no traceback. |
| **file tools + bash (modal Sandbox)** | launch `SANDBOX_MODE=modal decode --repo <url>` (needs `modal token set` creds), then `glob **/*.py, read one, then run 'ls -a /workspace'` | after clone line: `Decode - starting modal sandbox …` **and** `Decode - uploading the workspace to the modal sandbox…` — Workspace **bootstrap-uploaded** (ONE tar, host → remote; NOT `add_local_dir`-seeded, NOT mtime-synced) → `/workspace` holds the cloned repo + seeded `.decode/skills`, not empty scratch. File tools go directly against remote `SandboxFilesystem` (`read`/`write`/`edit`); `glob`/`grep` = remote `find`/`grep` — truthful, no host mirror. `bash` gates; fresh-exec. Sandbox outliving `SANDBOX_TIMEOUT_S` recreated on next op, **re-bootstrapped from last local `.decode/sandbox` state**; reply notes in-sandbox changes since last export may be lost. `lsp` + post-edit diagnostics **best-effort-disabled** (host `ty` can't reach remote fs — ADR-0012 §7). **`git` baked into image** (`apt_install("git")` + `git config` layer for `SANDBOX_GIT_USER_*` identity, both cached) → `git commit` works out of box. `SANDBOX_GIT_TOKEN` set → token injected INTO modal sandbox (`GITHUB_TOKEN` via `modal.Secret` + credential-helper layer) → model can `git push` / open PR from inside — **one** `SANDBOX_GIT_TOKEN`, **one** mechanism, both backends (docker direct-injects the identical way, ADR-0016 §2; use a scoped, revocable PAT — the sandbox holds it and can read it). `web_fetch` gated. Session end or `/ship` → `/workspace` **exported** to host `.decode/sandbox` for hand-back. **Guard:** no Modal creds → ``Decode: SANDBOX_MODE=modal but Modal credentials are missing — run `modal token set …` (see .env.example).``, non-zero, presence only (no `modal` import, no network call). |
| **decode run in a Sandbox** | `SANDBOX_MODE=docker decode run --repo <url> "run 'uname -a', then read README.md and summarize it"` | headless **bypass** run executes `bash` + file tools inside the Workspace, no prompt, prints the answer, exit `0`. Completion → Workspace **auto-shipped** as a `decode/<session-id>` branch, pushed **host-side from this very process** (outcome line → **stderr**, stdout pipe-clean; the "hand-back runs inside the flow" constraint died with the flow, ADR-0019 §1). Same backend guard in the headless pre-flight (daemon down / modal creds absent / `--repo` in `none` mode → one stderr line, non-zero, **nothing built**). A Kitaru Worker replay is this same path, with `SANDBOX_MODE=docker` + the repo clone pinned by the Agent Version. |
| **/ship (git hand-back)** | in a `SANDBOX_MODE=docker decode --repo <url>` session, do work, type `/ship` (idle-only, like `/compact` / `/clear`) | Workspace secured onto `decode/<session-id>` branch, pushed host-side: `Decode - handed the workspace back on branch decode/<id> (pushed to origin).` (modal exports `/workspace` first; docker's mount already live). Failed push never loses work — `Decode - could not push decode/<id> to origin; the results are safe on the local branch decode/<id> in .decode/sandbox — push it yourself when ready.` Unchanged/non-git → `Decode - the workspace is unchanged from the cloned HEAD, so there is nothing to hand back.` `none` mode / no `--repo` → `Decode - no sandbox workspace to ship.` Same hand-back runs automatically on REPL exit (silent no-op on skip). |
| **sandbox git token (`SANDBOX_GIT_TOKEN`, both backends)** | `rm -rf .decode/sandbox` (**required** — see below), then `SANDBOX_GIT_TOKEN=<fine-grained-PAT> SANDBOX_MODE=docker decode run --repo <url> "create NOTES.md with one line, commit it, push the branch, then open a PR against main"`. A/B: re-run with `env -u SANDBOX_GIT_TOKEN` | branch pushed + PR opened **from inside** the sandbox. ONE mechanism, BOTH backends (ADR-0016 §2): the token is **direct-injected into the Worker env as `GITHUB_TOKEN`** — docker via a **value-less** `-e GITHUB_TOKEN` (value rides the docker *client's* env → never in a host argv: `ps aux \| grep -F "<PAT>"` is EMPTY), modal via a `modal.Secret` — plus the shared credential-helper (`x-access-token:$GITHUB_TOKEN`) so `git push` over HTTPS works; `gh` reads `GITHUB_TOKEN` natively. **Expected (and the honest cost): `docker exec <worker-id> env \| grep GITHUB_TOKEN` PRINTS the token** — a sandboxed process CAN read it, so use a **scoped, revocable** PAT. **B side (unset):** no `GITHUB_TOKEN`, no `git config --global credential.helper`, `docker run` argv byte-identical to the no-token case — the sandbox holds **no credential at all**, and host-side hand-back still ships the branch (ADR-0016 §4). The token rides the one config surface — a laptop's `.env`, a deployment's Modal Secret (ADR-0021 §1) — no second lookup. `rm -rf .decode/sandbox` before ANY `--repo` run — a populated Workspace is **reused** and `--repo` **silently ignored** (no `origin` → push dies with `'origin' does not appear to be a git repository`; check `git -C .decode/sandbox remote -v`). Explicit-empty `SANDBOX_GIT_TOKEN=` injects nothing (gated on the *value*); mode `none` = no-op (it IS the host). Retired `SANDBOX_CREDENTIAL_PROXY_ENABLED` / `SANDBOX_PROXY_IMAGE` in a `.env` are **silently ignored** — the Credential Proxy is deleted (ADR-0016 §1), no proxy container exists. Full tutorial: [`03_sandboxing.md`](../../../running_the_code/03_sandboxing.md#2-optional-push-code-to-github-from-the-sandbox-sandbox_git_token). |

**Evals + replay** (operator surfaces — [`05_evals.md`](../../../running_the_code/05_evals.md), [`06_evals_replays.md`](../../../running_the_code/06_evals_replays.md)). Both cost real model calls; without keys every command **skips friendly** (one line, exit 0).

| Surface | Type this | Working looks like |
|---|---|---|
| **Benchmark trial** | `export OPIK_API_KEY=…` (+ your provider key), Docker running, then `make eval-benchmark ARGS='--task 001-find-and-replace'` | ONE Trial of one task through the **shipped runtime**: a Rich table `decode benchmark — 1 task(s) x 1 trial(s)` with `pass@1 1.00` and an empty `infra` column, then `evals benchmark: experiment bench-<stamp> logged under decode-evals; trial dirs in .decode/evals/runs/bench-<stamp>`. The **Trial Dir** is the evidence — `ls .decode/evals/runs/<job>/001-find-and-replace__<short-id>/` shows `seed/ home/ pristine/ agent/ verifier/ result.json`: `agent/summary.json` has `exit_reason: completed` and a `handback.branch` of `decode/<short-id>`, `git -C seed branch` lists that branch, `cat verifier/reward.txt` → `1.0`, `result.json` says `"status": "agent_ok"`. Grading is **host-side**: `pristine/` is a clone of the handed-back branch with `tests/` overlaid last, so the agent could not have touched its grader. **Break it on purpose:** `--task 018-git-bisect-revert --trials 2` (hard tier) — a lost trial must read `agent_fail` with a reason naming the ceiling/timeout, NOT `infra_error`; an `infra_error` means the harness failed (Docker down, seed failed) and is excluded from both numerator and denominator (ADR-0022 §4). Keyless: `make eval-benchmark` → `evals benchmark: skipped — set OPIK_API_KEY, …`, exit 0. |
| **Local Kitaru (record → cohort → replay)** | `make kitaru-local`, export the two lines it prints, then `uv run decode run "say hi in exactly three words"` | `make kitaru-local` starts the OSS server under docker compose (`docker ps`: the kitaru image + `postgres:16-alpine` on `localhost:8000`), then `scripts/bootstrap_kitaru.py` prints one row per resource (agent `decode`, version `decode@1` docker, importer `opik`, every `evaluators/*.py`) and the `export KITARU_API_URL=…` / `export KITARU_AGENT_ID=…` pair — **run it twice: the second run registers nothing** (idempotent). With both exported, the run logs `[kitaru] recording this run on http://localhost:8000 (agent_id=…, session_name=<session id>)` and `uv run kitaru session list --agent decode --origin recorded --size 3` shows it; `session get <ID>` shows every model + tool call as nodes. Join to Opik: a benchmark job run with `KITARU_AGENT_ID` exported freezes its losers with `uv run python -m evals kitaru cohort from-experiment <JOB>` (refuses with ONE line when the experiment recorded no `kitaru_agent_id`), and `uv run python -m evals kitaru import <TRACE_ID>` backfills a trace decode never recorded (needs a live Worker, else it waits and times out). Replay: in a second shell `set -a && . ./.env && set +a`, `unset KITARU_AGENT_ID`, `uv run kitaru worker start`, then `uv run kitaru replay create <SESSION_ID> --agent decode@1 --evaluator 'decode-request-limit@1' --tool-policy '{"default":{"type":"history","scope":"baseline","on_miss":"error_result"}}'` → `kitaru replay get <REPLAY_ID>` reaches a terminal state with a `result_session_id`. `uv run kitaru logout` stops the server; the same steps against the managed workspace are ONE `KITARU_API_URL` switch. |

**Mid-turn interaction** (while a turn streams — ADR-0002 §4-5):

- **Steer** — type a line + plain **Enter**: injected at next model-request boundary (never mid-stream/mid-tool).
- **Follow-up** — **Alt+Enter**: queued, drained only when turn would otherwise stop; continues as new turn.
- **Abort** — **Esc**: turn stops at next boundary, keeps work done, REPL idle (`[aborted]` marker).

**Persistence + memory across sessions:**

- `decode --resume` (or `decode --resume <session-id>`) replays latest (or named) session log from `.decode/sessions/*.jsonl`; prior conversation seeded, continue it.
- On quit (`/quit` or `Ctrl-D`), one cheap Gemini call appends a dated one-line summary (`- YYYY-MM-DD: …`) to `.decode/MEMORY.md`; relaunch injects it into agent instructions.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.