Runs a scripted scenario against the real pipeline with no model calls, and asserts the outcome the script implies

Zreplay runs a complete talent/brand negotiation through the real Z-Agent pipeline in about two seconds, for zero tokens. Every agent in the room reads its lines from a .zas script instead of calling a model, so persistence, pane routing, planner memory writes, goal events, offer minting, cross-pane wake turns and the streaming response all run on their production paths.

npm run zreplay --workspace @zooly/z-agent-srv -- --scenario TysonVsIronPeak
Replay clean: 7 turns, 31 runs, $0 spent.

Open in the playground:
  http://localhost:3004/z-agent/playground?room=jwg9lblyy11hcvnwnqbbk

Scripts are written by Zauthor or by hand.

What it does and does not test

This exercises orchestration, not model judgment. Nothing reads the memory document in a fully scripted run, which is exactly why a planner beat can be one line instead of a recorded sixteen-thousand-token blob.

How the mocking works

Every text and object model call in Z-Agent passes through one function, resolveModel. A room carrying a mock_tape_id resolves each of its agents to a scripted model instead of a gateway model, so the substitution happens in one place and everything downstream is untouched.

The tape is keyed per agent, not one flat list. A single chat turn makes four or five calls across two conversations — PlannerGuidAg, PlannerDistilMemAg, then a wake into the other participant's pane, then Recall, then the persona — so a shared cursor would hand one agent's line to another. Each agent has its own cursor, which means a call that does not happen simply does not advance anything.

Agents the script does not mention are served a standing no-op: Recall returns no paths (which the existing code reads as "pass the whole document"), ZGuard returns a clean bill of health, Arvist writes nothing. That last no-op is belt-and-braces. A room carrying a mock tape also skips durable-memory promotion in the close path itself, so even a fallback to a live Arvist cannot write tape fiction onto a real account.

Running past the end of the script

A script is finite, but a conversation driven by hand in the UI is not. When an agent's tape has no entry left for a call, resolveModel falls back to the room's real, originally-configured model for that one call, using the exact prompt, messages and tools the AI SDK already built — the same call that would have run had the room never been mocked at all. The server logs a warning naming the agent and the model it fell back to, and the conversation continues with a live reply instead of erroring.

For zreplay itself this is a feature, not a loophole: the run keeps going to completion instead of stopping mid-script, and the cost assertion below still catches it — a fallback call is a real call, so it reports a real costUsd on what should have been a free run.

Options

OptionDefaultWhat it does
--scenario <name>TysonVsIronPeakFolder holding the .zas script
--script <path>A script file directly, instead of a scenario folder
--room <id>Replay into an existing room; its cursors are rewound first
--talent-account <q>Bind the talent seat, by account id or search term
--brand-account <q>Bind the brand seat
--guest-brandoffLeave the brand seat an anonymous guest, so every effect is held instead of delivered
--real-roomoffCreate a real deal room instead of a playground one — see below
--send-emailsoffLet a --real-room run send its real wake-turn push emails too
--guardoffForce a ZGuard run at the end, which kicks Arvist as a separate request
--compile-onlyoffCompile and report, touching no database
--quietoffOnly print the summary

Offer minting needs a real account on both sides, so a scenario that ends in an offer wants both --account flags:

npm run zreplay --workspace @zooly/z-agent-srv -- \
  --scenario TysonVsIronPeak \
  --talent-account "Asi Meskin" \
  --brand-account "Elia Weiss123" \
  --guard

Without them the goal still flips and the event is still recorded, but the offer assertion is reported as skipped rather than failing.

--compile-only needs no database and no credentials at all, which makes it the fast way to check a script you have just edited:

npm run zreplay --workspace @zooly/z-agent-srv -- --scenario MaraVsNimbus --compile-only

What it asserts

The expectations are derived from the script rather than restated in it, so an assertion cannot drift from the fixture it is checking.

  • Goal statuses. The beats are applied in order and the result is compared against the room's memory, so the whole planner → memory → goal-event path is checked end to end.
  • The offer, in whichever direction the script implies. A script with an @offer beat must produce an offers row with the expected minor-unit amount; a script without one must produce none. That second half is what catches a refusal scenario quietly minting an offer.
  • Every scripted line was reached. A cursor short of its entry count means the conversation ended before the script did, which is a scenario that tested less than it claims to.
  • The leak boundary. Each turn records the memory slice it ran with, so the stored transcript is the evidence: one participant's guidance must never appear in another's slice, and the planner's private branch in nobody's.
  • Nothing was spent. Total cost across every run in the room must be zero.

