misack Claude Opus 4.7 (1M context) commited on
Commit
b9537b4
·
1 Parent(s): b587cd8

Trial-mode UX: live progress visibility + cosmetic fixes + tighter caps

Browse files

Three classes of issue surfaced during the trial playthrough:

(1) The 3-minute /advance felt completely silent in the UI even though the
LiveProgress component was mounted. Root cause: EventSource has no way to
attach an X-Trial-Password header, so the password gate rejected every
SSE connection silently. Backend now also accepts the password as a
?token= query param on the /games/{id}/stream endpoint, and the frontend
streamUrl appends it from localStorage. Live deliberation cards, the
phase rail, and the cost meter now render during advance.

(2) Cosmetic SPA bugs that shipped wrong turn counts:
- GameView.tsx:96 had `(turns?.length && 5) || 5` which always returned 5
regardless of the game's actual world.max_turns. Read straight from
state.world.max_turns now.
- Debrief.tsx:31 hardcoded `turn:5, max:5` for the shell header. Now
pulls from /games/{id}/state.
- i18n strings in en.ts / de.ts hardcoded "Five turns" and "Turn 1 of 5"
in newGame.scenario.footnote, newGame.ready.engineTail/modelOnly, and
debrief.dek. Replaced with turn-count-agnostic copy ("Multi-turn
dossiers", "the first briefing", "Every turn of deliberation").

(3) Trial-mode LLM-call shrinks beyond the existing 5-agent / 2-turn cap:
- Action coach MAX_ROUNDS now env-driven via _resolve_max_rounds(); trial
mode collapses to 1 round (override via WARGAME_TRIAL_COACH_ROUNDS).
- Critic / premortem / evaluator phases skip when WARGAME_TRIAL_MODE=1.
Saves 3 LLM calls per turn. They enrich the debrief but aren't part of
the named-character reveal that's the core demo. With these cuts a
full trial game (2 turns, 5 agents, 1-round coach, no eval phases)
drops to ~16 LLM calls vs ~25 before this commit.

5 new tests (349 passing). New env vars documented in app.py docstring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

frontend/src/api.ts CHANGED
@@ -184,7 +184,13 @@ export const api = {
184
  req<StateResponse>(`/games/${id}/pause`, { method: 'POST' }),
185
  resumeGame: (id: string) =>
186
  req<AdvanceResponse>(`/games/${id}/resume`, { method: 'POST' }),
187
- streamUrl: (id: string) => `${BASE}/games/${id}/stream`,
 
 
 
 
 
 
188
  };
189
 
190
  export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
 
184
  req<StateResponse>(`/games/${id}/pause`, { method: 'POST' }),
185
  resumeGame: (id: string) =>
186
  req<AdvanceResponse>(`/games/${id}/resume`, { method: 'POST' }),
187
+ // EventSource can't attach custom headers, so the trial password rides in
188
+ // a `?token=` query param instead. The backend middleware accepts either.
189
+ streamUrl: (id: string) => {
190
+ const pw = getPassword();
191
+ const qs = pw ? `?token=${encodeURIComponent(pw)}` : '';
192
+ return `${BASE}/games/${id}/stream${qs}`;
193
+ },
194
  };
195
 
196
  export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
