feat(simulator): dev-only Discord chat simulator on the real agent#146
feat(simulator): dev-only Discord chat simulator on the real agent#146rayhanadev wants to merge 9 commits into
Conversation
A gated `/simulator` page (SIMULATOR_ENABLED=1, dev only) that runs the
real orchestrator locally and renders the exact Discord UX it produces,
so message construction and the model's context can be iterated without
deploying to a live server.
It drives the bot's real plumbing rather than reimplementing it: builds a
GATEWAY_MESSAGE_CREATE packet + HandlerContext, runs the actual mention
detection and `prepareFreshTurn` (thread creation, lead-in fetch,
placeholder, AgentContext), then `streamTurn`. Two virtual Discord
transports — a fake `@discordjs/core` API and a smart `@discordjs/rest`
proxy — capture exactly what the bot sends; events fan out over SSE to a
React UI (chat → thread → follow-up) plus an inspector with three tabs:
session controls, a live context snapshot (the real buildContextSnapshot
/inspect-context breakdown), and an execution trace fed by teeing the
orchestrator's tool-call stream.
Production seams, all dev-gated and no-op in production:
- `__setDiscordRestForSimulation` / `__setRedisForSimulation` overrides
- extracted `prepareFreshTurn` + `replyEmptyMention` from the mention handler
- extracted `conversation-turn.ts` (history capping) shared with chat.ts
- gated `app.route("/simulator")` mount behind `isSimEnabled()`
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #146 +/- ##
==========================================
+ Coverage 98.42% 98.44% +0.01%
==========================================
Files 73 74 +1
Lines 1910 1934 +24
Branches 488 496 +8
==========================================
+ Hits 1880 1904 +24
Misses 14 14
Partials 16 16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
knip (CI) flagged SIM_DEFAULT_TIMEZONE as an unused export — it was, once the sim moved to `AgentContext.fromPacket` and `buildSimContext` was removed. Also teach knip about the repo's first Next frontend: add `page.tsx`/`layout.tsx` entries and `.tsx` to `project` so it traces the simulator's components (without which avatarColor/initials read as unused — they're used only from `.tsx`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — a few minor seam/lifecycle observations inline.
Reviewed changes — a gated, dev-only /simulator that runs the real orchestrator locally and renders the bot's Discord UX, plus the production-side extractions that let it reuse the real plumbing instead of reimplementing it.
- Simulator runtime (
src/lib/simulator/**) —SimConversationturn loop,SimEventBusSSE promise-queue fan-out,run-registrysingle-active-session,bootstrapinstalling the process-global fakes, virtual guild + fake core/REST transports, and atrace-orchestratorthat tees the real orchestrator stream into the Trace tab. - Simulator UI (
src/app/simulator/**) — two-pane React chat + inspector, hand-rolled Discord-markdown renderer, message-store, and the SSE hook. - Production seams (dev-gated) —
__setDiscordRestForSimulationturns thediscordREST export into a swappableProxy;__setRedisForSimulationoverrides the memoized Redis client;app.route("/simulator")mounts only behindisSimEnabled(). - Shared extractions —
prepareFreshTurn+replyEmptyMentionlifted out of the mention handler, andconversation-turn.ts(truncateForHistory/summarizeDroppedHistory/capHistory/stampCurrentTime) shared withworkflows/chat.ts;LEADIN_TURN_LIMITmoved tosrc/lib/ai/constants.ts.
I verified the production-path refactor carries no semantic drift: the wide-event metric counts (payload.context.recentMessages/referencedContext) are carried unchanged by AgentContext.toJSON(), capHistory's injected summarizer keeps the workflow's "use step" durability while the simulator uses the byte-identical plain default, and the moved constants are unchanged. The findings below are all dev-only and non-blocking.
ℹ️ Concurrent turns on one session are unguarded
SimConversation.runTurn mutates this.messages, this.turnCount, this.totalUsage, this.stableContext, and this.lastBotMessageId, and getOrCreateSession hands back the same instance for a session id. Two overlapping /chat POSTs (two tabs, a double-submit, or a quick retry) interleave those mutations — history ends up with two consecutive user turns, turnCount/totalUsage lose an update via read-modify-write, and both SSE streams see each other's run.finish. It's dev-only and self-inflicted, but a one-line in-flight guard would make the failure obvious instead of silently corrupting the transcript.
Technical details
# Concurrent turns on one session are unguarded
## Affected sites
- `src/lib/simulator/conversation.ts:169` — `runTurn` is re-entrant; no lock/flag.
- `src/lib/simulator/run-registry.ts:11` — `getOrCreateSession` returns the shared instance.
## Required outcome
- Overlapping turns on one session must not interleave `messages`/`turnCount`/`totalUsage`, and one turn's `run.finish` must not terminate another turn's SSE pump early.
## Suggested approach (optional)
- Cheapest: reject re-entry — `if (this.running) throw new Error("turn in progress");` set/cleared around the body. For a dev tool an explicit error is friendlier than silent serialization.
- Alternatively chain: `this.running = (this.running ?? Promise.resolve()).then(() => this.doTurn(req)); return this.running;`
## Open questions for the human
- Is the simulator ever expected to serve more than one local tab/process? If strictly single-tab localhost this is cosmetic.ℹ️ import simulator is unconditional — module graph enters the production bundle
The if (isSimEnabled()) gate only guards the app.route() call; the import simulator from "./routes/simulator" runs at module load in every process, pulling the whole simulator graph (bootstrap → the override setters, fakes, SimConversation) into the production bundle. No override fires at import time today, so it's latent rather than active — but it removes the isolation guarantee the gate is meant to provide, and any future top-level init in that graph would run in prod.
Technical details
# Unconditional simulator import in production server
## Affected sites
- `src/server/index.ts:10` — `import simulator from "./routes/simulator";` always evaluated.
- `src/server/index.ts:24` — `if (isSimEnabled()) app.route("/simulator", simulator);` only gates the mount.
## Required outcome
- The simulator module graph (and any future top-level side effects in it) must not load in a production process.
## Suggested approach (optional)
- Move to a dynamic import inside the gate:
`if (isSimEnabled()) app.route("/simulator", (await import("./routes/simulator")).default);`
(or a top-level `await` / lazy mount, matching how the server already initializes).Claude Opus | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single new commit since my prior review (62986ad → 38da911) is pure cleanup with no behavioral impact.
- Drop dead constant — removes the unused
SIM_DEFAULT_TIMEZONEfromsrc/lib/simulator/constants.ts. - Widen knip coverage —
knip.jsonnow scanssrc/**/*.{ts,tsx}and addspage.tsx/layout.tsxas entry points so the new simulator frontend is dead-code-linted.
Neither change touches the seams or runtime logic from my prior review, and the knip.json edit is a clean single-key update (no duplicate keys). The five informational findings from the initial review (override-setter gating, bus.close() on session swap, concurrent-turn guard, unconditional simulator import, .gitignore .env* glob) still stand and remain non-blocking.
Claude Opus | 𝕏
…ignore Triage of the pullfrog review on #146: - `.gitignore`: drop the accidental blanket `.env*` (it shadowed the tracked `.env.example`); `.env.local`/`.env*.local` already cover the secrets. - `__setDiscordRestForSimulation` / `__setRedisForSimulation`: gate on `SIMULATOR_ENABLED === "1"` too (not just `NODE_ENV`), matching the route mount so the transport/Redis swaps can't fire on a non-prod preview deploy. fake-rest.test sets the flag to keep exercising the swap. - `run-registry`: close the outgoing `SimEventBus` on session swap/reset so a lingering SSE subscriber is released instead of leaking. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Triaged the review (thanks @pullfrog) in 2503485 — all three were valid:
|
|
Verified all three triaged findings are correctly addressed in 2503485 and resolved their threads:
No code changes needed from me — the fixes were already live. Replied to and resolved all three threads. The two body-only informational findings (concurrent-turn guard, unconditional simulator import) had no inline threads and were not part of this triage, so they remain open for your discretion.
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the new commit since my last review (38da911 → 2503485) tightens the dev-only simulator gates and drops the stray .env* ignore, directly addressing three of my prior informational findings.
- Gated the override setters on
SIMULATOR_ENABLED—__setDiscordRestForSimulation(src/lib/ai/tools/discord/client.ts) and__setRedisForSimulation(src/lib/redis/client.ts) now bail unlessNODE_ENV !== "production"andSIMULATOR_ENABLED === "1", matching theisSimEnabled()route mount so the public setters can't swap global agent transports on a non-prod preview/staging deploy. - Closed the bus on session swap —
getOrCreateSessionandresetSessions(src/lib/simulator/run-registry.ts) now callcurrent?.bus.close()before discarding the oldSimConversation, releasing any lingering SSE subscriber instead of leaking a never-resolvingnext()promise. - Dropped the
.env*ignore —.gitignoreno longer shadows the committed.env.example. - Kept tests green under the new gate —
fake-rest.test.tsandconversation.test.tssetSIMULATOR_ENABLED = "1"so the now-gated setters still fire under test.
I verified each fix against the current tree and confirmed the gating logic is the exact De Morgan inverse of isSimEnabled(), so behavior matches the route gate. The affected fake-rest / conversation / redis suites pass under vitest. The two remaining prior findings (concurrent-turn guard, unconditional import simulator) are untouched by this commit and stay non-blocking — not re-raised here.
Claude Opus | 𝕏
`src/lib/simulator/**` is gated, dev-only tooling that never runs in production — like the already-excluded `src/app/**`, `src/server/**`, and test helpers. Its load-bearing pure logic keeps its own unit tests; the orchestration glue (conversation/bootstrap) is exercised in-browser, so counting it toward the global thresholds isn't meaningful. The frontend and route were already excluded via `src/app/**` / `src/server/**`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uard - vitest: add `src/lib/simulator/**` to the coverage exclude list — it's gated, dev-only tooling that never runs in production (like the already-excluded `src/app/**` / `src/server/**` / test helpers). Its pure logic keeps its own unit tests; the orchestration glue is exercised in-browser. - cover both branches of the `__setRedisForSimulation` gate so the new no-op-when-disabled path is verified (and Codecov patch/project stay green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mping) conversation-turn.ts was extracted from chat.ts in this PR and only had transitive coverage via chat.test, leaving the summary-failure fallback and the non-user-skip branch uncovered. Add a direct test (injecting a fake `summarize` so no model is needed) covering truncate, time-stamping, the under-cap no-op, the summarized-drop path, and the throw→plain-drop fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Directly test summarizeDroppedHistory (mocking generateText) instead of relying on chat.test's transitive coverage, and add the all-non-user history case that walks the drop scan to the cap. Closes the last patch-coverage gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The API routes 404 on deployments (NODE_ENV=production → isSimEnabled() false), but the Next App Router page still rendered there — serving a UI whose backend isn't mounted. Add a server-component segment layout that calls notFound() when the gate is off, so the whole /simulator route is hidden on any deployment and only renders under local `next dev` with SIMULATOR_ENABLED=1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single new commit since my last review (c924530 → acdd2cc) gates the dev-only /simulator UI segment so it matches its already-gated API.
- Gated the
/simulatorpage server-side — newsrc/app/simulator/layout.tsx, a Server Component (no"use client") that callsnotFound()unlessisSimEnabled(). This closes a real gap: previously only the Honoapp.route("/simulator")mount was gated, so the Next.js page itself would render in any env that served it. Now the whole segment 404s underNODE_ENV=production, mirroring the API.
I confirmed the gate runs server-side (env-readable), nests correctly under the root <html>/<body> layout, and that a production build simply prerenders the 404 — the desired outcome. The two remaining prior informational findings (concurrent-turn guard in SimConversation.runTurn, unconditional import simulator into the production bundle in src/server/index.ts) are untouched by this commit and stay non-blocking — not re-raised here.
Claude Opus | 𝕏
…h gap) The only line Codecov flagged was capHistory's default parameter (`summarize = summarizeDroppedHistory`) — every test and chat.ts pass an explicit summarizer, so the default branch was never taken. Add a no-arg capHistory call to exercise it, and clear the generateText mock between tests so its call-count assertion stays isolated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Why
There was no way to see what the bot sends to Discord — streamed edits, tool-activity lines, subagent previews, footers, message splitting, approval cards — without deploying and poking a real server: slow, noisy, not reproducible. This adds a gated, dev-only
/simulatorthat runs the real orchestrator locally and renders that UX faithfully, so message construction and the model's context can be iterated in a tight loop. It's also a harness for running agents locally.Before:
After:
What changed
/simulatorpage (src/app/simulator/**): a two-pane React UI — a high-fidelity Discord chat column (chat → thread → follow-up) and a tabbed inspector (session Controls, live Context snapshot, execution Trace). Hand-rolled Discord-markdown renderer, embeds, and clickable approval cards.src/lib/simulator/**): builds aGATEWAY_MESSAGE_CREATEpacket +HandlerContext, runs the actual mention detection andprepareFreshTurn(thread creation, lead-in, placeholder,AgentContext), thenstreamTurn. Two virtual Discord transports (fake@discordjs/coreAPI + smart@discordjs/restproxy) capture exactly what the bot emits; events fan out over SSE.buildContextSnapshot+/inspect-contextbreakdown; Trace tab is fed by teeing the orchestrator's tool-call stream (delegations render asdelegate › <domain>).__setDiscordRestForSimulation/__setRedisForSimulationoverridesprepareFreshTurn+replyEmptyMentionfrom the mention handlerconversation-turn.ts(history capping) shared withchat.tsapp.route("/simulator")mount behindisSimEnabled()Test plan
bun run typecheck— cleanbun lint— 0 errorsbun format --check— cleanbun test:coverage— 1066/1066 pass; thresholds met (stmts 92.4%, branches 88.7%, funcs 92.3%, lines 92.9%)AI_GATEWAY_API_KEYor a freshVERCEL_OIDC_TOKEN) — env/ops only, not code.🤖 Generated with Claude Code