From cf46f6518d35cdf9156c1c5310148c8bec64863e Mon Sep 17 00:00:00 2001
From: Christopher Nelson
Date: Sun, 20 Sep 2026 10:01:54 -0400
Subject: [PATCH 1/6] fix(world-lab): expose zero swarm mode in setup
---
.../src/components/world-lab.test.tsx | 59 +++++++++++++++++++
apps/world-lab/src/components/world-lab.tsx | 30 +++++++++-
docs/LIVE_SWARM_COMPARISON.md | 11 ++++
3 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx
index 36026a0..1588fac 100644
--- a/apps/world-lab/src/components/world-lab.test.tsx
+++ b/apps/world-lab/src/components/world-lab.test.tsx
@@ -1353,6 +1353,65 @@ describe('WorldLab', () => {
expect(overflowTrigger.closest('details')).not.toHaveAttribute('open');
});
+ it('lets an operator create a zero-swarm experiment from World Setup', async () => {
+ const swarm = simulationSnapshotSchema.parse({
+ ...initial,
+ scenario: { ...initial.scenario, cognitionMode: 'zero-swarm-v1' },
+ swarmProviderStatus: {
+ plannerMode: 'scripted-swarm-test',
+ plannerConfigured: false,
+ reflexMode: 'scripted-reflex-test',
+ reflexConfigured: false,
+ },
+ });
+ let previewMode: string | undefined;
+ let appliedMode: string | undefined;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith('/setup/preview')) {
+ const request = JSON.parse(String(init?.body)) as ReturnType<
+ typeof defaultWorldSetupRequest
+ >;
+ previewMode = request.cognitionMode;
+ return jsonResponse(previewWorldSetup(request));
+ }
+ if (url.endsWith('/experiment/setup')) {
+ appliedMode = (
+ JSON.parse(String(init?.body)) as ReturnType<
+ typeof defaultWorldSetupRequest
+ >
+ ).cognitionMode;
+ return jsonResponse({ snapshot: swarm });
+ }
+ return jsonResponse(initial);
+ }),
+ );
+ const user = userEvent.setup();
+ render( );
+ expect(
+ await screen.findByText('Legacy multi-agent experiment'),
+ ).toBeVisible();
+ await openOverflow(user);
+ await user.click(screen.getByRole('button', { name: 'World setup' }));
+ await user.selectOptions(
+ screen.getByLabelText('Cognition mode'),
+ 'zero-swarm-v1',
+ );
+ await user.click(screen.getByRole('button', { name: 'Preview' }));
+ expect(previewMode).toBe('zero-swarm-v1');
+ await user.click(
+ screen.getByRole('button', { name: 'Apply / Create Experiment' }),
+ );
+ expect(appliedMode).toBe('zero-swarm-v1');
+ expect(await screen.findByText('Zero swarm v1 experiment')).toBeVisible();
+ expect(screen.getByRole('tab', { name: 'Swarm' })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('tab', { name: 'Public chat' }),
+ ).not.toBeInTheDocument();
+ });
+
it.each(['balanced-random', 'fully-random'] as const)(
'previews and applies reproducible seed edits with regenerated %s assignments',
async (behaviorMode) => {
diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx
index 95b1fb8..adec872 100644
--- a/apps/world-lab/src/components/world-lab.tsx
+++ b/apps/world-lab/src/components/world-lab.tsx
@@ -843,7 +843,9 @@ export function WorldLab() {
WL
-
Developer simulation
+
+ {swarmMode ? 'Zero swarm v1' : 'Legacy multi-agent'} experiment
+
World Lab
@@ -2267,6 +2269,32 @@ function WorldSetupPanel({
>
}
>
+
+ Execution mode
+
+ Cognition mode
+
+ setDraft({
+ ...draft,
+ cognitionMode: event.target
+ .value as WorldSetupRequest['cognitionMode'],
+ })
+ }
+ >
+ Legacy multi-agent
+
+ Zero swarm v1 (Agent Zero + Jev)
+
+
+
+
+ Choose Zero swarm v1 to run one Agent Zero planner with Jev workers.
+ Preview and apply to create a new experiment. The live comparison
+ report is a separate command-line experiment.
+
+
World
Date: Sun, 20 Sep 2026 10:16:14 -0400
Subject: [PATCH 2/6] fix(world-lab): surface swarm execution diagnostics
---
apps/game-api/package.json | 1 +
.../src/swarm-diagnostics-cli.test.ts | 65 ++++++++++++++
apps/game-api/src/swarm-diagnostics-cli.ts | 84 +++++++++++++++++++
apps/world-lab/src/app/styles.css | 26 ++++++
.../src/components/swarm-view.test.tsx | 12 +++
apps/world-lab/src/components/swarm-view.tsx | 45 ++++++++++
.../src/components/world-lab.test.tsx | 7 +-
apps/world-lab/src/components/world-lab.tsx | 36 ++++++--
docs/LIVE_SWARM_COMPARISON.md | 2 +
docs/TESTING.md | 4 +
package.json | 1 +
11 files changed, 273 insertions(+), 10 deletions(-)
create mode 100644 apps/game-api/src/swarm-diagnostics-cli.test.ts
create mode 100644 apps/game-api/src/swarm-diagnostics-cli.ts
diff --git a/apps/game-api/package.json b/apps/game-api/package.json
index 327b7f2..8b7a283 100644
--- a/apps/game-api/package.json
+++ b/apps/game-api/package.json
@@ -7,6 +7,7 @@
"build": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js",
"compare:offline": "node --import tsx src/swarm-comparison-cli.ts",
"compare:live": "node --import tsx src/live-swarm-comparison-cli.ts",
+ "diagnose:swarm": "node --import tsx src/swarm-diagnostics-cli.ts",
"dev": "tsx watch src/server.ts",
"lint": "eslint .",
"start": "node dist/server.js",
diff --git a/apps/game-api/src/swarm-diagnostics-cli.test.ts b/apps/game-api/src/swarm-diagnostics-cli.test.ts
new file mode 100644
index 0000000..dd072db
--- /dev/null
+++ b/apps/game-api/src/swarm-diagnostics-cli.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+import type { SimulationSnapshot } from '@hexzero/shared';
+import { summarizeSwarmSnapshot } from './swarm-diagnostics-cli';
+
+describe('swarm diagnostic projection', () => {
+ it('shows why a stationary swarm has no player pressure and fallback actions', () => {
+ const snapshot = {
+ tickNumber: 1,
+ scenario: {
+ cognitionMode: 'zero-swarm-v1',
+ patientZeroAgentId: 'zero',
+ simulatedPlayer: { enabled: false, profile: 'casual-cleaner' },
+ },
+ world: { simulatedPlayer: null },
+ resolvedModels: [{ agentId: 'zero', modelId: 'google/gemini-test' }],
+ swarmProviderStatus: {
+ plannerMode: 'openrouter-swarm',
+ plannerConfigured: true,
+ reflexMode: 'typesafe-jev',
+ reflexConfigured: true,
+ },
+ experiment: {
+ attemptAccounting: {
+ attemptsStarted: 8,
+ attemptsFinalized: 8,
+ attemptsWithUnknownCost: 7,
+ },
+ },
+ swarmTicks: [
+ {
+ tickNumber: 1,
+ planSource: 'deterministic-fallback',
+ plannerFailure: { code: 'malformed-response' },
+ zeroAction: { type: 'wait' },
+ zeroActionResult: { accepted: true },
+ workers: [
+ {
+ action: { type: 'wait' },
+ source: 'deterministic-fallback',
+ failure: { code: 'timeout' },
+ },
+ ],
+ },
+ ],
+ } as unknown as SimulationSnapshot;
+ expect(summarizeSwarmSnapshot(snapshot)).toMatchObject({
+ tick: 1,
+ player: { enabled: false, active: false, movements: 0 },
+ attempts: { started: 8, finalized: 8 },
+ recentTicks: [
+ {
+ planSource: 'deterministic-fallback',
+ plannerFailureCode: 'malformed-response',
+ zeroAction: 'wait',
+ workerActions: { wait: 1 },
+ workerFallbacks: 1,
+ workerFailureCodes: ['timeout'],
+ },
+ ],
+ });
+ expect(JSON.stringify(summarizeSwarmSnapshot(snapshot))).not.toContain(
+ 'apiKey',
+ );
+ });
+});
diff --git a/apps/game-api/src/swarm-diagnostics-cli.ts b/apps/game-api/src/swarm-diagnostics-cli.ts
new file mode 100644
index 0000000..18a99b5
--- /dev/null
+++ b/apps/game-api/src/swarm-diagnostics-cli.ts
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+import {
+ simulationSnapshotSchema,
+ type SimulationSnapshot,
+ type WorldAction,
+} from '@hexzero/shared';
+
+/** A small, safe projection of an already committed local experiment. */
+export function summarizeSwarmSnapshot(snapshot: SimulationSnapshot) {
+ const zeroId = snapshot.scenario.patientZeroAgentId;
+ const player = snapshot.world.simulatedPlayer;
+ const attempts = snapshot.experiment.attemptAccounting;
+ const actionCounts = (actions: readonly (WorldAction | undefined)[]) => ({
+ move: actions.filter((action) => action?.type === 'move').length,
+ infect: actions.filter((action) => action?.type === 'infect').length,
+ capture: actions.filter((action) => action?.type === 'capture').length,
+ wait: actions.filter((action) => action?.type === 'wait').length,
+ });
+ return {
+ tick: snapshot.tickNumber,
+ mode: snapshot.scenario.cognitionMode,
+ zeroModel:
+ snapshot.resolvedModels.find(({ agentId }) => agentId === zeroId)
+ ?.modelId ?? null,
+ player: {
+ enabled: snapshot.scenario.simulatedPlayer.enabled,
+ profile: snapshot.scenario.simulatedPlayer.profile,
+ active: Boolean(player),
+ movements: player?.metrics.movements ?? 0,
+ cleaned: player?.metrics.cellsDisinfected ?? 0,
+ blockedCleans: player?.metrics.blockedDisinfections ?? 0,
+ },
+ providers: snapshot.swarmProviderStatus ?? null,
+ attempts: {
+ started: attempts.attemptsStarted,
+ finalized: attempts.attemptsFinalized,
+ unknownCost: attempts.attemptsWithUnknownCost,
+ },
+ recentTicks: (snapshot.swarmTicks ?? []).slice(-10).map((tick) => ({
+ tick: tick.tickNumber,
+ planSource: tick.planSource,
+ plannerFailureCode: tick.plannerFailure?.code ?? null,
+ zeroAction: tick.zeroAction.type,
+ zeroAccepted: tick.zeroActionResult?.accepted ?? null,
+ workerActions: actionCounts(tick.workers.map(({ action }) => action)),
+ jevDecisions: tick.workers.filter(({ source }) => source === 'jev-reflex')
+ .length,
+ workerFallbacks: tick.workers.filter(
+ ({ source }) => source === 'deterministic-fallback',
+ ).length,
+ workerFailureCodes: tick.workers.flatMap(({ failure }) =>
+ failure ? [failure.code] : [],
+ ),
+ })),
+ };
+}
+
+async function main(): Promise {
+ const port = process.env.PORT ?? '8787';
+ if (!/^\d{1,5}$/.test(port) || Number(port) > 65_535)
+ throw new Error('PORT must be a valid local TCP port.');
+ const url = `http://127.0.0.1:${port}/api/simulation`;
+ let response: Response;
+ try {
+ response = await fetch(url, { signal: AbortSignal.timeout(3_000) });
+ } catch {
+ throw new Error(
+ `Game API is unavailable at ${url}. Start pnpm dev and leave it running in another terminal.`,
+ );
+ }
+ if (!response.ok) throw new Error('Game API did not return a snapshot.');
+ const snapshot = simulationSnapshotSchema.parse(await response.json());
+ process.stdout.write(
+ `${JSON.stringify(summarizeSwarmSnapshot(snapshot), null, 2)}\n`,
+ );
+}
+
+if (import.meta.url === `file://${process.argv[1]}`)
+ void main().catch((error: unknown) => {
+ process.stderr.write(
+ `${error instanceof Error ? error.message : 'Swarm diagnostic failed.'}\n`,
+ );
+ process.exitCode = 1;
+ });
diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css
index af9307c..2d0eda9 100644
--- a/apps/world-lab/src/app/styles.css
+++ b/apps/world-lab/src/app/styles.css
@@ -2152,6 +2152,32 @@ summary:focus-visible {
text-overflow: ellipsis;
white-space: nowrap;
}
+.execution-mode-button {
+ display: grid;
+ justify-items: start;
+ min-width: 0;
+ padding: 5px 8px;
+ text-align: left;
+ background: color-mix(in srgb, var(--blackberry-cream) 25%, var(--surface));
+}
+.execution-mode-button strong {
+ color: var(--vanilla-custard);
+ font-size: 0.72rem;
+ white-space: nowrap;
+}
+.execution-mode-button .test-provider-summary {
+ width: 100%;
+}
+.swarm-diagnostic {
+ margin: 8px 0;
+ padding: 8px 10px;
+ border: 1px solid var(--line);
+ background: var(--surface-raised);
+ font-size: 0.75rem;
+}
+.swarm-diagnostic p + p {
+ margin-top: 4px;
+}
.command-alert {
border-color: color-mix(in srgb, var(--danger) 45%, var(--line));
background: color-mix(
diff --git a/apps/world-lab/src/components/swarm-view.test.tsx b/apps/world-lab/src/components/swarm-view.test.tsx
index fb76d8d..13e29a3 100644
--- a/apps/world-lab/src/components/swarm-view.test.tsx
+++ b/apps/world-lab/src/components/swarm-view.test.tsx
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { SimulationSnapshot } from '@hexzero/shared';
import {
+ SwarmActivityPanel,
SwarmAgentInspector,
SwarmRunPanel,
SwarmStrategyPanel,
@@ -99,6 +100,17 @@ function snapshot(withTick = true): SimulationSnapshot {
}
describe('swarm telemetry panels', () => {
+ it('summarizes inactive player pressure, actions, and provider failures', () => {
+ const value = snapshot();
+ render( );
+ const diagnostic = screen.getByRole('status');
+ expect(diagnostic).toHaveTextContent('0 moves');
+ expect(diagnostic).toHaveTextContent('1 infections');
+ expect(diagnostic).toHaveTextContent('1 Zero fallbacks');
+ expect(diagnostic).toHaveTextContent('Simulated player pressure is off');
+ expect(diagnostic).toHaveTextContent('Zero planner failure: timeout');
+ });
+
it('shows Zero fallback and worker reflex telemetry without social labels', () => {
const value = snapshot();
render(
diff --git a/apps/world-lab/src/components/swarm-view.tsx b/apps/world-lab/src/components/swarm-view.tsx
index 2644a9f..b6b848b 100644
--- a/apps/world-lab/src/components/swarm-view.tsx
+++ b/apps/world-lab/src/components/swarm-view.tsx
@@ -348,6 +348,21 @@ export function SwarmActivityPanel({
}) {
const ticks = snapshot.swarmTicks ?? [];
const latest = ticks.at(-1);
+ const workers = ticks.flatMap((tick) => tick.workers);
+ const actions = [
+ ...ticks.map((tick) => tick.zeroAction),
+ ...workers.map((worker) => worker.action),
+ ];
+ const countAction = (type: WorldAction['type']) =>
+ actions.filter((action) => action?.type === type).length;
+ const fallbackPlans = ticks.filter(
+ (tick) => tick.planSource === 'deterministic-fallback',
+ ).length;
+ const fallbackWorkers = workers.filter(
+ (worker) => worker.source === 'deterministic-fallback',
+ ).length;
+ const player = snapshot.world.simulatedPlayer;
+ const attempts = snapshot.experiment.attemptAccounting;
return (
Swarm activity
Agent Zero strategy
+
+
+ {ticks.length} committed ticks ·{' '}
+ {countAction('move')} moves · {countAction('infect')} infections ·{' '}
+ {countAction('wait')} waits · {fallbackPlans} Zero fallbacks ·{' '}
+ {fallbackWorkers} worker fallbacks · {attempts.attemptsStarted}{' '}
+ provider attempts
+
+
+ {player
+ ? `${player.profile} pressure: ${player.metrics.movements} moves, ${player.metrics.cellsDisinfected} cleans, ${player.metrics.blockedDisinfections} blocked cleans`
+ : 'Simulated player pressure is off. Enable it in World Setup to test cleaner or hunter behavior.'}
+
+ {latest?.plannerFailure && (
+
+ Zero planner failure: {latest.plannerFailure.code} ·{' '}
+ {latest.plannerFailure.message}
+
+ )}
+ {latest?.workers.some((worker) => worker.failure) && (
+
+ Worker failures:{' '}
+ {latest.workers
+ .flatMap((worker) =>
+ worker.failure ? [worker.failure.code] : [],
+ )
+ .join(', ')}
+
+ )}
+
{!latest ? (
) : (
diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx
index 1588fac..9210624 100644
--- a/apps/world-lab/src/components/world-lab.test.tsx
+++ b/apps/world-lab/src/components/world-lab.test.tsx
@@ -1393,8 +1393,11 @@ describe('WorldLab', () => {
expect(
await screen.findByText('Legacy multi-agent experiment'),
).toBeVisible();
- await openOverflow(user);
- await user.click(screen.getByRole('button', { name: 'World setup' }));
+ await user.click(
+ screen.getByRole('button', {
+ name: /Current execution mode: Legacy multi-agent/,
+ }),
+ );
await user.selectOptions(
screen.getByLabelText('Cognition mode'),
'zero-swarm-v1',
diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx
index adec872..280f29d 100644
--- a/apps/world-lab/src/components/world-lab.tsx
+++ b/apps/world-lab/src/components/world-lab.tsx
@@ -164,7 +164,9 @@ export function WorldLab() {
const configurationPendingRef = useRef(false);
const exportInitializedRef = useRef(false);
const exportTriggerRef = useRef(null);
+ const modeTriggerRef = useRef(null);
const setupTriggerRef = useRef(null);
+ const [setupOpenedFromMode, setSetupOpenedFromMode] = useState(false);
const overflowMenuRef = useRef(null);
useEffect(() => {
@@ -1017,13 +1019,28 @@ export function WorldLab() {
{!swarmMode && }
-
- {swarmMode
- ? `Zero: ${zeroModel?.modelId ?? 'model required'} · Jev: ${snapshot.swarmProviderStatus?.reflexModel ?? 'deterministic reflex'}`
- : snapshot.providerMode === 'openrouter'
- ? `${new Set(snapshot.resolvedModels.map(({ modelId }) => modelId ?? 'unassigned')).size} active model assignment${snapshot.resolvedModels.length === 1 ? '' : 's'}`
- : 'Deterministic test model'}
-
+ {
+ setSetupOpenedFromMode(true);
+ setSetupOpen(true);
+ }}
+ >
+
+ Mode: {swarmMode ? 'Zero swarm v1' : 'Legacy multi-agent'}
+
+
+ {swarmMode
+ ? `Zero: ${zeroModel?.modelId ?? 'model required'} · Jev: ${snapshot.swarmProviderStatus?.reflexModel ?? 'deterministic reflex'}`
+ : snapshot.providerMode === 'openrouter'
+ ? `${new Set(snapshot.resolvedModels.map(({ modelId }) => modelId ?? 'unassigned')).size} active model assignment${snapshot.resolvedModels.length === 1 ? '' : 's'}`
+ : 'Deterministic test model'}
+
+
{
if (overflowMenuRef.current)
overflowMenuRef.current.open = false;
+ setSetupOpenedFromMode(false);
setSetupOpen(true);
}}
>
@@ -1341,7 +1359,9 @@ export function WorldLab() {
open
snapshot={snapshot}
apiBase={apiBase}
- returnFocusRef={setupTriggerRef}
+ returnFocusRef={
+ setupOpenedFromMode ? modeTriggerRef : setupTriggerRef
+ }
onClose={() => setSetupOpen(false)}
onApplied={(next) => {
setSnapshot(next);
diff --git a/docs/LIVE_SWARM_COMPARISON.md b/docs/LIVE_SWARM_COMPARISON.md
index ae9b762..2478104 100644
--- a/docs/LIVE_SWARM_COMPARISON.md
+++ b/docs/LIVE_SWARM_COMPARISON.md
@@ -16,6 +16,8 @@ World Lab opens in the legacy mode by default. To watch one zero-swarm experimen
3. Select **Preview**, then **Apply / Create Experiment**. The header will say **Zero swarm v1 experiment**. In **Agents**, select an available model for Agent Zero.
4. Use **Single tick** to start. The **Scoreboard** tab shows Zero's strategy and worker directives; selecting a worker shows its chosen action, confidence, and probabilities. The **Swarm** activity tab shows the tick history. A provider fallback notice means the corresponding key or provider is unavailable.
+If agents appear stationary, read the **Swarm** activity summary first: it separates moves, infections, waits, Zero/worker fallbacks, provider attempts, and simulated-player pressure. The player is disabled in the default World Setup; selecting a profile without enabling pressure does not start it. The Game API keeps the current experiment in process memory, so keep `pnpm dev` running while inspecting it. In a second terminal, `pnpm diagnose:swarm` prints a short safe JSON summary of the current local experiment. It makes no provider calls. If it reports that port 8787 is unavailable, start `pnpm dev` and leave it running; a restarted Game API has a new experiment.
+
This UI is for inspecting one run. The matched Jev-versus-deterministic comparison below runs separately and produces a JSON report; it does not appear as a new World Lab screen.
## Run a bounded experiment
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 8d17bef..5e70a0e 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -34,6 +34,10 @@ scripted providers and verify the acknowledgement gate, input parity, bounded
attempt admission, safe metrics, and a no-network Markdown summary path.
`pnpm validate`, Playwright, and GitHub CI never execute a paid run. See
[Live swarm comparison](LIVE_SWARM_COMPARISON.md).
+The read-only `pnpm diagnose:swarm` command summarizes a running local Game API
+snapshot without provider calls, keys, prompts, or raw responses. World Lab's
+swarm activity summary shows pressure state, physical action counts, and
+fallbacks for immediate diagnosis of apparently stationary ticks.
The pre-live provider audit tests the Jev request's bounded capture-pressure
projection, omission of hidden hunter state and empty capture noise, and the
diff --git a/package.json b/package.json
index e250c0c..a78034f 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"build": "pnpm -r --if-present build",
"compare:offline": "pnpm --filter @hexzero/game-api compare:offline",
"compare:live": "pnpm --filter @hexzero/game-api compare:live",
+ "diagnose:swarm": "pnpm --filter @hexzero/game-api diagnose:swarm",
"dev": "pnpm --parallel --filter @hexzero/game-api --filter @hexzero/world-lab dev",
"dev:test-provider": "HEXZERO_PROVIDER=scripted pnpm dev",
"dev:api": "pnpm --filter @hexzero/game-api dev",
From 22e789955a820ae55b37476400b0ae10c242b415 Mon Sep 17 00:00:00 2001
From: Christopher Nelson
Date: Sun, 20 Sep 2026 10:49:29 -0400
Subject: [PATCH 3/6] fix(swarm): constrain Zero choices and recover from
rejected plans
---
.../src/simulation-service.swarm.test.ts | 16 +-
apps/game-api/src/simulation-service.ts | 78 ++++--
.../src/swarm-diagnostics-cli.test.ts | 3 +-
apps/game-api/src/swarm-diagnostics-cli.ts | 3 +
apps/world-lab/src/components/swarm-view.tsx | 5 +-
docs/ARCHITECTURE.md | 12 +
docs/SECURITY.md | 7 +
docs/TESTING.md | 5 +
.../agent-runtime/src/swarm-planner.test.ts | 97 +++++++
packages/agent-runtime/src/swarm-planner.ts | 245 ++++++++++++++++--
10 files changed, 418 insertions(+), 53 deletions(-)
diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts
index 44256ea..3be4497 100644
--- a/apps/game-api/src/simulation-service.swarm.test.ts
+++ b/apps/game-api/src/simulation-service.swarm.test.ts
@@ -408,7 +408,7 @@ describe('zero-swarm SimulationService tick', () => {
expect(snapshot.experiment.attemptAccounting.attemptsStarted).toBe(8);
});
- it('uses deterministic neutral directives and Zero wait when planning fails', async () => {
+ it('uses one billed Zero attempt and deterministic legal expansion when the first plan fails', async () => {
const simulation = setup(
new InspectingPlanner(true),
new ScriptedReflexProvider(
@@ -420,8 +420,20 @@ describe('zero-swarm SimulationService tick', () => {
expect(tick?.planSource).toBe('deterministic-fallback');
expect(tick?.zeroAction).toEqual({ type: 'wait' });
expect(
- tick?.workers.every(({ directive }) => directive.mission === 'hold'),
+ tick?.workers.every(
+ ({ directive, source }) =>
+ directive.mission === 'expand' && source === 'deterministic-fallback',
+ ),
+ ).toBe(true);
+ expect(
+ tick?.workers.some(
+ ({ action, actionResult }) =>
+ action?.type === 'infect' && actionResult?.accepted === true,
+ ),
).toBe(true);
+ expect(
+ simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted,
+ ).toBe(1);
});
it('reuses unexpired directives for four ticks, then replans on the fifth tick', async () => {
diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts
index 804101c..691706c 100644
--- a/apps/game-api/src/simulation-service.ts
+++ b/apps/game-api/src/simulation-service.ts
@@ -151,6 +151,21 @@ function chooseDeterministicWorkerAction(
};
}
+function selectNeutralFallbackCandidate(
+ compiled: CompiledReflexObservation,
+ state: WorldState,
+): string {
+ const choices = [...compiled.actions];
+ return (choices.find(([, action]) => action.type === 'infect') ??
+ choices.find(([, action]) => action.type === 'capture') ??
+ choices.find(
+ ([, action]) =>
+ action.type === 'move' &&
+ state.hexes.get(action.targetCell)?.state === 'open',
+ ) ??
+ choices.find(([, action]) => action.type === 'wait'))![0];
+}
+
export function selectMostRecentPatientZeroThreats<
T extends { eventId: string; occurredAt: string },
>(events: readonly T[]): T[] {
@@ -1666,6 +1681,7 @@ export class SimulationService {
{
signal: controller.signal,
deadlineAtMs,
+ reasoningProfile: resolvedZero.reasoningProfile,
beginAttempt: (kind) => {
const startedAt = this.#now();
const permit =
@@ -1756,26 +1772,39 @@ export class SimulationService {
? ('success' as const)
: ('unknown' as const),
};
- const choice = this.#deterministicWorkerCandidateSelector
- ? chooseDeterministicWorkerAction(
- compileReflexObservation(candidate, directive, history),
- this.#deterministicWorkerCandidateSelector,
- )
- : await chooseReflexWorldAction(
- candidate,
- directive,
- this.#reflexProvider,
- {
- history,
- signal: controller.signal,
- deadlineAtMs,
- accounting: this.#attemptAccounting,
- initialPermitReserved: true,
- intendedTickNumber: tickNumber,
- intendedTurnNumber: tickTurnBase + order.indexOf(worker.id) + 1,
- now: this.#now,
- },
- );
+ const retainedDirective = this.#lastValidSwarmPlan?.directives.some(
+ (previous) =>
+ previous.agentId === worker.id &&
+ previous.expiresAtTick >= tickNumber,
+ );
+ const choice =
+ planSource === 'deterministic-fallback' && !retainedDirective
+ ? chooseDeterministicWorkerAction(
+ compileReflexObservation(candidate, directive, history),
+ (compiled) =>
+ selectNeutralFallbackCandidate(compiled, candidate),
+ )
+ : this.#deterministicWorkerCandidateSelector
+ ? chooseDeterministicWorkerAction(
+ compileReflexObservation(candidate, directive, history),
+ this.#deterministicWorkerCandidateSelector,
+ )
+ : await chooseReflexWorldAction(
+ candidate,
+ directive,
+ this.#reflexProvider,
+ {
+ history,
+ signal: controller.signal,
+ deadlineAtMs,
+ accounting: this.#attemptAccounting,
+ initialPermitReserved: true,
+ intendedTickNumber: tickNumber,
+ intendedTurnNumber:
+ tickTurnBase + order.indexOf(worker.id) + 1,
+ now: this.#now,
+ },
+ );
selected.set(worker.id, choice);
}
if (controller.signal.aborted) throw new SimulationTurnCancelledError();
@@ -2932,8 +2961,8 @@ export class SimulationService {
) ?? {
id: `neutral-${tickNumber}-${worker.id}`,
agentId: worker.id,
- mission: 'hold' as const,
- targetCell: state.agents.get(worker.id)!.currentCell,
+ mission: 'expand' as const,
+ targetCell: null,
priority: 'normal' as const,
riskTolerance: 'low' as const,
issuedAtTick: tickNumber,
@@ -2946,8 +2975,9 @@ export class SimulationService {
if (waitIndex < 0)
throw new Error('The engine must provide a legal wait action.');
return swarmPlanSchema.parse({
- strategySummary:
- 'Maintain legal local positions while strategic planning is unavailable.',
+ strategySummary: this.#lastValidSwarmPlan
+ ? 'Continue prior valid directives while strategic planning is unavailable.'
+ : 'Use deterministic local expansion while strategic planning is unavailable.',
directives,
zeroActionCandidateId: `zero_action_${waitIndex}`,
});
diff --git a/apps/game-api/src/swarm-diagnostics-cli.test.ts b/apps/game-api/src/swarm-diagnostics-cli.test.ts
index dd072db..f014632 100644
--- a/apps/game-api/src/swarm-diagnostics-cli.test.ts
+++ b/apps/game-api/src/swarm-diagnostics-cli.test.ts
@@ -11,7 +11,7 @@ describe('swarm diagnostic projection', () => {
patientZeroAgentId: 'zero',
simulatedPlayer: { enabled: false, profile: 'casual-cleaner' },
},
- world: { simulatedPlayer: null },
+ world: { simulatedPlayer: null, hexes: [] },
resolvedModels: [{ agentId: 'zero', modelId: 'google/gemini-test' }],
swarmProviderStatus: {
plannerMode: 'openrouter-swarm',
@@ -45,6 +45,7 @@ describe('swarm diagnostic projection', () => {
} as unknown as SimulationSnapshot;
expect(summarizeSwarmSnapshot(snapshot)).toMatchObject({
tick: 1,
+ infectedCells: 0,
player: { enabled: false, active: false, movements: 0 },
attempts: { started: 8, finalized: 8 },
recentTicks: [
diff --git a/apps/game-api/src/swarm-diagnostics-cli.ts b/apps/game-api/src/swarm-diagnostics-cli.ts
index 18a99b5..f2ddb29 100644
--- a/apps/game-api/src/swarm-diagnostics-cli.ts
+++ b/apps/game-api/src/swarm-diagnostics-cli.ts
@@ -19,6 +19,9 @@ export function summarizeSwarmSnapshot(snapshot: SimulationSnapshot) {
return {
tick: snapshot.tickNumber,
mode: snapshot.scenario.cognitionMode,
+ infectedCells: snapshot.world.hexes.filter(
+ ({ state }) => state === 'infected',
+ ).length,
zeroModel:
snapshot.resolvedModels.find(({ agentId }) => agentId === zeroId)
?.modelId ?? null,
diff --git a/apps/world-lab/src/components/swarm-view.tsx b/apps/world-lab/src/components/swarm-view.tsx
index b6b848b..16b5907 100644
--- a/apps/world-lab/src/components/swarm-view.tsx
+++ b/apps/world-lab/src/components/swarm-view.tsx
@@ -362,6 +362,9 @@ export function SwarmActivityPanel({
(worker) => worker.source === 'deterministic-fallback',
).length;
const player = snapshot.world.simulatedPlayer;
+ const infectedCells = snapshot.world.hexes?.filter(
+ ({ state }) => state === 'infected',
+ ).length;
const attempts = snapshot.experiment.attemptAccounting;
return (
{player
- ? `${player.profile} pressure: ${player.metrics.movements} moves, ${player.metrics.cellsDisinfected} cleans, ${player.metrics.blockedDisinfections} blocked cleans`
+ ? `${player.profile} pressure: ${player.metrics.movements} moves, ${player.metrics.cellsDisinfected} cleans, ${player.metrics.blockedDisinfections} blocked cleans${infectedCells === 0 ? ' · no infected cells to pursue' : ''}`
: 'Simulated player pressure is off. Enable it in World Setup to test cleaner or hunter behavior.'}
{latest?.plannerFailure && (
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index df1532e..0c739c5 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -22,6 +22,18 @@ Zero planning uses the same provider-reported OpenRouter usage normalization as
legacy turns, including actual `usage.cost` when returned, and preserves that
metadata when a returned plan is rejected or a bounded non-success response
contains usage. Missing provider cost remains unknown.
+The OpenRouter planner asks Zero for bounded worker IDs, strategic target choice
+IDs, mission, priority, risk, and its own legal action choice. Server code
+materializes agent IDs, H3 targets, directive IDs, and five-tick lifetimes from
+the frozen observation. Full valid plans remain accepted for compatibility,
+but invalid output is classified into safe validation reasons without retaining
+raw provider text. The selected Zero reasoning profile is sent to OpenRouter
+with private reasoning excluded, and output is bounded.
+
+When Zero has no unexpired directive for a worker, a failed planning attempt
+gives that worker engine-legal deterministic local expansion without a Jev
+call. This prevents repeated billed Jev waits under a failed planner while
+retaining planner attempt telemetry.
Swarm tick records are separate from legacy agent turn records. Full all-agent
exports and the archive retain safe plans, directives, action choices, and
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 4f92bcd..4ba5d16 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -62,6 +62,13 @@ it never grants world mutation authority. Safe swarm tick records contain
structured plans and outcomes, while raw planner messages and responses remain
server-private. Legacy social and prose-memory cognition does not run in this
mode.
+The planner's preferred wire response contains only opaque worker, target, and
+Zero-action choices. The server maps them to authorized agent/cell/action IDs
+and issues directive IDs and lifetimes. Invalid outputs record only a bounded
+validation category such as an unknown target choice; they do not retain or
+echo the raw model response. When a worker has no unexpired directive from a
+prior valid Zero plan, it uses deterministic legal local expansion without a
+Jev request until planning recovers.
World Lab receives only configured booleans and adapter/model labels for the
swarm providers. It displays committed safe plan and reflex telemetry plus
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 5e70a0e..0d8222a 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -44,6 +44,11 @@ projection, omission of hidden hunter state and empty capture noise, and the
removal of the inert recent-facts field. Planner tests cover provider-reported
OpenRouter cost and detailed tokens for valid, rejected, and non-success Zero
responses, plus sanitized response IDs/models; missing cost stays unknown.
+Planner tests also cover compact opaque worker/target choices, server-issued
+directive IDs and lifetimes, Zero reasoning-profile dispatch, and safe
+classification of unknown or missing choices. Scripted service tests verify
+that a first-plan failure uses legal
+deterministic local expansion with only the Zero provider attempt billed.
Attempt-budget tests use deterministic providers and cover whole-roster tick
admission, retry permits, cancellation finalization, and the distinction
diff --git a/packages/agent-runtime/src/swarm-planner.test.ts b/packages/agent-runtime/src/swarm-planner.test.ts
index 4f298f1..584075d 100644
--- a/packages/agent-runtime/src/swarm-planner.test.ts
+++ b/packages/agent-runtime/src/swarm-planner.test.ts
@@ -57,8 +57,105 @@ const plan = swarmPlanSchema.parse({
},
],
});
+const compactPlan = {
+ strategySummary: 'Expand carefully.',
+ zeroActionCandidateId: 'zero_action_0',
+ directives: [
+ {
+ workerId: 'worker_0',
+ mission: 'expand',
+ targetId: 'target_0',
+ priority: 'normal',
+ riskTolerance: 'medium',
+ },
+ ],
+};
describe('swarm planners', () => {
+ it('turns bounded worker and target choices into authoritative directives', async () => {
+ const fetchImplementation = vi
+ .fn()
+ .mockResolvedValue(plannerResponse(compactPlan));
+ const result = await new OpenRouterSwarmPlanner({
+ apiKey: 'test-key',
+ fetchImplementation,
+ }).plan(observation, 'test-model', { reasoningProfile: 'low' });
+ expect(result.plan).toEqual({
+ strategySummary: 'Expand carefully.',
+ zeroActionCandidateId: 'zero_action_0',
+ directives: [
+ {
+ id: 'directive-1-worker_0',
+ agentId: worker,
+ mission: 'expand',
+ targetCell: cell,
+ priority: 'normal',
+ riskTolerance: 'medium',
+ issuedAtTick: 1,
+ expiresAtTick: 5,
+ },
+ ],
+ });
+ const body = JSON.parse(
+ String(fetchImplementation.mock.calls[0]?.[1]?.body),
+ ) as {
+ messages: Array<{ content: string }>;
+ reasoning: { enabled: boolean; effort: string; exclude: boolean };
+ max_tokens: number;
+ };
+ expect(body.reasoning).toEqual({
+ enabled: true,
+ effort: 'low',
+ exclude: true,
+ });
+ expect(body.max_tokens).toBe(4_096);
+ expect(body.messages[0]?.content).toContain('Code supplies directive IDs');
+ expect(JSON.parse(body.messages[1]!.content)).toMatchObject({
+ workers: [{ workerId: 'worker_0', position: cell }],
+ targetChoices: [{ targetId: 'target_0', cell }],
+ });
+ });
+
+ it('rejects unknown compact choices with a safe specific reason', async () => {
+ await expect(
+ new OpenRouterSwarmPlanner({
+ apiKey: 'test-key',
+ fetchImplementation: vi.fn().mockResolvedValue(
+ plannerResponse({
+ ...compactPlan,
+ directives: [
+ { ...compactPlan.directives[0], targetId: 'target_99' },
+ ],
+ }),
+ ),
+ }).plan(observation, 'test-model'),
+ ).rejects.toMatchObject({
+ failure: {
+ code: 'invalid-decision',
+ message: 'Agent Zero plan rejected: unknown target choice.',
+ },
+ });
+ });
+
+ it('reports missing compact worker directives without exposing raw output', async () => {
+ await expect(
+ new OpenRouterSwarmPlanner({
+ apiKey: 'test-key',
+ fetchImplementation: vi
+ .fn()
+ .mockResolvedValue(
+ plannerResponse({ ...compactPlan, directives: [] }),
+ ),
+ }).plan(observation, 'test-model'),
+ ).rejects.toMatchObject({
+ failure: {
+ code: 'invalid-decision',
+ message:
+ 'Agent Zero plan rejected: missing or extra worker directives.',
+ },
+ });
+ });
+
it('uses a deterministic plan only when it stays within authoritative options', async () => {
const result = await new ScriptedSwarmPlanner([plan]).plan(
observation,
diff --git a/packages/agent-runtime/src/swarm-planner.ts b/packages/agent-runtime/src/swarm-planner.ts
index cbc1600..15ce8a4 100644
--- a/packages/agent-runtime/src/swarm-planner.ts
+++ b/packages/agent-runtime/src/swarm-planner.ts
@@ -2,9 +2,12 @@ import { z } from 'zod';
import {
providerMetadataSchema,
swarmPlanSchema,
+ OPENROUTER_MAX_OUTPUT_TOKENS,
+ WORLD_SCENARIO_LIMITS,
zeroStrategicObservationSchema,
type ProviderFailure,
type ProviderMetadata,
+ type ReasoningProfile,
type SwarmPlan,
type ZeroStrategicObservation,
} from '@hexzero/shared';
@@ -28,6 +31,7 @@ export interface SwarmPlanner {
export interface PlannerOptions {
signal?: AbortSignal;
deadlineAtMs?: number;
+ reasoningProfile?: ReasoningProfile;
beginAttempt?: SwarmAttemptStarter;
}
@@ -79,6 +83,35 @@ const openRouterResponseSchema = z.object({
.min(1),
});
+const compactPlanSchema = z
+ .object({
+ strategySummary: z.string().trim().min(1).max(500),
+ directives: z
+ .array(
+ z
+ .object({
+ workerId: z.string().regex(/^worker_[0-9]+$/),
+ mission: z.enum([
+ 'expand',
+ 'hold',
+ 'relocate',
+ 'reinforce',
+ 'evade',
+ ]),
+ targetId: z
+ .string()
+ .regex(/^target_[0-9]+$/)
+ .nullable(),
+ priority: z.enum(['low', 'normal', 'high']),
+ riskTolerance: z.enum(['low', 'medium', 'high']),
+ })
+ .strict(),
+ )
+ .max(WORLD_SCENARIO_LIMITS.maximumAgents),
+ zeroActionCandidateId: z.string().regex(/^zero_action_[0-9]+$/),
+ })
+ .strict();
+
export interface OpenRouterSwarmPlannerOptions {
apiKey?: string;
timeoutMs?: number;
@@ -121,7 +154,11 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner {
startedAt + this.#timeoutMs,
);
const requestBody = JSON.stringify(
- buildSwarmPlannerRequest(observation, model),
+ buildSwarmPlannerRequest(
+ observation,
+ model,
+ options.reasoningProfile ?? 'provider-default',
+ ),
);
const sensitiveValues = collectSensitiveValues(
this.#apiKey,
@@ -284,13 +321,12 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner {
responseMetadata,
);
}
- const plan = swarmPlanSchema.safeParse(planRaw);
- if (!plan.success || !validPlan(plan.data, observation))
+ const decoded = decodePlanChoice(planRaw, observation);
+ if (!decoded.plan)
throw new SwarmPlannerError(
{
...failure('invalid-decision', false, model, startedAt),
- message:
- 'Agent Zero returned a plan outside the authoritative choices.',
+ message: `Agent Zero plan rejected: ${decoded.reason}.`,
},
responseMetadata,
);
@@ -298,9 +334,9 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner {
complete({
outcome: 'completed',
provider: safeMetadata,
- swarmPlan: plan.data,
+ swarmPlan: decoded.plan,
});
- return { plan: plan.data, metadata: safeMetadata };
+ return { plan: decoded.plan, metadata: safeMetadata };
} catch (error) {
const plannerError =
error instanceof SwarmPlannerError
@@ -371,46 +407,205 @@ export class ScriptedSwarmPlanner implements SwarmPlanner {
function buildSwarmPlannerRequest(
observation: ZeroStrategicObservation,
model: string,
+ reasoningProfile: ReasoningProfile,
) {
+ const targetChoices = observation.strategicTargetCells.map((cell, index) => ({
+ targetId: `target_${index}`,
+ cell,
+ }));
+ const targetIdByCell = new Map(
+ targetChoices.map(({ targetId, cell }) => [cell, targetId]),
+ );
+ const workers = observation.agents
+ .filter(({ agentId }) => agentId !== observation.zeroAgentId)
+ .map((agent, index) => ({
+ workerId: `worker_${index}`,
+ position: agent.position,
+ controlledCellCount: agent.controlledCellCount,
+ territoryDelta: agent.territoryDelta,
+ workerStatus: agent.workerStatus ?? 'unknown',
+ activeDirective: agent.directive
+ ? {
+ mission: agent.directive.mission,
+ targetId: agent.directive.targetCell
+ ? (targetIdByCell.get(agent.directive.targetCell) ?? null)
+ : null,
+ priority: agent.directive.priority,
+ riskTolerance: agent.directive.riskTolerance,
+ ticksRemaining: Math.max(
+ 0,
+ agent.directive.expiresAtTick - observation.tickNumber,
+ ),
+ }
+ : null,
+ }));
+ const workerIdByAgent = new Map(
+ observation.agents
+ .filter(({ agentId }) => agentId !== observation.zeroAgentId)
+ .map(({ agentId }, index) => [agentId, `worker_${index}`]),
+ );
+ const zero = observation.agents.find(
+ ({ agentId }) => agentId === observation.zeroAgentId,
+ )!;
return {
model,
temperature: 0,
+ max_tokens: OPENROUTER_MAX_OUTPUT_TOKENS,
+ ...(reasoningProfile === 'provider-default'
+ ? {}
+ : reasoningProfile === 'off'
+ ? { reasoning: { enabled: false, exclude: true } }
+ : {
+ reasoning: {
+ enabled: true,
+ effort: reasoningProfile,
+ exclude: true,
+ },
+ }),
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content:
- 'You are Agent Zero, a strategic planner. Return JSON only with strategySummary, directives, and zeroActionCandidateId. Assign intent, never exact worker movement. Each directive targetCell must be one of strategicTargetCells. Issue each directive at the current tick and set expiresAtTick between the current tick and current tick plus 9; normally cover at least five ticks so workers can operate between reviews. Use replanReasons and workerReplanRequests when present. zeroActionCandidateId must be one offered opaque candidate. Do not add fields.',
+ 'You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, mission (expand|hold|relocate|reinforce|evade), targetId (one offered targetId or null), priority (low|normal|high), and riskTolerance (low|medium|high). Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Code supplies directive IDs, agent IDs, target cells, and tick lifetimes; do not output those fields. Use replanReasons and workerReplanRequests when present.',
+ },
+ {
+ role: 'user',
+ content: JSON.stringify({
+ tickNumber: observation.tickNumber,
+ virtualTime: observation.virtualTime,
+ cells: observation.cells,
+ zero: {
+ position: zero.position,
+ controlledCellCount: zero.controlledCellCount,
+ territoryDelta: zero.territoryDelta,
+ },
+ workers,
+ recentPlayerPressure: observation.recentPlayerPressure,
+ replanReasons: observation.replanReasons ?? [],
+ workerReplanRequests: (
+ observation.workerReplanRequests ?? []
+ ).flatMap(({ agentId, probability }) => {
+ const workerId = workerIdByAgent.get(agentId);
+ return workerId ? [{ workerId, probability }] : [];
+ }),
+ legalZeroActions: observation.legalZeroActions,
+ targetChoices,
+ }),
},
- { role: 'user', content: JSON.stringify(observation) },
],
};
}
-function validPlan(
+function decodePlanChoice(
+ raw: unknown,
+ observation: ZeroStrategicObservation,
+): { plan: SwarmPlan; reason?: never } | { plan?: never; reason: string } {
+ // Valid full plans remain accepted for compatibility with existing callers.
+ const full = swarmPlanSchema.safeParse(raw);
+ if (full.success) {
+ const issue = planAuthorityIssue(full.data, observation);
+ return issue ? { reason: issue } : { plan: full.data };
+ }
+ const compact = compactPlanSchema.safeParse(raw);
+ if (!compact.success) {
+ const topField = String(compact.error.issues[0]?.path[0] ?? 'object');
+ const field = [
+ 'strategySummary',
+ 'directives',
+ 'zeroActionCandidateId',
+ ].includes(topField)
+ ? topField
+ : 'object';
+ return { reason: `invalid ${field} format` };
+ }
+ const workerIds = observation.agents
+ .filter(({ agentId }) => agentId !== observation.zeroAgentId)
+ .map(({ agentId }, index) => ({ workerId: `worker_${index}`, agentId }));
+ if (compact.data.directives.length !== workerIds.length)
+ return { reason: 'missing or extra worker directives' };
+ const workerMap = new Map(
+ workerIds.map(({ workerId, agentId }) => [workerId, agentId]),
+ );
+ const targetMap = new Map(
+ observation.strategicTargetCells.map((cell, index) => [
+ `target_${index}`,
+ cell,
+ ]),
+ );
+ const seen = new Set();
+ for (const directive of compact.data.directives) {
+ if (!workerMap.has(directive.workerId) || seen.has(directive.workerId))
+ return { reason: 'unknown or repeated worker choice' };
+ seen.add(directive.workerId);
+ if (directive.targetId !== null && !targetMap.has(directive.targetId))
+ return { reason: 'unknown target choice' };
+ }
+ if (
+ !observation.legalZeroActions.some(
+ ({ id }) => id === compact.data.zeroActionCandidateId,
+ )
+ )
+ return { reason: 'unknown Zero action choice' };
+ const plan = swarmPlanSchema.safeParse({
+ strategySummary: compact.data.strategySummary,
+ zeroActionCandidateId: compact.data.zeroActionCandidateId,
+ directives: compact.data.directives.map((directive) => ({
+ id: `directive-${observation.tickNumber}-${directive.workerId}`,
+ agentId: workerMap.get(directive.workerId)!,
+ mission: directive.mission,
+ targetCell:
+ directive.targetId === null ? null : targetMap.get(directive.targetId)!,
+ priority: directive.priority,
+ riskTolerance: directive.riskTolerance,
+ issuedAtTick: observation.tickNumber,
+ expiresAtTick: observation.tickNumber + 4,
+ })),
+ });
+ return plan.success
+ ? { plan: plan.data }
+ : { reason: 'directive materialization failed' };
+}
+
+function planAuthorityIssue(
plan: SwarmPlan,
observation: ZeroStrategicObservation,
-): boolean {
- const targets = new Set(observation.strategicTargetCells);
+): string | null {
+ if (
+ !observation.legalZeroActions.some(
+ ({ id }) => id === plan.zeroActionCandidateId,
+ )
+ )
+ return 'unknown Zero action choice';
const workers = new Set(
observation.agents
.filter(({ agentId }) => agentId !== observation.zeroAgentId)
.map(({ agentId }) => agentId),
);
- return (
- observation.legalZeroActions.some(
- ({ id }) => id === plan.zeroActionCandidateId,
- ) &&
- plan.directives.length === workers.size &&
- plan.directives.every(
- (directive) =>
- workers.has(directive.agentId) &&
- directive.issuedAtTick === observation.tickNumber &&
- directive.expiresAtTick >= observation.tickNumber &&
- directive.expiresAtTick <= observation.tickNumber + 9 &&
- (!directive.targetCell || targets.has(directive.targetCell)),
+ if (plan.directives.length !== workers.size)
+ return 'missing or extra worker directives';
+ const targets = new Set(observation.strategicTargetCells);
+ for (const directive of plan.directives) {
+ if (!workers.delete(directive.agentId))
+ return 'unknown or repeated worker choice';
+ if (directive.issuedAtTick !== observation.tickNumber)
+ return 'invalid directive issue tick';
+ if (
+ directive.expiresAtTick < observation.tickNumber ||
+ directive.expiresAtTick > observation.tickNumber + 9
)
- );
+ return 'invalid directive expiry';
+ if (directive.targetCell && !targets.has(directive.targetCell))
+ return 'unknown target choice';
+ }
+ return null;
+}
+
+function validPlan(
+ plan: SwarmPlan,
+ observation: ZeroStrategicObservation,
+): boolean {
+ return planAuthorityIssue(plan, observation) === null;
}
function responseText(
From 76e3238c8c72c4abf307e7da18620054070a6510 Mon Sep 17 00:00:00 2001
From: Christopher Nelson
Date: Sun, 20 Sep 2026 11:02:04 -0400
Subject: [PATCH 4/6] fix(swarm): distinguish reported cost from admission
exposure
---
.../src/swarm-diagnostics-cli.test.ts | 18 ++++++++-
apps/game-api/src/swarm-diagnostics-cli.ts | 8 ++++
.../src/components/swarm-view.test.tsx | 26 ++++++++++++-
apps/world-lab/src/components/swarm-view.tsx | 23 +++++++++--
apps/world-lab/src/components/world-lab.tsx | 28 ++++++++++++--
docs/ARCHITECTURE.md | 4 ++
docs/TESTING.md | 9 ++++-
.../src/typesafe-jev-reflex-provider.test.ts | 38 ++++++++++++++++++-
.../src/typesafe-jev-reflex-provider.ts | 27 +++++++------
9 files changed, 157 insertions(+), 24 deletions(-)
diff --git a/apps/game-api/src/swarm-diagnostics-cli.test.ts b/apps/game-api/src/swarm-diagnostics-cli.test.ts
index f014632..244dd13 100644
--- a/apps/game-api/src/swarm-diagnostics-cli.test.ts
+++ b/apps/game-api/src/swarm-diagnostics-cli.test.ts
@@ -24,6 +24,9 @@ describe('swarm diagnostic projection', () => {
attemptsStarted: 8,
attemptsFinalized: 8,
attemptsWithUnknownCost: 7,
+ knownFinalizedCostCredits: '0.02',
+ committedCreditExposure: '0.09',
+ reservationCreditsPerAttempt: '0.01',
},
},
swarmTicks: [
@@ -35,9 +38,10 @@ describe('swarm diagnostic projection', () => {
zeroActionResult: { accepted: true },
workers: [
{
+ agentId: 'worker',
action: { type: 'wait' },
source: 'deterministic-fallback',
- failure: { code: 'timeout' },
+ failure: { code: 'timeout', message: 'Jev timed out.' },
},
],
},
@@ -47,7 +51,14 @@ describe('swarm diagnostic projection', () => {
tick: 1,
infectedCells: 0,
player: { enabled: false, active: false, movements: 0 },
- attempts: { started: 8, finalized: 8 },
+ attempts: {
+ started: 8,
+ finalized: 8,
+ unknownCost: 7,
+ providerReportedCostCredits: '0.02',
+ admissionExposureCredits: '0.09',
+ reservePerAttemptCredits: '0.01',
+ },
recentTicks: [
{
planSource: 'deterministic-fallback',
@@ -56,6 +67,9 @@ describe('swarm diagnostic projection', () => {
workerActions: { wait: 1 },
workerFallbacks: 1,
workerFailureCodes: ['timeout'],
+ workerFailures: [
+ { agentId: 'worker', code: 'timeout', message: 'Jev timed out.' },
+ ],
},
],
});
diff --git a/apps/game-api/src/swarm-diagnostics-cli.ts b/apps/game-api/src/swarm-diagnostics-cli.ts
index f2ddb29..dc20aeb 100644
--- a/apps/game-api/src/swarm-diagnostics-cli.ts
+++ b/apps/game-api/src/swarm-diagnostics-cli.ts
@@ -38,6 +38,9 @@ export function summarizeSwarmSnapshot(snapshot: SimulationSnapshot) {
started: attempts.attemptsStarted,
finalized: attempts.attemptsFinalized,
unknownCost: attempts.attemptsWithUnknownCost,
+ providerReportedCostCredits: attempts.knownFinalizedCostCredits,
+ admissionExposureCredits: attempts.committedCreditExposure,
+ reservePerAttemptCredits: attempts.reservationCreditsPerAttempt,
},
recentTicks: (snapshot.swarmTicks ?? []).slice(-10).map((tick) => ({
tick: tick.tickNumber,
@@ -54,6 +57,11 @@ export function summarizeSwarmSnapshot(snapshot: SimulationSnapshot) {
workerFailureCodes: tick.workers.flatMap(({ failure }) =>
failure ? [failure.code] : [],
),
+ workerFailures: tick.workers.flatMap(({ agentId, failure }) =>
+ failure
+ ? [{ agentId, code: failure.code, message: failure.message }]
+ : [],
+ ),
})),
};
}
diff --git a/apps/world-lab/src/components/swarm-view.test.tsx b/apps/world-lab/src/components/swarm-view.test.tsx
index 13e29a3..7205421 100644
--- a/apps/world-lab/src/components/swarm-view.test.tsx
+++ b/apps/world-lab/src/components/swarm-view.test.tsx
@@ -87,6 +87,8 @@ function snapshot(withTick = true): SimulationSnapshot {
attemptsFinalized: 2,
attemptsWithUnknownCost: 1,
knownFinalizedCostCredits: '0.02',
+ committedCreditExposure: '0.03',
+ reservationCreditsPerAttempt: '0.01',
},
},
swarmProviderStatus: {
@@ -102,6 +104,12 @@ function snapshot(withTick = true): SimulationSnapshot {
describe('swarm telemetry panels', () => {
it('summarizes inactive player pressure, actions, and provider failures', () => {
const value = snapshot();
+ value.swarmTicks![0]!.workers[0]!.failure = {
+ code: 'invalid-decision',
+ message:
+ 'TypeSafe Jev returned an incomplete or inconsistent probability distribution.',
+ retryable: false,
+ };
render( );
const diagnostic = screen.getByRole('status');
expect(diagnostic).toHaveTextContent('0 moves');
@@ -109,6 +117,9 @@ describe('swarm telemetry panels', () => {
expect(diagnostic).toHaveTextContent('1 Zero fallbacks');
expect(diagnostic).toHaveTextContent('Simulated player pressure is off');
expect(diagnostic).toHaveTextContent('Zero planner failure: timeout');
+ expect(diagnostic).toHaveTextContent(
+ 'Worker: invalid-decision · TypeSafe Jev returned an incomplete or inconsistent probability distribution.',
+ );
});
it('shows Zero fallback and worker reflex telemetry without social labels', () => {
@@ -129,12 +140,25 @@ describe('swarm telemetry panels', () => {
expect(screen.getByText(/Planner timed out/)).toBeInTheDocument();
expect(screen.getAllByText('action_1')).toHaveLength(2);
expect(screen.getAllByText('72%')).toHaveLength(2);
- expect(screen.getByText(/TypeSafe cost unknown/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/TypeSafe Jev reports tokens, no monetary cost/),
+ ).toBeInTheDocument();
expect(
screen.queryByText(/alliance|chat|personality|memory/i),
).not.toBeInTheDocument();
});
+ it('separates reported provider cost from admission exposure', () => {
+ const value = snapshot();
+ render( );
+ expect(screen.getByText('Provider-reported cost')).toBeInTheDocument();
+ expect(screen.getByText('0.02 credits')).toBeInTheDocument();
+ expect(
+ screen.getByText('Admission exposure (not spend)'),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/0.03 credits · includes/)).toBeInTheDocument();
+ });
+
it('shows the telemetry empty state before the first committed tick', () => {
render( );
expect(
diff --git a/apps/world-lab/src/components/swarm-view.tsx b/apps/world-lab/src/components/swarm-view.tsx
index 16b5907..fc23dda 100644
--- a/apps/world-lab/src/components/swarm-view.tsx
+++ b/apps/world-lab/src/components/swarm-view.tsx
@@ -397,7 +397,11 @@ export function SwarmActivityPanel({
Worker failures:{' '}
{latest.workers
.flatMap((worker) =>
- worker.failure ? [worker.failure.code] : [],
+ worker.failure
+ ? [
+ `${agentName(snapshot, worker.agentId)}: ${worker.failure.code} · ${worker.failure.message}`,
+ ]
+ : [],
)
.join(', ')}
@@ -529,12 +533,23 @@ export function SwarmRunPanel({
-
Known finalized cost
+ Provider-reported cost
{accounting.knownFinalizedCostCredits} credits
-
Unknown provider costs
- {accounting.attemptsWithUnknownCost} · TypeSafe cost unknown
+ Unknown-cost attempts
+
+ {accounting.attemptsWithUnknownCost} · TypeSafe Jev reports tokens,
+ no monetary cost
+
+
+
+
Admission exposure (not spend)
+
+ {accounting.committedCreditExposure} credits · includes{' '}
+ {accounting.reservationCreditsPerAttempt} credit reserve per
+ unknown-cost attempt
+
Zero retained reported usage
diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx
index 280f29d..b309f7d 100644
--- a/apps/world-lab/src/components/world-lab.tsx
+++ b/apps/world-lab/src/components/world-lab.tsx
@@ -943,7 +943,7 @@ export function WorldLab() {
-
Committed exposure
+ Admission exposure (not spend)
{formatCost(
snapshot.experiment.attemptAccounting
@@ -967,7 +967,7 @@ export function WorldLab() {
-
Known finalized cost
+ Provider-reported cost
{formatCost(
snapshot.experiment.attemptAccounting
@@ -1016,6 +1016,14 @@ export function WorldLab() {
)}
+ {swarmMode && (
+
+ Jev reports tokens but no monetary cost. Its unknown-cost
+ attempts retain the configured admission reserve in exposure;
+ that reserve is not a provider bill. The reported cost above
+ includes OpenRouter amounts only when returned by the provider.
+
+ )}
{!swarmMode && }
@@ -1902,13 +1910,27 @@ function RunHealthSummary({
{elapsedMinutes} min
-
Committed credit exposure
+ Admission exposure (not spend)
{formatCost(
snapshot.experiment.attemptAccounting.committedCreditExposure,
)}
+
+
Provider-reported cost
+
+ {formatCost(
+ snapshot.experiment.attemptAccounting.knownFinalizedCostCredits,
+ )}
+
+
+
+
Unknown-cost attempts
+
+ {snapshot.experiment.attemptAccounting.attemptsWithUnknownCost}
+
+
Successful turns
{metrics.accepted}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0c739c5..6839667 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -22,6 +22,10 @@ Zero planning uses the same provider-reported OpenRouter usage normalization as
legacy turns, including actual `usage.cost` when returned, and preserves that
metadata when a returned plan is rejected or a bounded non-success response
contains usage. Missing provider cost remains unknown.
+World Lab distinguishes provider-reported cost from admission exposure. Each
+attempt with unknown monetary cost, including TypeSafe Jev, retains its
+configured per-attempt credit reserve in admission exposure. That reserve is
+a conservative execution limit, not a measured charge or Jev cost estimate.
The OpenRouter planner asks Zero for bounded worker IDs, strategic target choice
IDs, mission, priority, risk, and its own legal action choice. Server code
materializes agent IDs, H3 targets, directive IDs, and five-tick lifetimes from
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 0d8222a..af77c30 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -47,8 +47,13 @@ responses, plus sanitized response IDs/models; missing cost stays unknown.
Planner tests also cover compact opaque worker/target choices, server-issued
directive IDs and lifetimes, Zero reasoning-profile dispatch, and safe
classification of unknown or missing choices. Scripted service tests verify
-that a first-plan failure uses legal
-deterministic local expansion with only the Zero provider attempt billed.
+that a first-plan failure uses legal deterministic local expansion with only
+the Zero provider attempt billed. Jev provider tests distinguish unknown
+choices from inconsistent probability distributions and retain factual token
+usage for rejected decisions; World Lab and the read-only diagnostic show
+these safe worker failure reasons. Cost display tests separate provider-reported
+cost from admission exposure and show that TypeSafe Jev monetary cost remains
+unknown without provider billing data.
Attempt-budget tests use deterministic providers and cover whole-roster tick
admission, retry permits, cancellation finalization, and the distinction
diff --git a/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts
index b9ae4e8..f7eafd6 100644
--- a/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts
+++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts
@@ -174,7 +174,43 @@ describe('TypeSafeJevReflexProvider', () => {
.mockResolvedValue(choiceResponse('action_99')),
});
await expect(unknown.decide(observation)).rejects.toMatchObject({
- failure: { code: 'invalid-decision' },
+ failure: {
+ code: 'invalid-decision',
+ message:
+ 'TypeSafe Jev selected a candidate outside the supplied choices.',
+ },
+ metadata: { promptTokens: 45, completionTokens: 8, totalTokens: 53 },
+ });
+
+ const inconsistentProbabilities = new TypeSafeJevReflexProvider({
+ apiKey: 'test-key',
+ fetchImplementation: vi.fn
().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ model: TYPESAFE_JEV_MODEL,
+ answers: {
+ choose_action: {
+ type: 'choice',
+ choice: 'action_0',
+ probabilities: { action_0: 1 },
+ confidence: 0.7,
+ },
+ request_replan: { type: 'noul', noul: 0.25 },
+ },
+ usage: { input_tokens: 45, output_tokens: 8 },
+ }),
+ ),
+ ),
+ });
+ await expect(
+ inconsistentProbabilities.decide(observation),
+ ).rejects.toMatchObject({
+ failure: {
+ code: 'invalid-decision',
+ message:
+ 'TypeSafe Jev returned an incomplete or inconsistent probability distribution.',
+ },
+ metadata: { promptTokens: 45, completionTokens: 8, totalTokens: 53 },
});
const wrongModel = new TypeSafeJevReflexProvider({
diff --git a/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts
index f3b58b5..e1ba7c7 100644
--- a/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts
+++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts
@@ -218,25 +218,30 @@ export class TypeSafeJevReflexProvider implements ReflexProvider {
);
const answer = parsed.data.answers.choose_action;
const replanAnswer = parsed.data.answers.request_replan;
- if (
- !candidateIds.has(answer.choice) ||
- Object.keys(answer.probabilities).some(
- (id) => !candidateIds.has(id),
- ) ||
- candidateIds.size !== Object.keys(answer.probabilities).length ||
- !approximatelyOne(Object.values(answer.probabilities))
- )
+ const choiceValid = candidateIds.has(answer.choice);
+ const probabilityIds = Object.keys(answer.probabilities);
+ const probabilitiesValid =
+ probabilityIds.length === candidateIds.size &&
+ probabilityIds.every((id) => candidateIds.has(id)) &&
+ approximatelyOne(Object.values(answer.probabilities));
+ if (!choiceValid || !probabilitiesValid)
throw new ReflexProviderError(
{
code: 'invalid-decision',
- message:
- 'TypeSafe Jev selected a candidate outside the supplied choices.',
+ message: !choiceValid
+ ? 'TypeSafe Jev selected a candidate outside the supplied choices.'
+ : 'TypeSafe Jev returned an incomplete or inconsistent probability distribution.',
retryable: false,
httpStatus: response.status,
model: this.model,
latencyMs: Date.now() - startedAtMs,
},
- this.#metadata(Date.now() - startedAtMs, response.status),
+ this.#metadata(
+ Date.now() - startedAtMs,
+ response.status,
+ parsed.data.usage.input_tokens,
+ parsed.data.usage.output_tokens,
+ ),
);
const decision = reflexDecisionSchema.parse({
chosenCandidateId: answer.choice,
From b4f371ce82ac5c97d8c5efcbf4bfc2f732ad2094 Mon Sep 17 00:00:00 2001
From: Christopher Nelson
Date: Sun, 20 Sep 2026 11:05:52 -0400
Subject: [PATCH 5/6] fix(world-lab): scroll swarm activity in bottom dock
---
apps/world-lab/src/app/styles.css | 6 ++++
.../src/components/swarm-view.test.tsx | 3 ++
apps/world-lab/src/components/swarm-view.tsx | 3 ++
docs/TESTING.md | 3 +-
tests/e2e/world-lab.spec.ts | 35 +++++++++++++++++++
5 files changed, 49 insertions(+), 1 deletion(-)
diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css
index 2d0eda9..e6a792f 100644
--- a/apps/world-lab/src/app/styles.css
+++ b/apps/world-lab/src/app/styles.css
@@ -1003,6 +1003,12 @@ h2 {
overflow: hidden;
border-bottom: 0;
}
+.bottom-dock.activity-dock > .swarm-activity {
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+}
.bottom-dock .event-panel {
display: flex;
flex-direction: column;
diff --git a/apps/world-lab/src/components/swarm-view.test.tsx b/apps/world-lab/src/components/swarm-view.test.tsx
index 7205421..1f87321 100644
--- a/apps/world-lab/src/components/swarm-view.test.tsx
+++ b/apps/world-lab/src/components/swarm-view.test.tsx
@@ -111,6 +111,9 @@ describe('swarm telemetry panels', () => {
retryable: false,
};
render( );
+ expect(
+ screen.getByRole('tabpanel', { name: 'Swarm activity' }),
+ ).toHaveAttribute('tabindex', '0');
const diagnostic = screen.getByRole('status');
expect(diagnostic).toHaveTextContent('0 moves');
expect(diagnostic).toHaveTextContent('1 infections');
diff --git a/apps/world-lab/src/components/swarm-view.tsx b/apps/world-lab/src/components/swarm-view.tsx
index fc23dda..55cafe1 100644
--- a/apps/world-lab/src/components/swarm-view.tsx
+++ b/apps/world-lab/src/components/swarm-view.tsx
@@ -370,6 +370,9 @@ export function SwarmActivityPanel({
Swarm activity
Agent Zero strategy
diff --git a/docs/TESTING.md b/docs/TESTING.md
index af77c30..fc3512a 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -53,7 +53,8 @@ choices from inconsistent probability distributions and retain factual token
usage for rejected decisions; World Lab and the read-only diagnostic show
these safe worker failure reasons. Cost display tests separate provider-reported
cost from admission exposure and show that TypeSafe Jev monetary cost remains
-unknown without provider billing data.
+unknown without provider billing data. The browser suite checks that a long
+swarm activity log scrolls inside the fixed-height bottom dock.
Attempt-budget tests use deterministic providers and cover whole-roster tick
admission, retry permits, cancellation finalization, and the distinction
diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts
index a12235f..2283d70 100644
--- a/tests/e2e/world-lab.spec.ts
+++ b/tests/e2e/world-lab.spec.ts
@@ -7,6 +7,41 @@ const CIPHER_ID = '89ce9ddb-611f-4a46-8f7b-36e656494aa2';
const MINGLE_PERSONALITY =
'You are a social coalition-builder. Seek agents, initiate and continue conversations, propose alliances, answer offers, negotiate borders, and coordinate captures against dominant rivals. Prefer cooperation and public diplomacy over silent expansion, but protect your own territory and leave an alliance that repeatedly ignores or exploits you. Make concrete proposals rather than merely announcing actions.';
+test('keeps long swarm activity scrollable inside the bottom dock', async ({
+ page,
+}) => {
+ await page.goto('/');
+ await expect(page.getByText('Deterministic test model')).toBeVisible();
+ const dock = page.locator('.bottom-dock.activity-dock');
+ await dock.evaluate((element) => {
+ element.querySelector(':scope > .panel')?.remove();
+ const panel = document.createElement('section');
+ panel.className = 'panel swarm-panel swarm-activity';
+ panel.setAttribute('aria-label', 'Swarm activity');
+ panel.setAttribute('role', 'tabpanel');
+ panel.tabIndex = 0;
+ for (let index = 0; index < 30; index += 1) {
+ const entry = document.createElement('p');
+ entry.textContent = `Committed swarm tick ${index + 1}`;
+ panel.append(entry);
+ }
+ element.append(panel);
+ });
+ const log = page.getByRole('tabpanel', { name: 'Swarm activity' });
+ await expect(log).toHaveCSS('overflow-y', 'auto');
+ expect(
+ await log.evaluate(
+ (element) => element.scrollHeight > element.clientHeight,
+ ),
+ ).toBe(true);
+ await log.evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ expect(await log.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
+ const dockBox = await dock.boundingBox();
+ expect(dockBox?.height ?? Infinity).toBeLessThanOrEqual(211);
+});
+
test('runs the complete deterministic World Lab browser flow', async ({
page,
}) => {
From eda309550b7816cbad98a3c600f2245d68a69f71 Mon Sep 17 00:00:00 2001
From: Christopher Nelson
Date: Sun, 20 Sep 2026 11:22:41 -0400
Subject: [PATCH 6/6] fix(ci): handle terminal swarm ticks and exact setup
locator
---
apps/game-api/src/swarm-diagnostics-cli.ts | 2 +-
tests/e2e/world-lab.spec.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/game-api/src/swarm-diagnostics-cli.ts b/apps/game-api/src/swarm-diagnostics-cli.ts
index dc20aeb..bc20016 100644
--- a/apps/game-api/src/swarm-diagnostics-cli.ts
+++ b/apps/game-api/src/swarm-diagnostics-cli.ts
@@ -46,7 +46,7 @@ export function summarizeSwarmSnapshot(snapshot: SimulationSnapshot) {
tick: tick.tickNumber,
planSource: tick.planSource,
plannerFailureCode: tick.plannerFailure?.code ?? null,
- zeroAction: tick.zeroAction.type,
+ zeroAction: tick.zeroAction?.type ?? null,
zeroAccepted: tick.zeroActionResult?.accepted ?? null,
workerActions: actionCounts(tick.workers.map(({ action }) => action)),
jevDecisions: tick.workers.filter(({ source }) => source === 'jev-reflex')
diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts
index 2283d70..7b01cfa 100644
--- a/tests/e2e/world-lab.spec.ts
+++ b/tests/e2e/world-lab.spec.ts
@@ -92,7 +92,7 @@ test('runs the complete deterministic World Lab browser flow', async ({
await expect(page.getByRole('heading', { name: 'World Lab' })).toBeVisible();
await openActions();
- await page.getByRole('button', { name: 'World setup' }).click();
+ await page.getByRole('button', { name: 'World setup', exact: true }).click();
const setupDialog = page.getByRole('dialog', { name: 'World Setup' });
await setupDialog.getByLabel('Communication range (km)').fill('7.5');
await setupDialog.getByRole('button', { name: 'Preview' }).click();