frontend/src/i18n/de.ts CHANGED
@@ -56,14 +56,14 @@ export const de: Partial<Record<StringKey, string>> = {
56
  "newGame.scenarios.loading": "Szenarien werden geladen…",
57
  "newGame.scenario.role": "Rolle",
58
  "newGame.scenario.agentCount": "{count} Berater am Vorstandstisch.",
59
- "newGame.scenario.footnote": "Fünf Runden · Briefings im Nebel des Krieges · juriergte Ergebnisse",
60
  "newGame.scenario.vs": "vs {rival}",
61
  "newGame.ready.eyebrow": "Bereit",
62
  "newGame.ready.openCompany": "{company} öffnen",
63
  "newGame.ready.pick": "Szenario oben auswählen",
64
  "newGame.ready.engineWith": "Engine ·",
65
- "newGame.ready.engineTail": "· generiert Briefing · ~30s · Runde 1 von 5.",
66
- "newGame.ready.modelOnly": "Modell generiert Briefing · ~30s · Runde 1 von 5.",
67
  "newGame.ready.openCta": "Fall öffnen →",
68
  "newGame.ready.busy": "Briefing wird generiert…",
69
  "newGame.lang.eyebrow": "Sprache",
@@ -365,7 +365,7 @@ export const de: Partial<Record<StringKey, string>> = {
365
  "debrief.eyebrow.case": "Fall {short}…",
366
  "debrief.eyebrow.tail": "Postmortem",
367
  "debrief.title": "Das Urteil des Vorstands, aktenkundig.",
368
- "debrief.dek": "Fünf Runden Beratung, das Rivalen-Drehbuch, der Verlauf der Welt und die Begründung des Schiedsrichters — in einem einzigen Bericht zusammengeführt. Nutzen Sie ihn, um Auftakte zu vergleichen, Anker zu prüfen und die Strategieklausur des nächsten Quartals vorzubereiten.",
369
  "debrief.errorTitle": "Debrief konnte nicht geladen werden",
370
  "debrief.errorTag": "Fehler",
371
  "debrief.loading": "Debrief wird geladen…",
 
56
  "newGame.scenarios.loading": "Szenarien werden geladen…",
57
  "newGame.scenario.role": "Rolle",
58
  "newGame.scenario.agentCount": "{count} Berater am Vorstandstisch.",
59
+ "newGame.scenario.footnote": "Mehrrundige Dossiers · Briefings im Nebel des Krieges · juriergte Ergebnisse",
60
  "newGame.scenario.vs": "vs {rival}",
61
  "newGame.ready.eyebrow": "Bereit",
62
  "newGame.ready.openCompany": "{company} öffnen",
63
  "newGame.ready.pick": "Szenario oben auswählen",
64
  "newGame.ready.engineWith": "Engine ·",
65
+ "newGame.ready.engineTail": "· generiert das erste Briefing · ~30s.",
66
+ "newGame.ready.modelOnly": "Modell generiert das erste Briefing · ~30s.",
67
  "newGame.ready.openCta": "Fall öffnen →",
68
  "newGame.ready.busy": "Briefing wird generiert…",
69
  "newGame.lang.eyebrow": "Sprache",
 
365
  "debrief.eyebrow.case": "Fall {short}…",
366
  "debrief.eyebrow.tail": "Postmortem",
367
  "debrief.title": "Das Urteil des Vorstands, aktenkundig.",
368
+ "debrief.dek": "Alle Beratungsrunden, das Rivalen-Drehbuch, der Verlauf der Welt und die Begründung des Schiedsrichters — in einem einzigen Bericht zusammengeführt. Nutzen Sie ihn, um Auftakte zu vergleichen, Anker zu prüfen und die Strategieklausur des nächsten Quartals vorzubereiten.",
369
  "debrief.errorTitle": "Debrief konnte nicht geladen werden",
370
  "debrief.errorTag": "Fehler",
371
  "debrief.loading": "Debrief wird geladen…",
frontend/src/i18n/en.ts CHANGED
@@ -54,14 +54,14 @@ export const en = {
54
  "newGame.scenarios.loading": "Loading scenarios…",
55
  "newGame.scenario.role": "Role",
56
  "newGame.scenario.agentCount": "{count} advisory agents at your board.",
57
- "newGame.scenario.footnote": "Five turns · fog-of-war briefings · adjudicated outcomes",
58
  "newGame.scenario.vs": "vs {rival}",
59
  "newGame.ready.eyebrow": "Ready",
60
  "newGame.ready.openCompany": "Open {company}",
61
  "newGame.ready.pick": "Pick a scenario above",
62
  "newGame.ready.engineWith": "Engine ·",
63
- "newGame.ready.engineTail": "· generates briefing · ~30s · Turn 1 of 5.",
64
- "newGame.ready.modelOnly": "Model generates briefing · ~30s · Turn 1 of 5.",
65
  "newGame.ready.openCta": "Open case →",
66
  "newGame.ready.busy": "Generating briefing…",
67
  "newGame.lang.eyebrow": "Language",
@@ -363,7 +363,7 @@ export const en = {
363
  "debrief.eyebrow.case": "case {short}…",
364
  "debrief.eyebrow.tail": "Post-mortem",
365
  "debrief.title": "The board's verdict, on the record.",
366
- "debrief.dek": "Five turns of deliberation, the rival playbook, the world-state arc, and the referee's reasoning — collated into a single report. Use it to compare openings, debug anchors, and line up next quarter's strategic review.",
367
  "debrief.errorTitle": "Debrief could not be loaded",
368
  "debrief.errorTag": "Error",
369
  "debrief.loading": "Loading debrief…",
 
54
  "newGame.scenarios.loading": "Loading scenarios…",
55
  "newGame.scenario.role": "Role",
56
  "newGame.scenario.agentCount": "{count} advisory agents at your board.",
57
+ "newGame.scenario.footnote": "Multi-turn dossiers · fog-of-war briefings · adjudicated outcomes",
58
  "newGame.scenario.vs": "vs {rival}",
59
  "newGame.ready.eyebrow": "Ready",
60
  "newGame.ready.openCompany": "Open {company}",
61
  "newGame.ready.pick": "Pick a scenario above",
62
  "newGame.ready.engineWith": "Engine ·",
63
+ "newGame.ready.engineTail": "· generates the first briefing · ~30s.",
64
+ "newGame.ready.modelOnly": "Model generates the first briefing · ~30s.",
65
  "newGame.ready.openCta": "Open case →",
66
  "newGame.ready.busy": "Generating briefing…",
67
  "newGame.lang.eyebrow": "Language",
 
363
  "debrief.eyebrow.case": "case {short}…",
364
  "debrief.eyebrow.tail": "Post-mortem",
365
  "debrief.title": "The board's verdict, on the record.",
366
+ "debrief.dek": "Every turn of deliberation, the rival playbook, the world-state arc, and the referee's reasoning — collated into a single report. Use it to compare openings, debug anchors, and line up next quarter's strategic review.",
367
  "debrief.errorTitle": "Debrief could not be loaded",
368
  "debrief.errorTag": "Error",
369
  "debrief.loading": "Loading debrief…",
frontend/src/screens/Debrief.tsx CHANGED
@@ -14,6 +14,8 @@ export function Debrief({ id, onMeta }: Props) {
14
  const { t } = useI18n();
15
  const [md, setMd] = useState<string | null>(null);
16
  const [err, setErr] = useState<string | null>(null);
 
 
17
 
18
  useEffect(() => {
19
  api.debrief(id).then((r) => setMd(r.markdown)).catch((e) => setErr(String(e)));
@@ -23,14 +25,16 @@ export function Debrief({ id, onMeta }: Props) {
23
  api.getState(id).then((s) => {
24
  const lang = s.language as Language | undefined;
25
  if (lang === 'en' || lang === 'de') setLanguage(lang);
 
 
26
  }).catch(() => { /* non-fatal */ });
27
  }, [id]);
28
 
29
  useEffect(() => {
30
- // Debrief is post-game: turn count is whatever we have, recon = 0.
31
- onMeta({ id, turn: 5, max: 5, recon_remaining: 0 });
32
  return () => onMeta(null);
33
- }, [id, onMeta]);
34
 
35
  return (
36
  <>
 
14
  const { t } = useI18n();
15
  const [md, setMd] = useState<string | null>(null);
16
  const [err, setErr] = useState<string | null>(null);
17
+ // Track real turn / max for the shell header; previously hardcoded to 5/5.
18
+ const [meta, setMeta] = useState<{ turn: number; max: number }>({ turn: 0, max: 0 });
19
 
20
  useEffect(() => {
21
  api.debrief(id).then((r) => setMd(r.markdown)).catch((e) => setErr(String(e)));
 
25
  api.getState(id).then((s) => {
26
  const lang = s.language as Language | undefined;
27
  if (lang === 'en' || lang === 'de') setLanguage(lang);
28
+ const w: any = s.world;
29
+ if (w) setMeta({ turn: Number(w.turn) || 0, max: Number(w.max_turns) || 0 });
30
  }).catch(() => { /* non-fatal */ });
31
  }, [id]);
32
 
33
  useEffect(() => {
34
+ // Debrief is post-game; recon spent. Turn / max read from real state.
35
+ onMeta({ id, turn: meta.turn, max: meta.max, recon_remaining: 0 });
36
  return () => onMeta(null);
37
+ }, [id, meta, onMeta]);
38
 
39
  return (
40
  <>
frontend/src/screens/GameView.tsx CHANGED
@@ -93,7 +93,9 @@ export function GameView({ id, onMeta }: Props) {
93
  setBriefing(br.briefing);
94
  setBudget(br.recon_budget_remaining);
95
  setTurn(br.turn);
96
- setMaxTurns((st?.turns?.length && 5) || 5);
 
 
97
  setPhase((st.current_phase as Phase) || 'briefing');
98
  // Paused mid-turn — keep the actionSent UI mounted so Resume is visible.
99
  if (st?.status === 'paused') setActionSent(true);
 
93
  setBriefing(br.briefing);
94
  setBudget(br.recon_budget_remaining);
95
  setTurn(br.turn);
96
+ // Read max_turns from the live world state — was previously a stale
97
+ // hardcoded 5 that ignored trial-mode caps.
98
+ setMaxTurns(st?.world?.max_turns ?? 5);
99
  setPhase((st.current_phase as Phase) || 'briefing');
100
  // Paused mid-turn — keep the actionSent UI mounted so Resume is visible.
101
  if (st?.status === 'paused') setActionSent(true);
wargame/agents/action_coach.py CHANGED
@@ -38,10 +38,31 @@ if TYPE_CHECKING:
38
  from wargame.engine.state import Game
39
 
40
 
41
- # Maximum conversational rounds before forcing best-effort. A round is one
42
- # user message + one coach reply. After 2 rounds, the coach must commit
43
- # best-effort anchors and surface what was inferred vs. provided.
44
- MAX_ROUNDS = 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
  # ---------------------------------------------------------------------------
@@ -119,7 +140,7 @@ class ActionCoachSession(BaseModel):
119
  ge=0,
120
  description=(
121
  "Number of user-message / coach-reply rounds consumed so far. "
122
- f"Capped at {MAX_ROUNDS}; round {MAX_ROUNDS + 1} forces ready."
123
  ),
124
  )
125
  status: Literal["gathering", "ready", "committed"] = "gathering"
@@ -346,12 +367,12 @@ def advance_session(
346
  company_name=scenario.company_name,
347
  rival_name=scenario.rival_name,
348
  player_role=scenario.player_role,
349
- max_rounds=str(MAX_ROUNDS),
350
  )
351
  user = (
352
  f"## Game state\n\n"
353
  f"- Turn: **{game.world.turn} of {game.world.max_turns}**\n"
354
- f"- Rounds used in this coach session: **{session.rounds_used} of {MAX_ROUNDS}**\n"
355
  f"- Public narrative: {game.world.public_narrative or '_(none yet)_'}\n"
356
  f"- Media tone: {game.world.media_tone.value}\n\n"
357
  f"## Current briefing\n\n{_briefing_summary_for_coach(game)}\n\n"
@@ -377,9 +398,9 @@ def advance_session(
377
  )
378
 
379
  # Force a downgrade if the round budget is exhausted but the model still
380
- # wants to ask another question. Round MAX_ROUNDS is the LAST allowed
381
  # round, so any 'asking' response on or after that round must commit.
382
- if session.rounds_used >= MAX_ROUNDS and raw.status == "asking":
383
  raw = _force_best_effort(raw, transcript=transcript)
384
 
385
  # Belt-and-braces: if the model says 'ready' but didn't include a draft,
 
38
  from wargame.engine.state import Game
39
 
40
 
41
+ import os as _os
42
+
43
+
44
+ def _resolve_max_rounds() -> int:
45
+ """Conversational round cap before the coach must commit best-effort.
46
+
47
+ Trial mode collapses the coach to a single round (default 1) so a
48
+ leaked password can't drive the chatbot in a loop. Operators override
49
+ via WARGAME_TRIAL_COACH_ROUNDS. Outside trial mode the historical
50
+ behaviour (2 rounds) stands.
51
+ """
52
+ if _os.getenv("WARGAME_TRIAL_MODE", "").strip() in ("1", "true", "yes", "on"):
53
+ try:
54
+ return max(int(_os.getenv("WARGAME_TRIAL_COACH_ROUNDS", "1")), 1)
55
+ except ValueError:
56
+ return 1
57
+ return 2
58
+
59
+
60
+ # Public constant kept for back-compat; readers should call
61
+ # `_resolve_max_rounds()` directly. The constant is recomputed at import
62
+ # time from env, which is fine for the single-process FastAPI server but
63
+ # means tests that flip env via monkeypatch must call _resolve_max_rounds()
64
+ # (or reload this module) to pick up the change.
65
+ MAX_ROUNDS = _resolve_max_rounds()
66
 
67
 
68
  # ---------------------------------------------------------------------------
 
140
  ge=0,
141
  description=(
142
  "Number of user-message / coach-reply rounds consumed so far. "
143
+ f"Capped at {_resolve_max_rounds()}; round {_resolve_max_rounds() + 1} forces ready."
144
  ),
145
  )
146
  status: Literal["gathering", "ready", "committed"] = "gathering"
 
367
  company_name=scenario.company_name,
368
  rival_name=scenario.rival_name,
369
  player_role=scenario.player_role,
370
+ max_rounds=str(_resolve_max_rounds()),
371
  )
372
  user = (
373
  f"## Game state\n\n"
374
  f"- Turn: **{game.world.turn} of {game.world.max_turns}**\n"
375
+ f"- Rounds used in this coach session: **{session.rounds_used} of {_resolve_max_rounds()}**\n"
376
  f"- Public narrative: {game.world.public_narrative or '_(none yet)_'}\n"
377
  f"- Media tone: {game.world.media_tone.value}\n\n"
378
  f"## Current briefing\n\n{_briefing_summary_for_coach(game)}\n\n"
 
398
  )
399
 
400
  # Force a downgrade if the round budget is exhausted but the model still
401
+ # wants to ask another question. The last allowed round is _resolve_max_rounds().
402
  # round, so any 'asking' response on or after that round must commit.
403
+ if session.rounds_used >= _resolve_max_rounds() and raw.status == "asking":
404
  raw = _force_best_effort(raw, transcript=transcript)
405
 
406
  # Belt-and-braces: if the model says 'ready' but didn't include a draft,
wargame/api/app.py CHANGED
@@ -120,8 +120,16 @@ def create_app() -> FastAPI:
120
  # otherwise break CORS handshakes and HF Space healthchecks.
121
  elif request.method == "OPTIONS" or request.url.path == "/healthz":
122
  pass
123
- elif request.headers.get("X-Trial-Password") != password:
124
- return JSONResponse({"detail": "Unauthorized"}, status_code=401)
 
 
 
 
 
 
 
 
125
  # Trial-mode: refuse expensive endpoints up front so a leaked
126
  # password can't drain the Azure budget through them.
127
  if _is_disabled(request.method, request.url.path):
 
120
  # otherwise break CORS handshakes and HF Space healthchecks.
121
  elif request.method == "OPTIONS" or request.url.path == "/healthz":
122
  pass
123
+ else:
124
+ # Browsers' EventSource API can't attach custom headers, so SSE
125
+ # endpoints accept the password as a `?token=` query param too.
126
+ # Anything else still requires the X-Trial-Password header.
127
+ supplied = (
128
+ request.headers.get("X-Trial-Password")
129
+ or request.query_params.get("token")
130
+ )
131
+ if supplied != password:
132
+ return JSONResponse({"detail": "Unauthorized"}, status_code=401)
133
  # Trial-mode: refuse expensive endpoints up front so a leaked
134
  # password can't drain the Azure budget through them.
135
  if _is_disabled(request.method, request.url.path):
wargame/engine/loop.py CHANGED
@@ -13,8 +13,21 @@ from __future__ import annotations
13
 
14
  import json
15
  import logging
 
16
  import threading
17
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  from wargame.agents import constraint_checker, rival_ceo
19
  from wargame.agents.base import begin_turn_usage, end_turn_usage, use_game_language
20
  from wargame.agents.registry import ScenarioConfig, load_scenario
@@ -145,10 +158,9 @@ def new_game(
145
  # can't drive a full 5-turn game (~80 LLM calls) in one click. Default
146
  # is 1 — a single decision plus debrief. Operators can override via
147
  # WARGAME_TRIAL_MAX_TURNS. No effect when WARGAME_TRIAL_MODE is unset.
148
- import os as _os
149
- if _os.getenv("WARGAME_TRIAL_MODE", "").strip() in ("1", "true", "yes", "on"):
150
  try:
151
- world.max_turns = max(int(_os.getenv("WARGAME_TRIAL_MAX_TURNS", "1")), 1)
152
  except ValueError:
153
  world.max_turns = 1
154
 
@@ -341,7 +353,11 @@ def advance(game: Game, *, generate_next_briefing: bool = True) -> Game:
341
  )
342
  _check_pause(game, current)
343
 
344
- if not current.critic_assessments:
 
 
 
 
345
  events.publish(
346
  PhaseEventKind.PHASE_STARTED, label="critic"
347
  )
@@ -351,7 +367,7 @@ def advance(game: Game, *, generate_next_briefing: bool = True) -> Game:
351
  )
352
  _check_pause(game, current)
353
 
354
- if not current.pre_mortems:
355
  events.publish(
356
  PhaseEventKind.PHASE_STARTED, label="premortem"
357
  )
@@ -371,7 +387,8 @@ def advance(game: Game, *, generate_next_briefing: bool = True) -> Game:
371
  )
372
  _check_pause(game, current)
373
 
374
- if not current.evaluator_scores and current.verdicts:
 
375
  events.publish(
376
  PhaseEventKind.PHASE_STARTED, label="evaluator"
377
  )
 
13
 
14
  import json
15
  import logging
16
+ import os
17
  import threading
18
 
19
+
20
+ def _trial_mode_active() -> bool:
21
+ """Whether WARGAME_TRIAL_MODE is set in any of the truthy variants.
22
+
23
+ Used by both new_game (max_turns cap) and advance (skip eval phases)
24
+ to gate trial-mode-only shrinks. Cheap to call — re-reads env every
25
+ time so an operator change takes effect without a restart.
26
+ """
27
+ return os.getenv("WARGAME_TRIAL_MODE", "").strip() in (
28
+ "1", "true", "yes", "on",
29
+ )
30
+
31
  from wargame.agents import constraint_checker, rival_ceo
32
  from wargame.agents.base import begin_turn_usage, end_turn_usage, use_game_language
33
  from wargame.agents.registry import ScenarioConfig, load_scenario
 
158
  # can't drive a full 5-turn game (~80 LLM calls) in one click. Default
159
  # is 1 — a single decision plus debrief. Operators can override via
160
  # WARGAME_TRIAL_MAX_TURNS. No effect when WARGAME_TRIAL_MODE is unset.
161
+ if _trial_mode_active():
 
162
  try:
163
+ world.max_turns = max(int(os.getenv("WARGAME_TRIAL_MAX_TURNS", "1")), 1)
164
  except ValueError:
165
  world.max_turns = 1
166
 
 
353
  )
354
  _check_pause(game, current)
355
 
356
+ # Trial mode skips critic + premortem to cut ~2 LLM calls per
357
+ # turn. They enrich the debrief but aren't part of the core
358
+ # narrative reveal that matters for a quick demo.
359
+ _trial = _trial_mode_active()
360
+ if not _trial and not current.critic_assessments:
361
  events.publish(
362
  PhaseEventKind.PHASE_STARTED, label="critic"
363
  )
 
367
  )
368
  _check_pause(game, current)
369
 
370
+ if not _trial and not current.pre_mortems:
371
  events.publish(
372
  PhaseEventKind.PHASE_STARTED, label="premortem"
373
  )
 
387
  )
388
  _check_pause(game, current)
389
 
390
+ # Evaluator scores are also skipped in trial mode (1 LLM call).
391
+ if not _trial and not current.evaluator_scores and current.verdicts:
392
  events.publish(
393
  PhaseEventKind.PHASE_STARTED, label="evaluator"
394
  )
wargame/tests/test_trial_safeguards.py CHANGED
@@ -94,6 +94,52 @@ def test_trial_mode_keeps_core_loop_open(monkeypatch):
94
  assert r.status_code == 200, f"{path} should be reachable in trial mode"
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def test_password_gate_still_runs_in_trial_mode(monkeypatch):
98
  """Disabling endpoints must not bypass the password gate — otherwise a
99
  503 leaks the existence of those routes to unauthenticated callers."""
 
94
  assert r.status_code == 200, f"{path} should be reachable in trial mode"
95
 
96
 
97
+ def test_sse_stream_accepts_token_query_param(monkeypatch, tmp_path):
98
+ """EventSource can't attach custom headers, so the SSE endpoint must
99
+ accept the password as `?token=...`. Header still works too."""
100
+ monkeypatch.setenv("WARGAME_TRIAL_PASSWORD", "letmein")
101
+ monkeypatch.setenv("WARGAME_GAMES_DIR", str(tmp_path))
102
+ app = _fresh_app(monkeypatch)
103
+ client = TestClient(app)
104
+ # No password -> 401
105
+ r = client.get("/games/nope/stream")
106
+ assert r.status_code == 401
107
+ # Header -> reaches the route, which 404s on unknown game
108
+ r = client.get("/games/nope/stream", headers={"X-Trial-Password": "letmein"})
109
+ assert r.status_code == 404
110
+ # Query param -> same as header
111
+ r = client.get("/games/nope/stream?token=letmein")
112
+ assert r.status_code == 404
113
+ # Wrong query param -> 401
114
+ r = client.get("/games/nope/stream?token=wrong")
115
+ assert r.status_code == 401
116
+
117
+
118
+ def test_action_coach_max_rounds_in_trial_mode(monkeypatch):
119
+ monkeypatch.setenv("WARGAME_TRIAL_MODE", "1")
120
+ from wargame.agents import action_coach
121
+
122
+ importlib.reload(action_coach)
123
+ assert action_coach._resolve_max_rounds() == 1
124
+
125
+
126
+ def test_action_coach_max_rounds_default(monkeypatch):
127
+ monkeypatch.delenv("WARGAME_TRIAL_MODE", raising=False)
128
+ from wargame.agents import action_coach
129
+
130
+ importlib.reload(action_coach)
131
+ assert action_coach._resolve_max_rounds() == 2
132
+
133
+
134
+ def test_action_coach_max_rounds_overridable(monkeypatch):
135
+ monkeypatch.setenv("WARGAME_TRIAL_MODE", "1")
136
+ monkeypatch.setenv("WARGAME_TRIAL_COACH_ROUNDS", "3")
137
+ from wargame.agents import action_coach
138
+
139
+ importlib.reload(action_coach)
140
+ assert action_coach._resolve_max_rounds() == 3
141
+
142
+
143
  def test_password_gate_still_runs_in_trial_mode(monkeypatch):
144
  """Disabling endpoints must not bypass the password gate — otherwise a
145
  503 leaks the existence of those routes to unauthenticated callers."""