That last one catches two different problems, and they call for different fixes. If every offending run's model id still starts with mock:, that agent was routed through the mock correctly and simply ran past the end of its script — extend the script. If a model id does not start with mock:, an agent was never routed through the mock at all, which is the deeper bug this whole exercise exists to catch.

The guest-brand variant

A brand arriving through a z-link is an anonymous guest until they log in, and while they are, every effect in the room is frozen: pushes are composed and parked as pending effects rather than delivered, and offer minting waits for a real account on both ends.

npm run zreplay --workspace @zooly/z-agent-srv -- \
  --scenario TysonVsIronPeak --talent-account "Asi Meskin" --guest-brand
held effects:     3 push(es), 1 run(s) waiting on the guest claiming the room
offer:            correctly withheld while the brand is a guest

The same script covers both cases with no edits. This variant is also why the mock keeps its own cursor rather than inferring one from stored messages: a held push composes a persona turn that is never written as a message, so counting messages would hand the same scripted line out twice.

Testing dashboard-visible side effects with --real-room

Everything above runs in a playground room, which is deliberately invisible outside the admin playground — it never appears in anyone's /dashboard/conversations. That is the wrong room kind for checking whether a scripted negotiation shows up correctly in the real UI: the inbox entry, the unread badge, the deep link.

--real-room creates a deal room instead, titled the same way a real one is ("BrandName × TalentName"), and requires both --talent-account and --brand-account — a room meant to show up in real dashboards needs two real accounts to show up for:

npm run zreplay --workspace @zooly/z-agent-srv -- \
  --scenario KenjiVsSolstice \
  --talent-account "Elia Talent" \
  --brand-account "Elia Brand" \
  --real-room --guard
kind:            deal

Real room: this conversation will appear in both accounts' own
/dashboard/conversations. Any offer minted is still flagged isDemo and
appears marked Demo in Offers & Deals. Wake-turn push
emails stay suppressed unless --send-emails was passed.
...
offer:            brand-offer → 1on0yoxouhgmdl4yaplh4 status=APPROVED amount=1300000 USD isDemo=true

Scripted offers appear in Offers & Deals marked Demo. A room running a tape mints isDemo: true whether it is a playground room or a --real-room deal room, so the listing can mark it Demo instead of looking like a live deal. zreplay prints the id either way, and fails the run if a scripted offer is not flagged.

Emails stay off unless you ask. A wake turn normally emails the participant, and that is suppressed for any scripted room so a replay does not mail a real person the same line every run. --send-emails opts back in for a --real-room run, if you specifically want to test the email side effect too — the accounts under test may have real addresses attached, so this is off by default.

Safety

Scripted rooms are refused unless Z_AGENT_MOCK_ENABLED=true. This is a different situation from running past the end of a script, and it does not fall back: a room carrying a tape where scripting is switched off for the whole deployment stops working until someone looks, rather than either falling back (which would spend tokens nobody asked for) or honouring the tape (which would read scripted negotiation terms to a real counterparty as though their agent meant them). There is no tape to run out of in this case — mocking was never switched on at all.

Two other side effects are suppressed for scripted rooms:

  • Wake emails. A wake turn normally emails the participant. A replay would otherwise mail a real person the same scripted line on every run — opt back in with --send-emails (see above).
  • The external scheduler. The quiet-room chain writes its Guard watch row but never creates the EventBridge timer, so a Lambda cannot fire against a scripted room a minute after you have walked away. Zreplay also clears the Guard watch when it finishes.

Z_AGENT_GUARD_DEBOUNCE_MS is set low for the run, so --guard exercises the chain in seconds instead of the production one minute. Arvist is kicked as a fire-and-forget request; the CLI reports whether that kick was dispatched (triggered=true), not the promotion outcomes. A run without NEXT_PUBLIC_APP_URL or CRON_SECRET skips the kick and reports triggered=false.

Replaying by hand in the UI

A scripted agent ignores its prompt entirely, which has a useful consequence: in a scripted room you can type anything and still get the next scripted reply. The /* ... */ blocks in a script are taken verbatim so they can be pasted straight in, but nothing requires it — which is what makes a scripted room usable for a demo as well as a test.

To get a room you can drive by hand, create it without replaying it:

npm run zreplay --workspace @zooly/z-agent-srv -- \
  --scenario KenjiVsSolstice --push --create-only

--create-only creates the room and stops, printing the room handle and the playground URL with the conversation still at its first line. --push also stores the script under its handle so a separate process — which is what the server is — can find it. See Zpush for pushing to a server that does not have the repo checked out.

GET /api/z-agent/rooms/<id>/script returns the scripted human lines for the pane, and DELETE on the same route rewinds the cursors so the room can replay its script from the top.