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.
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.
Keep the live simulator for the judgment questions — whether Recall picks sensible branches, whether a persona reasons well from memory. Zreplay answers the other question: given a known scenario, does the system handle it.
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.
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.
This is why the mock model id carries the room's real model alongside the tape
handle — mock:<tape>#<agentKey>#<roomId>#<fallbackModel> — nothing else is
still in scope by the time a tape runs out deep inside a stream.
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.
| Option | Default | What it does |
|---|---|---|
--scenario <name> | TysonVsIronPeak | Folder 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-brand | off | Leave the brand seat an anonymous guest, so every effect is held instead of delivered |
--real-room | off | Create a real deal room instead of a playground one — see below |
--send-emails | off | Let a --real-room run send its real wake-turn push emails too |
--guard | off | Force a ZGuard run at the end, which kicks Arvist as a separate request |
--compile-only | off | Compile and report, touching no database |
--quiet | off | Only 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
The expectations are derived from the script rather than restated in it, so an assertion cannot drift from the fixture it is checking.
@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.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.
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.
--real-roomEverything 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
Unlike ensureDealRoom, this never dedupes against an existing room for the
same talent+brand pair — every run creates a fresh one. Repeated --real-room
runs against the same two accounts pile up separate deal rooms in their inboxes,
by design: the point is a clean conversation to inspect each time, not a
production-faithful single permanent room.
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.
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:
--send-emails (see above).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.
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.
The server process needs Z_AGENT_MOCK_ENABLED=true in its own environment. A
newly added env var needs the server restarted — a room carrying a tape refuses
to run rather than reaching a real model, so this shows up as a failed turn
rather than a silent fallback.