Navi Post-Deploy Smoke Test
Overview
Navi's only functional entry point is a Slack message. This skill is the test
harness: it plays the role of a user in a dedicated Slack test channel, sends
each scenario's message to Navi, waits for Navi to finish, and then judges the
outcome on three layers — never on the reaction emoji alone:
- Reaction lifecycle — necessary, not sufficient. Liveness signal only.
- Navi's actual thread reply — the user-facing content.
- The execution Trace in the dev PostgreSQL — the ground truth of what
Navi actually did internally (rounds, nodes, delegations, tool calls, errors).
A scenario only passes when all three agree with its expected result. The agent
running this skill is the judge — that is what absorbs LLM non-determinism.
Prerequisites
- A Slack MCP connected to the workspace that hosts the test channel (any of
the
slack_* tools: post message, read thread replies, read reactions, look
up users).
- A PostgreSQL MCP (
mcp__mac-postgresql__*) that can reach the dev
environment's agent database.
config.md (this skill's folder) filled in: test channel id, Navi's bot
handle/user id, dev DB environment name, timeout.
scenarios.yaml (this skill's folder): the scenarios to run, validated by
scenarios.schema.json. The user keeps adding to this file after each deploy.
If a prerequisite is missing, say exactly which one and stop — do not fake a pass.
Inputs
- Read
config.md for the channel id, Navi identity, dev DB env, timeout,
max concurrency (how many independent scenarios may run in parallel — see
the Concurrency model below), and the report channel id (where step 6 posts
the run report).
- Validate
scenarios.yaml first. From the skill folder run
python3 validate_scenarios.py (needs PyYAML + jsonschema). It checks required
fields, id format, duplicate ids, and that every agents name is a known agent
(a typo fails here, not mid-run). If it exits non-zero, report the listed
problems and stop. If Python/deps are unavailable, validate scenarios.yaml
against scenarios.schema.json yourself before proceeding.
- Read
scenarios.yaml for the scenarios (each: id, title, message,
expect, optional agents list, optional note, optional in_thread_of). If
the user named specific scenarios in their prompt, run only those; otherwise run
all.
in_thread_of (thread dependency). A scenario with in_thread_of: T-0XX
is not sent as a fresh top-level message — it is posted as a reply inside
the Slack thread of the referenced scenario, to test multi-turn / context
continuation (e.g. T-004 creates a ticket, then T-005 in_thread_of: T-004
says "assign it to me" relying on the thread's context). The reference always
points at a scenario defined earlier in the file (validated). When you run a
subset, a threaded scenario needs its in_thread_of ancestor to have run
first in the same session — auto-include the ancestor chain and run it
top-to-bottom so the parent thread exists; if an ancestor cannot be run, report
that and skip the dependent rather than silently posting it top-level.
Workflow
Concurrency model
Independent scenarios run in parallel; turns that share a Slack thread run
sequentially. Work out the schedule before sending anything:
- Group scenarios into thread-families. A family is a top-level scenario
plus every scenario that threads onto it (transitively via
in_thread_of). In
the bundled scenarios, T-004 with its replies T-005/T-006/T-007/T-013 is one
family; every other scenario is a family of one.
- Within a family: strictly sequential, in file (id) order — send a turn
only after the previous turn in that family has reached a terminal reaction.
The whole family shares one root
thread_ts, so its turns must never
interleave; this is exactly what the trace/reply correlation in steps 2–3
relies on.
- Across families: parallel, up to Max concurrency families in flight at
once (read it from
config.md). Each family has a distinct root thread_ts,
so their reactions, replies, and traces never collide. Run a worklist: keep up
to N families active; whenever one finishes, start the next pending family.
Setting Max concurrency to 1 collapses this back to the old one-at-a-time
behavior.
The cap counts families, not turns. A family occupies one concurrency slot
for its entire sequential life — from its first turn's send until its last turn
reaches terminal — and only one of its turns is ever in flight at a time. The slot
frees only when the family completes; you then pull the next pending family into
it. So a family's root turn (e.g. T-004) runs concurrently with other families,
but never with its own later turns (T-005…). At any moment up to N turns are
in flight, at most one per thread.
Concretely, you (the agent) are the scheduler: start min(N, #families) families;
each poll cycle, batch-read the in-flight turns (reactions/replies + the trace
queries can go out together); for every turn that hit terminal and has its three
layers, either send that family's next turn (same slot) or, if the family is done,
free the slot and start the next pending family. Repeat until every family is done.
Run the per-turn steps (1 Send → 2 Wait → 3 Collect) for each turn as the schedule
dispatches it; you may judge (step 4) a scenario as soon as its three layers
are in. Do the cleanup (step 5) and post the run report (step 6) only after all
families have finished. For each turn:
1. Send
- The message text is just
<@NAVI_USER_ID> {scenario.message} — append no
test marker. Every Slack message already gets a unique ts, and that ts (not
an in-message tag) is the correlation key. A [navi-test …]-style marker would
leak into Navi's request text and mislead it — Navi reasons over the whole
message (it only strips the @mention), so for vague asks it treats the tag as
the thing to act on. Keep the message clean.
- Pace the sends. Navi has a per-user rate limiter: messages posted ~1s
apart can be rejected with a "You're sending messages too fast" thread reply —
no reaction, no trace, the turn never ran. Leave a few seconds between sends
(the family-concurrency cap alone does not guarantee spacing). A rate-limited
send is NOT a scenario result: retry it in a later free slot, and report the
limiter only as a run note (or as a finding if it recurs at human pacing).
- Default (no
in_thread_of): post as a fresh top-level message (NOT a
reply into an existing thread) so it gets its own unique ts.
- Threaded (
in_thread_of: T-0XX): post as a reply into the referenced
scenario's thread — slack_send_message with thread_ts = the thread-root
ts you recorded for T-0XX (see below). This makes Navi handle it as a
follow-up turn with the thread's prior context.
- Record, per scenario id, the posted message's own ts and the send time
(both load-bearing for correlation), plus the thread-root ts to thread
future replies under:
- top-level scenario → thread-root ts = its own posted ts (Navi sets
thread_ts = event.ts, so agent_traces.thread_ts equals it exactly).
- threaded scenario → thread-root ts = inherited from its
in_thread_of
ancestor (Slack flattens nested replies into one thread:
thread_ts = event.thread_ts = root). So a later scenario may thread off
this one and still land in the same root thread.
- ⚠️ For a threaded scenario,
agent_traces.thread_ts is the shared root ts,
NOT this message's own ts — so it is NOT a unique key on its own. Correlate
threaded scenarios by thread_ts(root) + started_at ≥ send_time under strictly
sequential execution (step 3).
2. Wait for completion
- Poll every few seconds: read the reactions on the sent message and the
thread replies.
- Navi's reaction lifecycle (exact emoji names):
| emoji |
name |
meaning |
| 👀 |
eyes |
received / actively processing |
| ✅ |
white_check_mark |
finished successfully |
| ❌ |
x |
raised an unhandled error |
| 🚫 |
no_entry_sign |
cancelled (e.g. plan rejected) |
- Stop waiting when a terminal reaction (
white_check_mark / x /
no_entry_sign) appears, or the configured timeout elapses.
- If Navi posts a plan and waits for approval (👀 persists, no terminal
reaction, an interactive plan message appears): this skill does not click
Slack approval buttons. To test execution, phrase the scenario message so it
carries the human's own authorization inline (Navi supports code-verified
direct execute). Treat a stuck-at-approval result as a finding, not a pass,
unless the scenario explicitly expects "stops for approval".
- On timeout with no terminal reaction → FAIL (record "no terminal reaction
within {timeout}s; last 👀 still present").
3. Collect the three layers
- Reaction: read reactions on this scenario's own posted ts (Navi reacts
on the exact message it received, so this is correct even inside a shared
thread). Take the terminal emoji (or none).
- Reply: Navi's reply text in the thread. For a threaded scenario the
thread is shared with the ancestor, so attribute the reply to THIS turn: take
the bot user's message(s) in the thread with
ts > {this scenario's posted ts}
(posted after your send). Sequential execution within the family guarantees
no other turn in this thread is interleaving; concurrent families post into
different threads, so they never land here.
- ⚠️ Native-table blind spot. Navi posts final replies through Slack's
streaming API (
chat.startStream + markdown_text,
slack/ai_surface.py), so markdown tables render as native table
blocks in the real Slack client — but thread-reading tools
(slack_read_thread) return only the message's plain-text layer, where
those blocks are invisible. A reply that reads as a header followed by
an empty gap right where a table belongs almost always means "the table
rendered fine; your reader can't see it". (Bullet lines instead of a gap =
Navi's plain-text fallback path _post_markdown_response, which flattens
tables and IS readable.) Recover the actual table content from the trace's
agent_trace_tool_calls.result_preview rather than trusting the text read.
- Trace (the important one): query the dev DB. First select the dev
environment, then correlate — the key differs for top-level vs threaded:
mcp__mac-postgresql__list_environments / switch_environment → dev (see
config.md for the exact env name).
- Top-level scenario — correlate by the sent ts (
thread_ts == sent ts,
exact and unique). Poll until a row appears AND its status is terminal (the
trace is written asynchronously while Navi runs), up to the configured
timeout:SELECT trace_id, status, round_count, duration_ms, error_message,
agent_type, started_at
FROM agent_traces
WHERE thread_ts = '{sent_ts}'
AND trace_role = 'root' -- ignore sub-agent child traces
AND started_at >= '{send_time}' -- freshness guard, never match a stale run
ORDER BY started_at DESC
LIMIT 1;
- Threaded scenario (
in_thread_of) — thread_ts is the shared root ts
(event.thread_ts), so it matches the ancestor and every sibling turn in the
thread. Disambiguate by freshness: this turn's trace is the newest root
trace under the shared thread whose started_at is at/after when you posted
it. Because turns within a family run strictly sequentially (each waits
for its terminal reaction before the next turn in the same thread is sent) and
other families use a different thread_ts, the ancestor's trace started well
before this send_time and later siblings in this thread aren't sent yet — so
exactly one row qualifies:SELECT trace_id, status, round_count, duration_ms, error_message,
agent_type, started_at
FROM agent_traces
WHERE thread_ts = '{thread_root_ts}'
AND trace_role = 'root'
AND started_at >= '{send_time}' -- excludes the ancestor + earlier turns
ORDER BY started_at DESC
LIMIT 1;
If Navi's clock can lag the Slack post, allow a small skew buffer
(send_time - 5s); the ancestor is tens of seconds older, so the buffer stays
safe.
- Reliability rests on, strongest first: (1) for top-level,
thread_ts == sent ts is exact and globally unique — concurrency-safe, which is exactly why
independent (top-level) families can run in parallel; (2) for threaded,
thread_ts(root) + started_at ≥ send_time under sequential execution within
the family leaves exactly one candidate (concurrent families have a
different thread_ts, so they never enter this WHERE clause); (3)
trace_role='root' isolates the top-level trace from its sub-agent children;
(4) within any one thread only one turn is ever in flight. Fallback if a
top-level thread_ts somehow doesn't match: newest root trace with
started_at >= {send_time} AND source = 'slack_message' AND user_email = {test sender}. ⚠️ This freshness-only fallback is NOT concurrency-safe —
while other families are in flight, several runs from the same sender qualify.
Only use it after the other in-flight turns have quiesced, or temporarily drop
Max concurrency to 1 and retry that one scenario. Child/sub-agent traces for
this run link via parent_trace_id = {trace_id} or
agent_trace_delegations.child_trace_id.
- Pull the flow detail for the chosen
trace_id:-- tool calls (names, status, errors)
SELECT round_num, tool_name, status, error_message, result_size
FROM agent_trace_tool_calls WHERE trace_id = '{trace_id}' ORDER BY started_at;
-- sub-agent delegations (which specialist was scheduled, and its child trace)
SELECT requested_agent_type, resolved_agent_type, child_trace_id, task_summary
FROM agent_trace_delegations WHERE trace_id = '{trace_id}';
-- did each dispatched agent actually run, and finish clean? (one row per child)
SELECT trace_id, agent_type, status, error_message
FROM agent_traces WHERE trace_id IN ({child_trace_ids from the row(s) above});
-- nodes traversed
SELECT node_name, duration_ms FROM agent_trace_nodes
WHERE trace_id = '{trace_id}' ORDER BY entered_at;
-- failures / notable events
SELECT event_type, event_data FROM agent_trace_events
WHERE trace_id = '{trace_id}' ORDER BY created_at;
- Use read-only SELECTs only. Never write to the dev DB.
4. Judge (all three must agree)
Each scenario gives a plain-language expect (what the reply should convey +
what Navi should / must not do), an optional agents list (the specialist
sub-agents that must be dispatched), and an optional note. You translate
that intent into the layered check — the scenario does not hand you exact tool
names or status strings, so match against intent, not literal text:
- Reaction: terminal success (
white_check_mark) unless expect/note calls
out a different terminal state (e.g. the safety gate's stop-for-approval).
- Reply: consistent with what
expect says the reply should convey
(approximate / semantic match — you are the judge). Never FAIL a scenario
solely because table-shaped content seems missing from the reply read — that
is usually the native-table blind spot (step 3): the table is in the trace's
result_preview and renders fine in the real client. Judge the reply's table
content from the trace result; only fail the reply layer on evidence the user
actually saw wrong/missing content (e.g. a screenshot).
- Trace:
agent_traces.status is a clean terminal status with error_message
null (unless the scenario expects otherwise); the behavior expect describes is
borne out — the right kind of tools/delegations ran (follow delegations into
child traces), and anything expect says must not happen is absent (e.g. no
write/commit/push tools for a read-only scenario, no destructive tool for the
safety gate). Map the human's intent onto whatever the real tool/status names
turn out to be; never fail a scenario merely because a tool is named differently
than you guessed.
- Agent routing (when
agents is set — this assertion is exact, because the
scheduled specialist is the behavior under test): for each agent named in
agents, confirm a delegation resolved_agent_type matches it and that
child trace actually ran with a clean terminal status (not just requested). It
is a FAIL if an expected agent was never dispatched, its child trace errored or
is missing, or a clearly different specialist was scheduled in its place. For a
multi-agent fan-out, every listed agent must be present (extra agents are fine
unless expect says otherwise). agents: none asserts the opposite — no
delegation occurred and Navi answered directly; a delegation row then = FAIL.
Note requested_agent_type can differ from resolved_agent_type; judge on
resolved (what actually ran), and flag a requested≠resolved mismatch as a
finding worth reporting.
If any layer disagrees → FAIL. A green ✅ with a wrong reply, a trace error, or the
wrong agent scheduled is still a FAIL — that's the whole point of looking past the
emoji.
5. (optional) Cleanup
If the scenario defines cleanup (sandbox artifacts), note it. Do not perform
destructive cleanup automatically unless the scenario says so.
6. Post the run report to Slack (after ALL scenarios)
Once every scenario in the run has been judged, always post a single report
message to the report channel id from config.md (defaults to the test
channel). This runs on every completed run — pass or fail, one scenario or many.
- Post as a fresh top-level message (do not reply into any scenario thread).
- The report message must NOT
@mention Navi (it would trigger a new Navi
run). Refer to Navi by plain name only.
- Use the Slack report format below (a compact variant of the terminal
report — Slack markdown, trimmed replies). Keep it under Slack's size limit;
if there are many failures, include full detail for failures and collapse
passes to one line each.
- Keep the printed terminal report too — Slack is in addition to, not instead of.
- If posting the report fails (e.g. Slack error), report that as a finding in the
terminal output; do not silently drop it.
Report format
Two outputs per run: the terminal report (full detail, below) and the
Slack report (step 6 — same content, Slack-formatted and trimmed to fit).
Print a summary then per-scenario detail. For every FAILURE include all of:
SUMMARY: {passed}/{total} passed ({duration})
FAIL — {scenario.id}: {scenario.title}
sent: {exact message posted to Navi}
reaction: {terminal emoji or "none (timeout)"}
reply: {Navi's actual reply, trimmed}
trace: trace_id={id} status={status} rounds={n} error={error_message or none}
tools=[{tool_name:status, ...}] delegations=[{resolved_agent_type, ...}]
expected: {scenario.expect (+ agents + note), in plain words}
diverged: {one-line diagnosis of which layer(s) disagreed and how — name the
wrong/missing agent when routing is what failed}
For passes, one line each: PASS — {id}: {title}.
End with the overall verdict and, if anything failed, a short prioritized list of
what to investigate.
Slack report (step 6)
Post this to the report channel. Same facts as the terminal report, but built for
Slack mrkdwn so it stays scannable. Follow this structure literally — one fact
per labelled line. Do NOT write the report as flowing prose paragraphs (that is
what makes a run unreadable). Render exactly like this:
*Navi smoke test — {passed}/{total} passed* · {duration} · {dev DB env}
{✅ | ⚠️} *Verdict:* {one line — "all green" or "N failed, M are real bugs vs scenario drift"}
✅ *Passed ({k})*
• {id} — {title} ({3–6 word why, e.g. "no delegation, as asserted"})
❌ *Failed ({n})*
*{id} — {title}*
• reaction: {emoji} · reply: {reply trimmed to ONE line}
• trace: {trace_id} — {status}, {n} rounds, {error_message or "no error"}
• routing: {resolved agents that ran, or "none — answered inline"}
• expected: {scenario.expect in plain words, incl. agents/note}
• diverged: {which layer(s) disagreed and how — name the wrong/missing agent}
⏭️ *Skipped ({s})*
• {id} — {reason, e.g. unfilled <FILL> / no sandbox ids}
🔎 *Investigate first*
1. {prioritized item}
2. {prioritized item}
Rendering rules (these are what keep it clean — the screenshot bug was breaking
all of them):
- Emojis appear in only two places: the
Verdict: line, and after the
reaction: label. Never drop a ✅/❌/👀 into the middle of a sentence — a reader
can't tell whether it means "passed" or "the reaction was green". The per-section
✅ Passed / ❌ Failed headers carry the verdict; individual scenarios don't
repeat it.
- Backticks only for a real identifier you'd copy-paste (a
trace_id). Do NOT
wrap tool names, agent names, status words, repo paths, or agents=[…] in
backticks — that grey-box noise is what made the old report unreadable. Write
them as plain words: routing: none — answered inline (execute_direct_capability).
- One fact per
• line. Don't pack reaction + reply + trace + diagnosis into a
single run-on line. Each failed scenario is a small block of labelled lines.
- Trim the reply to one line (~one sentence). If it was an error/404, say so in
plain words and quote just the key fragment, not the whole payload.
- Put a blank line between failed-scenario blocks so they don't visually merge.
Drop any section that's empty: no failures → drop ❌ Failed and 🔎 Investigate
first; nothing skipped → drop ⏭️ Skipped. If everything passed, the whole
report is just the title, a ✅ verdict, and the ✅ Passed list.
Safety
- Scenarios run against the deployed bot and may execute real actions; keep them
scoped to the configured sandbox (see
config.md).
- The dev DB is queried read-only.
- Never invent a trace or a reply. If the trace can't be found, report that as a
finding (it may itself indicate a logging/wiring regression).
1---2name: navi-testing3description: Run post-deploy smoke tests against the deployed Navi Slack agent. Drives a dedicated Slack test channel (@mentions Navi), then VERIFIES each scenario on three layers — the Slack reaction lifecycle, Navi's actual thread reply, AND the execution Trace queried from the dev PostgreSQL — and reports failures with the scenario, what Navi actually did, and what was expected. Use when the user says "run navi tests", "smoke test navi", "test navi after deploy", "navi-testing", or wants to validate a Navi deployment.4---56# Navi Post-Deploy Smoke Test78## Overview910Navi's only functional entry point is a Slack message. This skill is the test11harness: it plays the role of a user in a dedicated Slack test channel, sends12each scenario's message to Navi, waits for Navi to finish, and then **judges the13outcome on three layers** — never on the reaction emoji alone:14151. **Reaction lifecycle** — necessary, not sufficient. Liveness signal only.162. **Navi's actual thread reply** — the user-facing content.173. **The execution Trace** in the **dev** PostgreSQL — the ground truth of what18 Navi actually did internally (rounds, nodes, delegations, tool calls, errors).1920A scenario only passes when all three agree with its expected result. The agent21running this skill is the judge — that is what absorbs LLM non-determinism.2223## Prerequisites2425- A **Slack MCP** connected to the workspace that hosts the test channel (any of26 the `slack_*` tools: post message, read thread replies, read reactions, look27 up users).28- A **PostgreSQL MCP** (`mcp__mac-postgresql__*`) that can reach the **dev**29 environment's agent database.30- `config.md` (this skill's folder) filled in: test channel id, Navi's bot31 handle/user id, dev DB environment name, timeout.32- `scenarios.yaml` (this skill's folder): the scenarios to run, validated by33 `scenarios.schema.json`. The user keeps adding to this file after each deploy.3435If a prerequisite is missing, say exactly which one and stop — do not fake a pass.3637## Inputs3839- Read **`config.md`** for the channel id, Navi identity, dev DB env, timeout,40 **max concurrency** (how many independent scenarios may run in parallel — see41 the Concurrency model below), and the **report channel id** (where step 6 posts42 the run report).43- **Validate `scenarios.yaml` first.** From the skill folder run44 `python3 validate_scenarios.py` (needs PyYAML + jsonschema). It checks required45 fields, id format, duplicate ids, and that every `agents` name is a known agent46 (a typo fails here, not mid-run). If it exits non-zero, report the listed47 problems and stop. If Python/deps are unavailable, validate `scenarios.yaml`48 against `scenarios.schema.json` yourself before proceeding.49- Read **`scenarios.yaml`** for the scenarios (each: `id`, `title`, `message`,50 `expect`, optional `agents` list, optional `note`, optional `in_thread_of`). If51 the user named specific scenarios in their prompt, run only those; otherwise run52 all.53- **`in_thread_of` (thread dependency).** A scenario with `in_thread_of: T-0XX`54 is **not** sent as a fresh top-level message — it is posted as a reply **inside55 the Slack thread of the referenced scenario**, to test multi-turn / context56 continuation (e.g. T-004 creates a ticket, then T-005 `in_thread_of: T-004`57 says "assign it to me" relying on the thread's context). The reference always58 points at a scenario defined **earlier** in the file (validated). When you run a59 **subset**, a threaded scenario needs its `in_thread_of` ancestor to have run60 first **in the same session** — auto-include the ancestor chain and run it61 top-to-bottom so the parent thread exists; if an ancestor cannot be run, report62 that and skip the dependent rather than silently posting it top-level.6364## Workflow6566### Concurrency model6768Independent scenarios run **in parallel**; turns that share a Slack thread run69**sequentially**. Work out the schedule before sending anything:70711. **Group scenarios into thread-families.** A *family* is a top-level scenario72 plus every scenario that threads onto it (transitively via `in_thread_of`). In73 the bundled scenarios, T-004 with its replies T-005/T-006/T-007/T-013 is one74 family; every other scenario is a family of one.752. **Within a family: strictly sequential**, in file (id) order — send a turn76 only after the previous turn in that family has reached a terminal reaction.77 The whole family shares one root `thread_ts`, so its turns must never78 interleave; this is exactly what the trace/reply correlation in steps 2–379 relies on.803. **Across families: parallel**, up to **Max concurrency** families in flight at81 once (read it from `config.md`). Each family has a distinct root `thread_ts`,82 so their reactions, replies, and traces never collide. Run a worklist: keep up83 to N families active; whenever one finishes, start the next pending family.84 Setting Max concurrency to `1` collapses this back to the old one-at-a-time85 behavior.8687**The cap counts families, not turns.** A family occupies one concurrency slot88for its *entire* sequential life — from its first turn's send until its last turn89reaches terminal — and only one of its turns is ever in flight at a time. The slot90frees only when the family completes; you then pull the next pending family into91it. So a family's root turn (e.g. T-004) runs concurrently with *other* families,92but **never** with its own later turns (T-005…). At any moment up to N turns are93in flight, at most one per thread.9495Concretely, you (the agent) are the scheduler: start min(N, #families) families;96each poll cycle, batch-read the in-flight turns (reactions/replies + the trace97queries can go out together); for every turn that hit terminal and has its three98layers, either send that family's next turn (same slot) or, if the family is done,99free the slot and start the next pending family. Repeat until every family is done.100101Run the per-turn steps (1 Send → 2 Wait → 3 Collect) for each turn as the schedule102dispatches it; you may **judge** (step 4) a scenario as soon as its three layers103are in. Do the cleanup (step 5) and post the run report (step 6) only **after all104families have finished**. For each turn:105106### 1. Send107- The message text is just `<@NAVI_USER_ID> {scenario.message}` — **append no108 test marker**. Every Slack message already gets a unique `ts`, and that ts (not109 an in-message tag) is the correlation key. A `[navi-test …]`-style marker would110 leak into Navi's request text and mislead it — Navi reasons over the whole111 message (it only strips the @mention), so for vague asks it treats the tag as112 the thing to act on. Keep the message clean.113- **Pace the sends.** Navi has a per-user rate limiter: messages posted ~1s114 apart can be rejected with a "You're sending messages too fast" thread reply —115 no reaction, no trace, the turn never ran. Leave a few seconds between sends116 (the family-concurrency cap alone does not guarantee spacing). A rate-limited117 send is NOT a scenario result: retry it in a later free slot, and report the118 limiter only as a run note (or as a finding if it recurs at human pacing).119- **Default (no `in_thread_of`):** post as a **fresh top-level message** (NOT a120 reply into an existing thread) so it gets its own unique ts.121- **Threaded (`in_thread_of: T-0XX`):** post as a **reply into the referenced122 scenario's thread** — `slack_send_message` with `thread_ts =` the **thread-root123 ts you recorded** for `T-0XX` (see below). This makes Navi handle it as a124 follow-up turn with the thread's prior context.125- Record, per scenario id, the posted message's own **ts** and the **send time**126 (both load-bearing for correlation), plus the **thread-root ts** to thread127 future replies under:128 - top-level scenario → thread-root ts = its own posted ts (Navi sets129 `thread_ts = event.ts`, so `agent_traces.thread_ts` equals it exactly).130 - threaded scenario → thread-root ts = **inherited** from its `in_thread_of`131 ancestor (Slack flattens nested replies into one thread:132 `thread_ts = event.thread_ts = root`). So a later scenario may thread off133 this one and still land in the same root thread.134- ⚠️ For a threaded scenario, `agent_traces.thread_ts` is the **shared root ts**,135 NOT this message's own ts — so it is NOT a unique key on its own. Correlate136 threaded scenarios by `thread_ts(root) + started_at ≥ send_time` under strictly137 sequential execution (step 3).138139### 2. Wait for completion140- Poll every few seconds: read the **reactions** on the sent message and the141 **thread replies**.142- Navi's reaction lifecycle (exact emoji names):143 | emoji | name | meaning |144 |---|---|---|145 | 👀 | `eyes` | received / actively processing |146 | ✅ | `white_check_mark` | finished successfully |147 | ❌ | `x` | raised an unhandled error |148 | 🚫 | `no_entry_sign` | cancelled (e.g. plan rejected) |149- Stop waiting when a **terminal** reaction (`white_check_mark` / `x` /150 `no_entry_sign`) appears, or the configured **timeout** elapses.151- If Navi posts a plan and waits for approval (👀 persists, no terminal152 reaction, an interactive plan message appears): this skill does **not** click153 Slack approval buttons. To test execution, phrase the scenario message so it154 carries the human's own authorization inline (Navi supports code-verified155 direct execute). Treat a stuck-at-approval result as a finding, not a pass,156 unless the scenario explicitly expects "stops for approval".157- On **timeout with no terminal reaction** → FAIL (record "no terminal reaction158 within {timeout}s; last 👀 still present").159160### 3. Collect the three layers161- **Reaction**: read reactions on **this scenario's own posted ts** (Navi reacts162 on the exact message it received, so this is correct even inside a shared163 thread). Take the terminal emoji (or none).164- **Reply**: Navi's reply text in the thread. For a **threaded** scenario the165 thread is shared with the ancestor, so attribute the reply to THIS turn: take166 the bot user's message(s) in the thread with `ts > {this scenario's posted ts}`167 (posted after your send). Sequential execution **within the family** guarantees168 no other turn in this thread is interleaving; concurrent families post into169 different threads, so they never land here.170 - ⚠️ **Native-table blind spot.** Navi posts final replies through Slack's171 streaming API (`chat.startStream` + `markdown_text`,172 `slack/ai_surface.py`), so markdown tables render as **native table173 blocks** in the real Slack client — but thread-reading tools174 (`slack_read_thread`) return only the message's plain-text layer, where175 those blocks are **invisible**. A reply that reads as a header followed by176 an empty gap right where a table belongs almost always means "the table177 rendered fine; your reader can't see it". (Bullet lines instead of a gap =178 Navi's plain-text fallback path `_post_markdown_response`, which flattens179 tables and IS readable.) Recover the actual table content from the trace's180 `agent_trace_tool_calls.result_preview` rather than trusting the text read.181- **Trace** (the important one): query the **dev** DB. First select the dev182 environment, then correlate — **the key differs for top-level vs threaded**:183 - `mcp__mac-postgresql__list_environments` / `switch_environment` → dev (see184 `config.md` for the exact env name).185 - **Top-level scenario** — correlate by the sent ts (`thread_ts == sent ts`,186 exact and unique). Poll until a row appears AND its `status` is terminal (the187 trace is written asynchronously while Navi runs), up to the configured188 timeout:189 ```sql190 SELECT trace_id, status, round_count, duration_ms, error_message,191 agent_type, started_at192 FROM agent_traces193 WHERE thread_ts = '{sent_ts}'194 AND trace_role = 'root' -- ignore sub-agent child traces195 AND started_at >= '{send_time}' -- freshness guard, never match a stale run196 ORDER BY started_at DESC197 LIMIT 1;198 ```199 - **Threaded scenario (`in_thread_of`)** — `thread_ts` is the **shared root ts**200 (`event.thread_ts`), so it matches the ancestor and every sibling turn in the201 thread. Disambiguate by **freshness**: this turn's trace is the newest root202 trace under the shared thread whose `started_at` is at/after when you posted203 it. Because turns **within a family** run strictly sequentially (each waits204 for its terminal reaction before the next turn in the same thread is sent) and205 other families use a different `thread_ts`, the ancestor's trace started well206 before this `send_time` and later siblings in this thread aren't sent yet — so207 exactly one row qualifies:208 ```sql209 SELECT trace_id, status, round_count, duration_ms, error_message,210 agent_type, started_at211 FROM agent_traces212 WHERE thread_ts = '{thread_root_ts}'213 AND trace_role = 'root'214 AND started_at >= '{send_time}' -- excludes the ancestor + earlier turns215 ORDER BY started_at DESC216 LIMIT 1;217 ```218 If Navi's clock can lag the Slack post, allow a small skew buffer219 (`send_time - 5s`); the ancestor is tens of seconds older, so the buffer stays220 safe.221 - Reliability rests on, strongest first: (1) for top-level, `thread_ts == sent222 ts` is exact and globally unique — concurrency-safe, which is exactly why223 independent (top-level) families can run in parallel; (2) for threaded,224 `thread_ts(root) + started_at ≥ send_time` under sequential execution **within225 the family** leaves exactly one candidate (concurrent families have a226 different `thread_ts`, so they never enter this WHERE clause); (3)227 `trace_role='root'` isolates the top-level trace from its sub-agent children;228 (4) within any one thread only one turn is ever in flight. Fallback if a229 top-level `thread_ts` somehow doesn't match: newest root trace with230 `started_at >= {send_time}` AND `source = 'slack_message'` AND `user_email =231 {test sender}`. ⚠️ This freshness-only fallback is **NOT concurrency-safe** —232 while other families are in flight, several runs from the same sender qualify.233 Only use it after the other in-flight turns have quiesced, or temporarily drop234 Max concurrency to 1 and retry that one scenario. Child/sub-agent traces for235 this run link via `parent_trace_id = {trace_id}` or236 `agent_trace_delegations.child_trace_id`.237 - Pull the flow detail for the chosen `trace_id`:238 ```sql239 -- tool calls (names, status, errors)240 SELECT round_num, tool_name, status, error_message, result_size241 FROM agent_trace_tool_calls WHERE trace_id = '{trace_id}' ORDER BY started_at;242 -- sub-agent delegations (which specialist was scheduled, and its child trace)243 SELECT requested_agent_type, resolved_agent_type, child_trace_id, task_summary244 FROM agent_trace_delegations WHERE trace_id = '{trace_id}';245 -- did each dispatched agent actually run, and finish clean? (one row per child)246 SELECT trace_id, agent_type, status, error_message247 FROM agent_traces WHERE trace_id IN ({child_trace_ids from the row(s) above});248 -- nodes traversed249 SELECT node_name, duration_ms FROM agent_trace_nodes250 WHERE trace_id = '{trace_id}' ORDER BY entered_at;251 -- failures / notable events252 SELECT event_type, event_data FROM agent_trace_events253 WHERE trace_id = '{trace_id}' ORDER BY created_at;254 ```255 - Use **read-only** SELECTs only. Never write to the dev DB.256257### 4. Judge (all three must agree)258Each scenario gives a plain-language **`expect`** (what the reply should convey +259what Navi should / must not do), an optional **`agents`** list (the specialist260sub-agents that must be dispatched), and an optional **`note`**. You translate261that intent into the layered check — the scenario does **not** hand you exact tool262names or status strings, so match against intent, not literal text:263- **Reaction**: terminal success (`white_check_mark`) unless `expect`/`note` calls264 out a different terminal state (e.g. the safety gate's stop-for-approval).265- **Reply**: consistent with what `expect` says the reply should convey266 (approximate / semantic match — you are the judge). Never FAIL a scenario267 solely because table-shaped content seems missing from the reply read — that268 is usually the native-table blind spot (step 3): the table is in the trace's269 `result_preview` and renders fine in the real client. Judge the reply's table270 content from the trace result; only fail the reply layer on evidence the user271 actually saw wrong/missing content (e.g. a screenshot).272- **Trace**: `agent_traces.status` is a clean terminal status with `error_message`273 null (unless the scenario expects otherwise); the behavior `expect` describes is274 borne out — the right kind of tools/delegations ran (follow delegations into275 child traces), and anything `expect` says **must not** happen is absent (e.g. no276 write/commit/push tools for a read-only scenario, no destructive tool for the277 safety gate). Map the human's intent onto whatever the real tool/status names278 turn out to be; never fail a scenario merely because a tool is named differently279 than you guessed.280- **Agent routing** (when `agents` is set — this assertion *is* exact, because the281 scheduled specialist is the behavior under test): for **each** agent named in282 `agents`, confirm a delegation `resolved_agent_type` matches it **and** that283 child trace actually ran with a clean terminal status (not just requested). It284 is a FAIL if an expected agent was never dispatched, its child trace errored or285 is missing, or a clearly different specialist was scheduled in its place. For a286 multi-agent fan-out, every listed agent must be present (extra agents are fine287 unless `expect` says otherwise). `agents: none` asserts the **opposite** — no288 delegation occurred and Navi answered directly; a delegation row then = FAIL.289 Note `requested_agent_type` can differ from `resolved_agent_type`; judge on290 **resolved** (what actually ran), and flag a requested≠resolved mismatch as a291 finding worth reporting.292293If any layer disagrees → FAIL. A green ✅ with a wrong reply, a trace error, or the294wrong agent scheduled is still a FAIL — that's the whole point of looking past the295emoji.296297### 5. (optional) Cleanup298If the scenario defines cleanup (sandbox artifacts), note it. Do not perform299destructive cleanup automatically unless the scenario says so.300301### 6. Post the run report to Slack (after ALL scenarios)302Once every scenario in the run has been judged, **always** post a single report303message to the **report channel id** from `config.md` (defaults to the test304channel). This runs on every completed run — pass or fail, one scenario or many.305306- Post as a **fresh top-level message** (do not reply into any scenario thread).307- The report message must **NOT** `@mention` Navi (it would trigger a new Navi308 run). Refer to Navi by plain name only.309- Use the **Slack report format** below (a compact variant of the terminal310 report — Slack markdown, trimmed replies). Keep it under Slack's size limit;311 if there are many failures, include full detail for failures and collapse312 passes to one line each.313- Keep the printed terminal report too — Slack is in addition to, not instead of.314- If posting the report fails (e.g. Slack error), report that as a finding in the315 terminal output; do not silently drop it.316317## Report format318319Two outputs per run: the **terminal report** (full detail, below) and the320**Slack report** (step 6 — same content, Slack-formatted and trimmed to fit).321322Print a summary then per-scenario detail. For **every FAILURE** include all of:323324```325SUMMARY: {passed}/{total} passed ({duration})326327FAIL — {scenario.id}: {scenario.title}328 sent: {exact message posted to Navi}329 reaction: {terminal emoji or "none (timeout)"}330 reply: {Navi's actual reply, trimmed}331 trace: trace_id={id} status={status} rounds={n} error={error_message or none}332 tools=[{tool_name:status, ...}] delegations=[{resolved_agent_type, ...}]333 expected: {scenario.expect (+ agents + note), in plain words}334 diverged: {one-line diagnosis of which layer(s) disagreed and how — name the335 wrong/missing agent when routing is what failed}336```337338For passes, one line each: `PASS — {id}: {title}`.339340End with the overall verdict and, if anything failed, a short prioritized list of341what to investigate.342343### Slack report (step 6)344345Post this to the report channel. Same facts as the terminal report, but built for346Slack mrkdwn so it stays scannable. **Follow this structure literally — one fact347per labelled line. Do NOT write the report as flowing prose paragraphs** (that is348what makes a run unreadable). Render exactly like this:349350```351*Navi smoke test — {passed}/{total} passed* · {duration} · {dev DB env}352353{✅ | ⚠️} *Verdict:* {one line — "all green" or "N failed, M are real bugs vs scenario drift"}354355✅ *Passed ({k})*356• {id} — {title} ({3–6 word why, e.g. "no delegation, as asserted"})357358❌ *Failed ({n})*359360*{id} — {title}*361• reaction: {emoji} · reply: {reply trimmed to ONE line}362• trace: {trace_id} — {status}, {n} rounds, {error_message or "no error"}363• routing: {resolved agents that ran, or "none — answered inline"}364• expected: {scenario.expect in plain words, incl. agents/note}365• diverged: {which layer(s) disagreed and how — name the wrong/missing agent}366367⏭️ *Skipped ({s})*368• {id} — {reason, e.g. unfilled <FILL> / no sandbox ids}369370🔎 *Investigate first*3711. {prioritized item}3722. {prioritized item}373```374375Rendering rules (these are what keep it clean — the screenshot bug was breaking376all of them):377- **Emojis appear in only two places**: the `Verdict:` line, and after the378 `reaction:` label. Never drop a ✅/❌/👀 into the middle of a sentence — a reader379 can't tell whether it means "passed" or "the reaction was green". The per-section380 ✅ *Passed* / ❌ *Failed* headers carry the verdict; individual scenarios don't381 repeat it.382- **Backticks only for a real identifier** you'd copy-paste (a `trace_id`). Do NOT383 wrap tool names, agent names, status words, repo paths, or `agents=[…]` in384 backticks — that grey-box noise is what made the old report unreadable. Write385 them as plain words: `routing: none — answered inline (execute_direct_capability)`.386- **One fact per `•` line.** Don't pack reaction + reply + trace + diagnosis into a387 single run-on line. Each failed scenario is a small block of labelled lines.388- **Trim the reply to one line** (~one sentence). If it was an error/404, say so in389 plain words and quote just the key fragment, not the whole payload.390- Put a blank line between failed-scenario blocks so they don't visually merge.391392Drop any section that's empty: no failures → drop ❌ *Failed* and 🔎 *Investigate393first*; nothing skipped → drop ⏭️ *Skipped*. If everything passed, the whole394report is just the title, a ✅ verdict, and the ✅ *Passed* list.395396## Safety397398- Scenarios run against the deployed bot and may execute real actions; keep them399 scoped to the configured **sandbox** (see `config.md`).400- The dev DB is queried **read-only**.401- Never invent a trace or a reply. If the trace can't be found, report that as a402 finding (it may itself indicate a logging/wiring regression).