diff --git a/WorldOS-GUI-RUNBOOK.md b/WorldOS-GUI-RUNBOOK.md index 6eec1328..ed7855dc 100644 --- a/WorldOS-GUI-RUNBOOK.md +++ b/WorldOS-GUI-RUNBOOK.md @@ -8,7 +8,7 @@ > `qa/release_readiness.py` (the RRI scorer), `qa/SCORECARD.md` (the ledger). > > Takeover routing, 2026-06-01: `/Users/lume/ClawDnD-val` is the synced local app/private-art checkout -> (`19c3fd0 == origin/main` after #475/#494/#495/#496/#498) and the default place to build/run/test the GUI and native app. +> (`ff84444 == origin/main` after #475/#494/#495/#496/#498/#499) and the default place to build/run/test the GUI and native app. > Lexar is for evidence/snapshots/logs, not the default runtime tree, because macOS permission prompts > can break AI/browser tests when assets live on the external drive. For tracked GUI edits, prefer a > same-disk local worktree; use Lexar worktrees only for non-GUI slices that will not launch against art. @@ -47,6 +47,15 @@ again proved product wiring: private art present, Codex provider, Alfira active, visible narration, five enabled actions, writable `/move`, one accepted move, chat roles `dm, player, dm`, and `/session-surface` still actionable. The provider trace still had three failed/cancelled engine tool calls, so #479 stays open. +- The #479 trace-clean branch proof + (`/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-479-traceclean-nodup-proof-20260601T003002Z/`, app-code + `b081092`) reran the built app with private art, Codex provider, Alfira active, five enabled actions, + a writable `/move`, one accepted/resolved player move, chat roles `dm, player, dm`, and `/session-surface` + still actionable. `app-evidence/manifest.json` had no gaps and `provider-errors.after-move.json` reported + zero parse errors plus zero failed/error tool calls. Native accessibility review showed exactly one opening + narration row and one follow-up narration row, confirming engine-logged `/chat` rows resolve turns without + duplicating visible prose. Treat this as branch diagnostic evidence until merged and, if required, rerun on + main before closing #479. ## Agent-facing app contract @@ -57,7 +66,7 @@ campaign, can the player act, where is the move sink, and is private art configured?" - `qa/ui_playtest_app.sh` captures launcher and minted-provider `app-status` JSON into the native evidence folder. A built-app proof that cannot produce this status object is a harness/product observability failure. -- Agent-grade testing progress as of `19c3fd0`: #481 app-status is closed, #482 deterministic scripted +- Agent-grade testing progress as of `ff84444`: #481 app-status is closed, #482 deterministic scripted provider is merged, #483 failure buckets are merged, and #484 stable accessibility/DOM hooks are merged. #485 evidence bundle completion and #486 gate-split follow-through remain active. A scripted `:8899` harness surface can prove app observability, but it is not built-app release proof unless it came from diff --git a/WorldOS-OPERATING-GOAL.md b/WorldOS-OPERATING-GOAL.md index 83807662..6d57f729 100644 --- a/WorldOS-OPERATING-GOAL.md +++ b/WorldOS-OPERATING-GOAL.md @@ -5,10 +5,10 @@ Post-compaction agents: this 6-line block is ground truth. Do NOT reconstruct state from scattered docs or old plans; trust this, verify the sha, then act. ────────────────────────────────────────────────────────────────────────── - AS OF: 2026-06-01T06:50:47+07:00 current-main built-app proof archived after PR #498 + AS OF: 2026-06-01T07:34:57+07:00 #479 trace-clean branch proof archived MAIN BASELINE: - 19c3fd0 (PRs #475, #494, #495, #496, #498 merged; verified - `HEAD == origin/main` in /Users/lume/ClawDnD-val on 2026-06-01T06:50:47+07:00). + ff84444 (PRs #475, #494, #495, #496, #498, #499 merged; verified + `/Users/lume/ClawDnD-val` was fast-forwarded after #499). Re-verify current `origin/main` before acting. CANONICAL: /Users/lume/ClawDnD-val is now the synced local app/private-art checkout and the default place to build/run/test the Mac app. Keep GUI/runtime tests on this @@ -32,6 +32,13 @@ narration, five enabled actions, and one `/move` resolved. It is PLAYABLE diagnostic evidence, but NOT trace-clean: #479 remains open because provider trace contained 3 failed/safety-cancelled tool calls. + Latest trace-clean #479 branch proof is app/code `b081092` + (`codex-479-traceclean-nodup-proof-20260601T003002Z`): same built-app + playability proof, no app-evidence gaps, and provider trace summary + records 0 parse errors and 0 failed/error tool calls; native accessibility + review showed one opening narration row and one post-move narration row, not + duplicate chat/event prose. This is branch diagnostic evidence until merged + and, if required, rerun on main. Latest deterministic built-app smoke proof is PR #495 app-code `698ed65` (`ui-hooks-app-proof-20260601T054934`): scripted provider, private art, Abby seated, five enabled actions, writable `/move`, one @@ -41,8 +48,8 @@ LAST VALID RELEASE GATE: none after the RRI contract hardening. A release verdict requires expected persona count, disk-backed palette/image/behavioral evidence, and built .app play. - NEXT ACTION: Close or narrow #479 with a trace-clean current-main built-app run or - deterministic-provider release split, then run #466 for a trustworthy + NEXT ACTION: Open/land the #479 trace-clean branch, rerun/confirm on merged main if + needed for closure, then run #466 for a trustworthy clean RRI failure list/result: Mac/local or macOS CI supplies built `.app` proof, while the 32GB support VM runs heavy backend/persona sweeps after explicit VM preflight/auth setup. Continue #485/#486 for @@ -186,7 +193,7 @@ verifier; can revert the goal to "fix" anytime. --- -## 9. CURRENT STATUS (2026-06-01T06:50:47+07:00 — current-main built-app proof archived, provider trace still noisy) +## 9. CURRENT STATUS (2026-06-01T07:34:57+07:00 — #479 trace-clean branch proof archived) - Repo truth stabilization merged in PR #465, UX-first doc sync merged in PR #468, first-minute click/title chrome proof merged in PR #470, local/Lexar/support-VM routing merged in PR #471, @@ -195,8 +202,9 @@ verifier; can revert the goal to "fix" anytime. `/app-status`, evidence export baseline, and docs; PR #494 added the dev-gated scripted provider; PR #495 added stable OpenWorlds accessibility / `data-worldos-testid` hooks; PR #496 added app playtest failure buckets plus RRI gate-split metadata. PR #498 synced takeover docs after those - merges. The local app/private-art checkout `/Users/lume/ClawDnD-val` is synced to - `19c3fd0 == origin/main` as of 2026-06-01T06:50:47+07:00. + merges. PR #499 then recorded the current-main built-app proof. The local app/private-art + checkout `/Users/lume/ClawDnD-val` was fast-forwarded to `ff84444 == origin/main` + after #499. - The stale local pre-sync artifacts were preserved before the fast-forward at `/Volumes/LEXAR/Codex/worldos-local-checkout-snapshot-20260531T223923` and in `stash@{0}` (`pre-sync local takeover docs 2026-05-31`). Treat those as evidence, not current release truth. @@ -205,9 +213,9 @@ verifier; can revert the goal to "fix" anytime. image/palette/behavioral/UI audit sources were either missing or harness-contaminated. - Built-app launch smoke on `cad2e00` rendered OpenWorlds with private art, but the first Resume/Play click still forced Claude and failed on Claude auth. PR #472 fixed that web/native selection bug. - The current #474 slice adds a separate Codex DM wrapper and points the native Codex adapter at it, + PR #475 then added a separate Codex DM wrapper and points the native Codex adapter at it, while keeping the older Codex actor wrapper as a constrained player/move-facade helper. -- The new Codex-DM path has now produced local built-app first-turn evidence: private BG art loaded, +- Early Codex-DM local built-app evidence is preserved: private BG art loaded, Alfira seated as `player`, visible DM narration, enabled actions, a real player move appended to `player_moves.jsonl`, and a post-move DM response with `can_act:true` in `/session-surface`. Evidence is in `/Volumes/LEXAR/Codex/worldos-built-app-proof/`: @@ -218,7 +226,7 @@ verifier; can revert the goal to "fix" anytime. macOS TCC attribution contamination: `responsible=dev.clawdnd.app`, but the actual accessor was `/usr/bin/find` launched by the test/diagnostic environment. Treat that screenshot prompt as harness contamination unless a clean run shows `WorldOSApp`/WebKit itself accessing a protected library path. -- The next gate evidence step is issue #466: a clean non-partial five-persona RRI from `19c3fd0` or newer. +- The next gate evidence step is issue #466: a clean non-partial five-persona RRI from `ff84444` or newer. Heavy backend/persona sweeps belong on the owner-provided 32GB support VM (`support-vm-1`) once auth/config are intentionally installed there; connection details are kept outside tracked docs. Mac-only built-app launch/play proof stays on this Mac or macOS CI. @@ -230,8 +238,13 @@ verifier; can revert the goal to "fix" anytime. private art root present, Codex provider, live campaign/run, writable `/move`, Alfira active, five enabled actions, visible narration, one accepted player move, chat roles `dm, player, dm`, and `/session-surface` still live/actionable. Its provider trace still recorded 3 failed/cancelled tool calls - (`log_event`, `log_event`, `persist_beat`), so #479 remains open until a later built-app run proves - non-reproduction or the release gate formally separates deterministic wiring proof from real-provider quality. + (`log_event`, `log_event`, `persist_beat`). The follow-up #479 branch proof `b081092` + (`codex-479-traceclean-nodup-proof-20260601T003002Z`) reran the built `WorldOS.app` with private art, + accepted and resolved a real `/move`, kept `/session-surface` live/actionable, exported + `app-evidence/manifest.json` with no gaps, and produced a provider trace summary with zero parse errors + and zero failed/error tool calls. Native accessibility review also showed the chronicle rendered the opening + and follow-up once each after suppressing engine-logged `/chat` duplicates. Treat that as branch diagnostic + evidence until the fix lands on main and, if release closure requires it, is rerun on merged main. - The agent-grade testability layer now has real code merged: `GET /app-status` exposes the live run, campaign, provider, private-art presence, move sink, actor, enabled actions, readiness, and failure buckets without mutating state; the scripted provider can prove wiring behind a dev/test gate; and stable a11y/DOM @@ -259,8 +272,9 @@ Use the gate as evidence, not as the roadmap. The next sprint should optimize th 1. **Stretch first-turn proof into a short built-app playtest.** PARTIAL. PR #475 and follow-up proofs show a fresh player can launch, choose/start/resume, reach the Table, submit multiple `/move`s, and see narration resolve without - critical console/runtime errors. Current `19c3fd0` has a fresh built-app proof, but #479 remains open on - real-provider trace cleanliness. Evidence must be built-app screenshots plus `/app-status`, + critical console/runtime errors. Current main has fresh built-app proof, and the #479 branch now has + trace-clean real-provider evidence that still needs merge/main confirmation. Evidence must be built-app + screenshots plus `/app-status`, `/session-surface`, move/chat/provider artifacts, not a proxy preview. 2. **Fix the "this is not clickable" feeling.** Close #309 only when clicking any visible tab/button background works with mouse and keyboard. Pair with visual truth for #306 so the title/day/chrome no diff --git a/WorldOS-RUNBOOK.md b/WorldOS-RUNBOOK.md index e251777a..b4c963e4 100644 --- a/WorldOS-RUNBOOK.md +++ b/WorldOS-RUNBOOK.md @@ -25,7 +25,7 @@ > If an operator hands you local session notes or decision records, treat them as > private working artifacts unless they are intentionally promoted into tracked docs. > -> Last updated: 2026-06-01T06:50:47+07:00 (`main@19c3fd0`; #475/#494/#495/#496/#498 merged; release notes below are historical context). +> Last updated: 2026-06-01T07:34:57+07:00 (`main@ff84444`; #475/#494/#495/#496/#498/#499 merged; #479 trace-clean branch proof at `b081092`; release notes below are historical context). > > **Graphics & game-types roadmap (canonical):** the long-term plan for the kinds of games > WorldOS can produce (GT0 narrative dashboard → GT1 SNES pixel → GT2 Pillars/BG isometric) @@ -309,13 +309,15 @@ the UX-first doc sync merged as PR #468, and first-minute click/title chrome pro Local routing sync merged as PR #471, native provider-selection sync merged as PR #472, takeover state docs synced as PR #473, Codex-DM app observability merged as PR #475, scripted smoke provider merged as PR #494, stable agent UI hooks merged as PR #495, failure-bucket/RRI split metadata merged as PR #496, -and takeover truth sync merged as PR #498. The local app/private-art checkout is now synced at -`19c3fd0 == origin/main`; the only current gate +and takeover truth sync merged as PR #498, followed by PR #499 recording current-main built-app proof. +The local app/private-art checkout is now synced at +`ff84444 == origin/main`; the only current gate truth lives in `WorldOS-OPERATING-GOAL.md` + `WorldOS-GUI-RUNBOOK.md` + `qa/SCORECARD.md`. Do not use this section to decide release state. The next sprint is UX-first (#467): -current-main built-app play is proven diagnostically on `19c3fd0`, but #479 remains open because the -provider trace still failed/cancelled engine tool calls. Close/narrow #479 with trace-clean evidence or a -formal deterministic-vs-provider gate split, run #466 only after Mac built-app proof plus support-VM preflight, +current-main built-app play is proven diagnostically on `19c3fd0`, and the #479 branch proof at `b081092` +is trace-clean with zero failed/error provider tool calls and no visible chronicle duplication in native +accessibility review. Land that branch and rerun/confirm on main if required, then run #466 only after +Mac built-app proof plus support-VM preflight, then prioritize clickability/chrome, launcher clarity, live-response feel, and CRPG depth before more hardening/proxy/security work. diff --git a/qa/SCORECARD.md b/qa/SCORECARD.md index 06ff3364..5e7a1ddb 100644 --- a/qa/SCORECARD.md +++ b/qa/SCORECARD.md @@ -12,6 +12,7 @@ | Run | Date | app/code SHA | Surface | Provider | Evidence | Result / notes | |---|---|---|---|---|---|---| +| codex-479-traceclean-nodup-proof-20260601T003002Z | 2026-06-01 | b081092 | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-479-traceclean-nodup-proof-20260601T003002Z/` | **PASS trace-clean branch diagnostic for #479.** Private BG art present via `/Users/lume/ClawDnD-val`; `/app-status` reported build `b081092`, live Codex run `play-20260601003008`, writable move sink, Alfira active, five enabled actions, and no evidence gaps in `app-evidence/manifest.json`; one `/move` accepted and resolved; chat roles `dm, player, dm`; `/session-surface` remained live/actionable; native after-move screenshot archived. `provider-errors.after-move.json` reports zero parse errors, zero failed/error tool calls, and statuses `completed=10`, `in_progress=10`. Native accessibility review showed one opening narration row and one follow-up narration row, not duplicate chat/event prose. Not an RRI verdict; rerun on merged main before closing #479 if required by release policy. | | codex-current-main-proof-20260531T234242Z | 2026-06-01 | 19c3fd0 | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-current-main-proof-20260531T234242Z/` | **PASS playable diagnostic, but provider trace not clean.** Private BG art present; `/app-status` reported build `19c3fd0`, live Codex run, writable move sink, Alfira active, five enabled actions, and private-art root present; one `/move` accepted and resolved; chat roles `dm, player, dm`; `/session-surface` remained live/actionable; built-app screenshot and `app-evidence/manifest.json` archived. `provider-errors.after-move.json` recorded three failed/cancelled engine tool calls (`log_event`, `log_event`, `persist_beat`), so #479 remains open and this is not trace-clean release evidence. | | ui-hooks-app-proof-20260601T054934 | 2026-06-01 | 698ed65 | `dist/WorldOS.app` local Mac | Scripted provider | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/ui-hooks-app-proof-20260601T054934/` | **PASS diagnostic deterministic built-app smoke for #484.** Private art present via `/Users/lume/ClawDnD-val`; built app launched scripted provider; Abby seated as active player; five enabled actions; writable `/move`; one deterministic follow-up resolved; real location/portrait/class image bytes. Not a real-provider or RRI verdict. | | scripted-provider-app-proof-20260601T054328 | 2026-06-01 | 88c4394 | `dist/WorldOS.app` local Mac | Scripted provider | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/scripted-provider-app-proof-20260601T054328/` | **PASS diagnostic deterministic built-app smoke for #482.** Private art present; scripted provider launched behind the dev/test gate; Abby seated; five enabled actions; writable `/move`; direct provider contract and Swift build checks passed before merge. Not a real-provider or RRI verdict. | diff --git a/scripts/play_codex_dm.sh b/scripts/play_codex_dm.sh index 537ca57a..55b933f6 100755 --- a/scripts/play_codex_dm.sh +++ b/scripts/play_codex_dm.sh @@ -339,16 +339,50 @@ HERO_PC_CLASS="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.class // ""')" echo "[codex-dm-provider] seeded solo player: $HERO_PC_NAME ($HERO_PC_RACE $HERO_PC_CLASS) in campaign $HERO_CAMP" chatlog() { - python3 - "$CHAT" "$1" "$2" <<'PY' + python3 - "$CHAT" "$1" "$2" "${3:-}" <<'PY' import json import sys -path, role, text = sys.argv[1:] +path, role, text, extra_json = sys.argv[1:] +row = {"role": role, "text": text} +if extra_json: + try: + extra = json.loads(extra_json) + except ValueError as exc: + raise SystemExit(f"invalid chatlog extra_json: {exc}") from exc + if not isinstance(extra, dict): + raise SystemExit(f"invalid chatlog extra_json: expected object, got {type(extra).__name__}") + row.update(extra) with open(path, "a", encoding="utf-8") as handle: - handle.write(json.dumps({"role": role, "text": text}) + "\n") + handle.write(json.dumps(row) + "\n") +PY +} + +log_engine_narration() { + local campaign_id="$1" text="$2" + [ -n "${campaign_id//[[:space:]]/}" ] || return 1 + [ -n "${text//[[:space:]]/}" ] || return 1 + CLAWDND_STATE_DIR="$RUN_DIR" WORLDOS_STATE_DIR="$RUN_DIR" \ + uv run --directory "$ROOT/servers/engine" python - "$campaign_id" "$text" <<'PY' +import sys + +import server + +campaign_id, text = sys.argv[1], sys.argv[2] +server.log_event(campaign_id, "narration", text) PY } +record_dm_reply() { + local campaign_id="$1" text="$2" phase="$3" + if log_engine_narration "$campaign_id" "$text"; then + chatlog dm "$text" '{"engine_logged":true}' + else + echo "[codex-dm-provider] warning: could not record ${phase} narration through engine" >&2 + chatlog dm "$text" + fi +} + codex_dm_turn() { local prompt="$1" printf '%s\n' "$prompt" > "$PROMPT_FILE" @@ -389,12 +423,16 @@ codex_dm_turn() { } LOG_EVENT_TOOL_RULE="Tool argument rule: for log_event narration, omit the speaker argument entirely. For dialogue, pass a real non-empty character id or name. Never pass JSON null for speaker or any optional string field." +WRAPPER_NARRATION_LOG_RULE="Wrapper narration log rule: Do not call log_event for player-facing narration or dialogue in this provider wrapper. Put visible prose and dialogue in your final reply; the wrapper writes that reply to chat and records it through the engine after the turn. Use log_event only if a later explicit instruction requires a short non-duplicate system/roll row." +OPENING_LOG_EVENT_RULE="Opening log rule: do not call log_event for the full opening narration. Put the player-facing opening prose in your final reply; the wrapper will record that final reply through the engine after the turn. Only call log_event during the opening if you need one short non-duplicate system/roll row." STATE_DISCOVERY_RULE="State discovery rule: after reading skills/dungeon-master/SKILL.md, use clawdnd-engine/clawdnd-rules MCP tools for live game state. Do not use shell commands, rg, find, or filesystem reads to discover campaign state." STARTUP_MUTATION_RULE="Startup mutation rule: the wrapper has already seated the one player before you are called. Before the first player-facing narration, do not call start_world, start_session, start_character, load_canon_character, create_character, or recruit_companion. Introduce scene NPCs in narration first; create or load a tracked NPC only after the player engages them." SOCIAL_CHECK_TARGET_RULE="Social check target rule: call social_check only when scene_context already shows a real tracked npc_id for the target. Do not call load_canon_character or create_character solely to manufacture a social-check target during the same turn. If the target is not already tracked, do not use persuasion, deception, intimidation, or another attitude-moving social skill. Use a non-attitude skill_check such as investigation or perception for what the player can infer, then narrate the scene-local response; persist a new NPC later only when the player keeps engaging them." RULES_LOOKUP_RULE="Rules lookup rule: during the opening turn, do not call lookup_class or other rules lookups just to restate the pre-seated player's class/race; get_state already includes enough player-facing identity for the opener. Use clawdnd-rules only when resolving an actual rule, spell, item, condition, or monster question." PARLEY_TOOL_RULE="Parley tool rule: when using generate_parley_options, pass an explicit skills array such as persuasion, insight, performance, intimidation, deception. Do not rely on include_alignment or an implicit 'any' skill." -PERSIST_BEAT_RULE="Persist beat rule: do not call persist_beat during the opening turn. Opening state is already logged through log_event; persist only after at least one real player move has been resolved. When calling persist_beat with memories, each memory must be an object with character_id and fact fields. Do not pass memory strings." +REWARD_MUTATION_RULE="Reward mutation rule: Do not call award_xp, grant_xp, level_up, or reward-granting mutation tools in this built-app provider proof path. If a moment deserves reward accounting, mention the fictional consequence in final narration and persist only memory/decision context with persist_beat." +OPENING_PERSIST_BEAT_RULE="Opening persist rule: do not call persist_beat during the opening turn. Opening state is recorded by the wrapper after your final reply; persist only on later turns after an actual player move has been resolved." +MOVE_PERSIST_BEAT_RULE="Move persist rule: This is a post-move turn: at least one real player move has been accepted and relayed below. If this beat produced durable memory, decision, or time changes, call persist_beat only after you have resolved the move. Do not put player-facing prose into persist_beat events; the wrapper records your final reply through the engine. When calling persist_beat with memories, each memory must be an object with character_id and fact fields. Do not pass memory strings." if [ -n "${CLAWDND_PLAY_COMPANIONS//[[:space:]]/}" ]; then COMPANION_TOOL_RULE="Companion rule: only add companions named by CLAWDND_PLAY_COMPANIONS (${CLAWDND_PLAY_COMPANIONS}). Do not add any other companion to the party." else @@ -435,12 +473,15 @@ You are the Dungeon Master for a solo WorldOS / ClawDnD adventure in world "$CLA Before acting, read skills/dungeon-master/SKILL.md and follow its live-world contract. Use the clawdnd-engine tools as the sole writer of game state, clawdnd-rules for rules grounding, and clawdnd-voice only if needed with the null backend. $LOG_EVENT_TOOL_RULE +$WRAPPER_NARRATION_LOG_RULE +$OPENING_LOG_EVENT_RULE $STATE_DISCOVERY_RULE $STARTUP_MUTATION_RULE $SOCIAL_CHECK_TARGET_RULE $RULES_LOOKUP_RULE $PARLEY_TOOL_RULE -$PERSIST_BEAT_RULE +$REWARD_MUTATION_RULE +$OPENING_PERSIST_BEAT_RULE $COMPANION_TOOL_RULE Native-selected canon hero already seated: @@ -462,12 +503,15 @@ You are the Dungeon Master for a solo WorldOS / ClawDnD adventure in world "$CLA Before acting, read skills/dungeon-master/SKILL.md and follow its live-world contract. Use the clawdnd-engine tools as the sole writer of game state, clawdnd-rules for rules grounding, and clawdnd-voice only if needed with the null backend. $LOG_EVENT_TOOL_RULE +$WRAPPER_NARRATION_LOG_RULE +$OPENING_LOG_EVENT_RULE $STATE_DISCOVERY_RULE $STARTUP_MUTATION_RULE $SOCIAL_CHECK_TARGET_RULE $RULES_LOOKUP_RULE $PARLEY_TOOL_RULE -$PERSIST_BEAT_RULE +$REWARD_MUTATION_RULE +$OPENING_PERSIST_BEAT_RULE $COMPANION_TOOL_RULE Start a live solo session: @@ -484,9 +528,9 @@ fi if ! OPENING="$(codex_dm_turn "$OPENING_PROMPT")"; then fail "Codex DM opening turn failed; see $STDERR_LOG" fi -chatlog dm "$OPENING" ACTIVE_CAMPAIGN_ID="$(discover_active_campaign_id)" +record_dm_reply "$ACTIVE_CAMPAIGN_ID" "$OPENING" "opening" CAMPAIGN_TOOL_HINT="$(campaign_tool_hint "$ACTIVE_CAMPAIGN_ID")" DM_TURNS=1 @@ -505,19 +549,21 @@ while true; do if ! REPLY="$(codex_dm_turn "You are the Dungeon Master mid-session. Re-ground from the engine state first, then resolve this player move through the engine/rules tools and reply with 2nd-person player-facing narration. $LOG_EVENT_TOOL_RULE +$WRAPPER_NARRATION_LOG_RULE $STATE_DISCOVERY_RULE $CAMPAIGN_TOOL_HINT $SOCIAL_CHECK_TARGET_RULE $RULES_LOOKUP_RULE $PARLEY_TOOL_RULE -$PERSIST_BEAT_RULE +$REWARD_MUTATION_RULE +$MOVE_PERSIST_BEAT_RULE $COMPANION_TOOL_RULE Player move: $PMSG")"; then fail "Codex DM move turn failed; see $STDERR_LOG" fi - chatlog dm "$REPLY" + record_dm_reply "$ACTIVE_CAMPAIGN_ID" "$REPLY" "move" DM_TURNS=$((DM_TURNS + 1)) else sleep 2 diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index 39df1e50..e02a2c3c 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -185,9 +185,40 @@ def test_codex_dm_wrapper_forbids_null_speaker_arguments(): source = DM_SCRIPT.read_text(encoding="utf-8") assert "LOG_EVENT_TOOL_RULE=" in source + assert "WRAPPER_NARRATION_LOG_RULE=" in source + assert "OPENING_LOG_EVENT_RULE=" in source assert "omit the speaker argument entirely" in source assert "Never pass JSON null for speaker" in source + assert "Do not call log_event for player-facing narration or dialogue in this provider wrapper" in source + assert "do not call log_event for the full opening narration" in source + assert "log_engine_narration" in source + assert '[ -n "${campaign_id//[[:space:]]/}" ] || return 1' in source + assert '[ -n "${text//[[:space:]]/}" ] || return 1' in source assert source.count("$LOG_EVENT_TOOL_RULE") >= 3 + assert source.count("$WRAPPER_NARRATION_LOG_RULE") == 3 + assert 'record_dm_reply "$ACTIVE_CAMPAIGN_ID" "$REPLY" "move"' in source + assert '"engine_logged":true' in source + assert "invalid chatlog extra_json" in source + + +def test_codex_dm_wrapper_records_engine_narration_before_chat_tail(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + helper_start = source.index("record_dm_reply() {") + helper_end = source.index("codex_dm_turn() {", helper_start) + helper_block = source[helper_start:helper_end] + assert helper_block.index('log_engine_narration "$campaign_id" "$text"') < helper_block.index('chatlog dm "$text"') + assert 'chatlog dm "$text" \'{"engine_logged":true}\'' in helper_block + + opening_start = source.index('if ! OPENING="$(codex_dm_turn "$OPENING_PROMPT")"') + opening_end = source.index('CAMPAIGN_TOOL_HINT="$(campaign_tool_hint "$ACTIVE_CAMPAIGN_ID")"', opening_start) + opening_block = source[opening_start:opening_end] + assert 'record_dm_reply "$ACTIVE_CAMPAIGN_ID" "$OPENING" "opening"' in opening_block + + move_start = source.index('if ! REPLY="$(codex_dm_turn') + move_end = source.index('DM_TURNS=$((DM_TURNS + 1))', move_start) + move_block = source[move_start:move_end] + assert 'record_dm_reply "$ACTIVE_CAMPAIGN_ID" "$REPLY" "move"' in move_block def test_codex_dm_wrapper_prompts_use_engine_state_discovery(): @@ -238,13 +269,28 @@ def test_codex_dm_wrapper_avoids_noisy_provider_tool_retries(): assert "PARLEY_TOOL_RULE" in source assert "pass an explicit skills array" in source assert "Do not rely on include_alignment" in source - assert "PERSIST_BEAT_RULE" in source + assert "OPENING_PERSIST_BEAT_RULE" in source + assert "MOVE_PERSIST_BEAT_RULE" in source + assert "REWARD_MUTATION_RULE" in source + assert "Do not call award_xp" in source assert "do not call persist_beat during the opening turn" in source - assert "persist only after at least one real player move" in source + assert "This is a post-move turn: at least one real player move has been accepted" in source assert "each memory must be an object with character_id and fact fields" in source assert source.count("$RULES_LOOKUP_RULE") == 3 assert source.count("$PARLEY_TOOL_RULE") == 3 - assert source.count("$PERSIST_BEAT_RULE") == 3 + assert source.count("$REWARD_MUTATION_RULE") == 3 + assert source.count("$OPENING_PERSIST_BEAT_RULE") == 2 + assert source.count("$MOVE_PERSIST_BEAT_RULE") == 1 + + +def test_codex_dm_wrapper_move_prompt_does_not_restate_opening_persist_ban(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + start = source.index("You are the Dungeon Master mid-session") + move_prompt = source[start : source.index("Player move:", start)] + assert "$MOVE_PERSIST_BEAT_RULE" in move_prompt + assert "$OPENING_PERSIST_BEAT_RULE" not in move_prompt + assert "do not call persist_beat during the opening turn" not in move_prompt def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 1ddc546b..76ead7e6 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -403,6 +403,7 @@ function useLiveSession(state) { // no narration, or the human/native path where /chat is the sole source) — text-keyed, // since a chat-only beat has no session-log seq, and there is no /events stream to // collide with in that case. + if (it.engine_logged === true) return null; if (eventsStreamedThisTurnRef.current) return null; const clean = sanitize(it.text); return clean && claimNarration(clean) ? { kind: "narration", text: clean, at: nextLogSeq() } : null; diff --git a/viewer/tests/test_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py index b678fe8b..d7488beb 100644 --- a/viewer/tests/test_live_narration_stream.py +++ b/viewer/tests/test_live_narration_stream.py @@ -661,6 +661,25 @@ def test_recent_events_deduped_against_live_tail_by_seq(self): "recentEvents rows sharing a live seq must be dropped (immune to prose); the older un-twinned line is kept, leading, in order (#405)", ) + # --- #479: provider wrappers may write the final DM reply to /chat only as a turn-resolution + # signal after also recording the same prose through the engine. That /chat row is marked + # engine_logged and must clear pending without adding a duplicate chronicle row, even if /chat + # arrives before the /events poll catches up. --------------------------------------------------- + def test_engine_logged_chat_reply_resolves_without_rendering_duplicate(self): + out = self._run( + "h.arm('ask the sergeant');" + "h.enqueue('/chat', { items: [{ role: 'dm', text: 'The sergeant refuses to name the captain.', engine_logged: true }], next: 1 });" + "await h.tick();" + "var afterChat = h.narrationTexts();" + "var pendingAfterChat = h.pending();" + "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'The sergeant refuses to name the captain.', seq: 7 }], next: 8 });" + "await h.tick();" + "return ({ afterChat, pendingAfterChat, chronicle: h.chronicleNarration() });" + ) + self.assertEqual(out["afterChat"], [], "engine-logged /chat rows are resolution signals, not visible duplicate prose") + self.assertIsNone(out["pendingAfterChat"], "engine-logged /chat rows still resolve the pending turn") + self.assertEqual(out["chronicle"], ["The sergeant refuses to name the captain."]) + # --- #406 (3): a legitimately-REPEATED /chat-only line on a LATER turn must still render ------- # The seq-keyed dedup (#407) is the canonical run-long path; the TEXT-key fallback (used only for # a /chat-only beat: a terse turn, or the human/native path) must be scoped to the TURN, not the