- README: 42 tools (was 38), /demo row, TUI section, testing plus integration line, full layout map - tui.md: research link, tests pointer, soften shot-tested claim - AGENTS.md/tests/README.md/research hubs: cross-links between docs - Research: forward-correct tui conclusion plus INDEX activity row (19 failures fixed, tai_pkg deleted, 320/320 green); audit PASS
125 lines
8.7 KiB
Markdown
125 lines
8.7 KiB
Markdown
# Integration tests
|
|
|
|
This is where **integration tests** live — tests that drive the real `tai.py` binary as a
|
|
subprocess and check its actual behavior. They are a different thing from the project's
|
|
**unit tests** (`test_tai.py`, `test_seal.py`, at the repository top level, `unittest`-based,
|
|
import `tai.py` as a module, fast, no subprocess). Both exist on purpose, side by side; see
|
|
[AGENTS.md](../AGENTS.md#testing)'s "Testing" section for which convention applies to which kind of test, and do
|
|
not move one style into the other's location or naming.
|
|
|
|
**This is the only way integration tests get written for this project from now on.** A new
|
|
integration test is a new file under `tests/`, built on [tai_test.py](../tai_test.py) (the framework, at the
|
|
repository root — read its module docstring before using it). Don't hand-roll a fresh
|
|
`subprocess`/`asyncio` harness per test file; extend `tai_test.TestCase` instead, so every
|
|
integration test behaves consistently (same timeout handling, same process cleanup, same
|
|
logging, same CLI flags) without each one having to get those details right from scratch.
|
|
|
|
## The core rule: test with the real AI, and accept the flakes
|
|
|
|
An integration test for this project is not done proving something until it has made the
|
|
real `tai.py` binary make a real decision through a real call to a real LLM backend. Not a
|
|
mock of the model, not a stub tool, not a direct write into the vault that *simulates* what
|
|
the agent would have done. If a test's "proof" could have been produced by a human directly
|
|
editing the database instead of the agent ever running, it isn't an integration test for an
|
|
AI agent — it's a database test wearing an integration test's name.
|
|
|
|
This isn't a style preference; it came directly from the first time this rule was followed
|
|
end to end, while building `tests/rag.py`:
|
|
|
|
- The CLI's own terminal rendering turned out to hide the actual tool-call return value
|
|
behind a `└─ 78 chars · 1ms` summary. A test that stopped at the subprocess's stdout — the
|
|
"deterministic-looking" layer — would have silently reported every real save as a failure.
|
|
Checking the vault's actual state caught this; a mocked tool call would never have surfaced
|
|
the discrepancy, because a mock doesn't render a UI.
|
|
- With a real model in the loop, `record_save` got called **twice for one file** and **three
|
|
times for another** in the same run, once in a cross-call way that briefly made search
|
|
results from an earlier file leak into a later one. That is a genuine, observed property of
|
|
how the agent actually behaves under conversational history — not a bug anyone would have
|
|
thought to inject into a scripted fake, and not something a test that never touches the real
|
|
model could ever have found.
|
|
- The final, strong per-file proof (exact content fidelity, the fingerprint surviving
|
|
chunking under the correct record, `/search` naming the correct `mem:` id) is only
|
|
meaningful *because* the thing being measured — the model's own tool call — was real. A
|
|
fidelity score of 1.00 against a value the test itself inserted would prove nothing.
|
|
|
|
So: **we do not write integration tests that fake the AI's part to get a clean, deterministic
|
|
result. We accept that free, rotating backends (see root [README.md](../README.md#backends)'s "Backends" section)
|
|
make LLM-driven steps genuinely flaky, and we treat that flakiness as the cost of actually
|
|
testing the thing we built, not as a defect to engineer around by testing something easier
|
|
instead.** The practical consequences of this — report ratios instead of demanding
|
|
all-or-nothing, prefer a deterministic *plumbing* check (a REPL slash command) only to prove
|
|
the harness itself works, never as a replacement for the AI-driven test of the actual feature
|
|
— are spelled out below in "LLM-driven steps are probabilistic."
|
|
|
|
## How to add a new integration test
|
|
|
|
1. Create `tests/<topic>.py` (lowercase, no `test_` prefix — that prefix is reserved for the
|
|
top-level unit-test files so the two kinds are never confused by name alone).
|
|
2. `sys.path.insert(0, str(Path(__file__).resolve().parent.parent))` then `from tai_test import TestCase, ...` — see [tests/rag.py](rag.py) for the exact boilerplate.
|
|
3. Subclass `TestCase` for each distinct scenario. Required: `name` (a class attribute),
|
|
`async def run(self)` (drive the process via `self.spawn(...)`, return whatever `verify()`
|
|
needs), `async def verify(self, result)` (raise `TestAssertionFailed` with a clear message
|
|
on failure). Optional: `async def setup(self)` / `async def teardown(self)`, `timeout`,
|
|
`mode` ("pipe" or "pty" — see [tai_test.py](../tai_test.py)'s docstring for when each applies).
|
|
4. Collect your cases into a module-level `ALL_CASES = [...]` list and end the file with:
|
|
```python
|
|
if __name__ == "__main__":
|
|
sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs")))
|
|
```
|
|
5. Run it: `python3 tests/<topic>.py` (all cases), `--filter <substring>` (a subset),
|
|
`--interactive` (also forward your own live typing into the running process — useful for
|
|
watching a flaky step happen and nudging it by hand), `--timeout <seconds>` (override).
|
|
|
|
## Isolation — always use a throwaway `TAI_HOME`
|
|
|
|
Every test that touches the vault must set `TAI_HOME` (and `TAI_PASSPHRASE=""`, so the vault
|
|
stays plaintext and directly inspectable) to a fresh `tempfile.mkdtemp()` in `setup()`, and
|
|
remove it in `teardown()`. Never point a test at the real `~/.tai` — there is no "are you
|
|
sure" here, a test that forgets this will read and write the actual user's memory, secrets,
|
|
and schedules. `tests/rag.py`'s two test cases are the reference example.
|
|
|
|
## Verify against state, not against rendered text
|
|
|
|
tai's default terminal rendering shows a tool's result as a truncated summary (e.g.
|
|
`└─ 78 chars · 1ms`), not the tool's actual return string — this was discovered empirically
|
|
while building `tests/rag.py` (its first version tried to regex-match `"saved mem:..."` out of
|
|
one-shot stdout and silently failed every time, even when the save had genuinely succeeded).
|
|
**When a test needs to know whether something actually happened, check the thing that
|
|
actually changed** — read the vault's `memory.db` directly with `sqlite3` (safe once
|
|
`TAI_PASSPHRASE` is forced empty per the isolation rule above), check a file on disk, etc. —
|
|
rather than parsing CLI output that was only ever designed to be read by a human, not
|
|
asserted on. CLI output is still worth capturing (it's what the log file is for, and it's
|
|
exactly what a deterministic REPL slash-command reply like `/search` is reasonable to assert
|
|
on, since that output format is a stable, documented interface) — the rule is specifically
|
|
about not trusting *tool-call rendering* as a proxy for *tool-call success*.
|
|
|
|
## LLM-driven steps are probabilistic — say so, don't hide it
|
|
|
|
This is the practical follow-through on "test with the real AI" above, not an escape hatch
|
|
from it. The thing actually being validated — did the agent decide to use its tools
|
|
correctly — only exists as a real step if the model is really making the decision, so that
|
|
step is never faked out for convenience. What *can* and should vary is how much of the test
|
|
*around* that step needs the model too:
|
|
|
|
- A deterministic path (a REPL slash command, which dispatches a tool directly with no model
|
|
call at all) is appropriate for checking that the harness and the plumbing work —
|
|
`InteractiveReplSmokeTest` in `tests/rag.py` is exactly this, and it exists to isolate
|
|
"is the pty transport broken" from "did the model behave today" when something fails. It is
|
|
a smoke check *alongside* the AI-driven test, never a *replacement* for it when the thing
|
|
under test is the agent's own tool-use behavior.
|
|
- tai's primary backend selects a free model per request (see the root [README.md](../README.md#backends)'s
|
|
"Backends" section) — the exact model, and therefore exact tool-calling reliability, can
|
|
differ between runs. Say this plainly in the test's docstring wherever a step is LLM-driven.
|
|
- Write `verify()` to fail only on a genuinely empty/broken result, not on "fewer than 100% of
|
|
attempts worked" — report the actual ratio (or, better, a per-item breakdown, as
|
|
`RagIndexAndSearchTest.verify()` does) in the failure message, so a flaky run is
|
|
distinguishable from a real regression at a glance.
|
|
|
|
## Process hygiene
|
|
|
|
`tai_test.ManagedProcess` always starts children with `start_new_session=True` and tears them
|
|
down with an escalating, group-wide, always-reaping shutdown (`terminate()`), specifically so
|
|
a timed-out or crashed test never leaves an orphaned `tai.py` (or anything it spawned) running
|
|
in the background. If you ever spawn a process outside of `self.spawn()` for some reason, you
|
|
are responsible for giving it the same guarantee yourself — don't bypass it.
|