changeset 261:b401627fc49e

Add JRPG mock flows and interactive previews Add scripted mock SSE commands, custom event forwarding, animated chat turns, full-height message navigation, and a cyberpunk resume dossier. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <mrjunejune@users.noreply.github.com>
date Wed, 05 Aug 2026 20:38:32 -0700
parents 1f9877b637e9
children 0f45474c1b1a
files .claude/skills/zenbu-personal-site/SKILL.md mrjunejune/BUILD mrjunejune/README.md mrjunejune/conversation_api.c mrjunejune/inference/BUILD mrjunejune/inference/mock_responses.json mrjunejune/inference/mock_sidecar.py mrjunejune/inference/mock_sidecar_test.py mrjunejune/inference_bridge.c mrjunejune/inference_bridge.h mrjunejune/inference_stack.sh mrjunejune/src/jrpg/index.html mrjunejune/src/jrpg/jrpg.css mrjunejune/src/jrpg/jrpg.js mrjunejune/src/sw.js mrjunejune/test/conversation_api_test.js mrjunejune/test/inference_bridge_fake_sidecar.c mrjunejune/test/inference_bridge_test.c mrjunejune/test/production_inference_bundle_test.sh mrjunejune/test/snapshots/jrpg.snapshot mrjunejune/test/theme_and_webp_test.js
diffstat 21 files changed, 2082 insertions(+), 124 deletions(-) [+]
line wrap: on
line diff
--- a/.claude/skills/zenbu-personal-site/SKILL.md	Wed Aug 05 09:19:41 2026 -0700
+++ b/.claude/skills/zenbu-personal-site/SKILL.md	Wed Aug 05 20:38:32 2026 -0700
@@ -87,6 +87,13 @@
   bazel run //mrjunejune:run_inference_stack
 ```
 
+Use `bazel run //mrjunejune:run_inference_stack -- --mock` for deterministic
+UI development through the real Seobeo, Deita, inference bridge, and SSE path
+without Copilot authentication or a LiteLLM key. Mock stream scenarios are
+maintained as `!command` event scripts in
+`mrjunejune/inference/mock_responses.json`; custom event types pass through
+Seobeo to the page's `mjj-jrpg-stream-event`.
+
 ## Production-minded checklist
 
 - Use Dowa's fixed-width aliases and `boolean`/`TRUE`/`FALSE` in first-party C
--- a/mrjunejune/BUILD	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/BUILD	Wed Aug 05 20:38:32 2026 -0700
@@ -326,6 +326,7 @@
     "$(location //mrjunejune/inference:litellm_proxy_zip)",
     "$(location //mrjunejune/inference:litellm_config.yaml)",
     "$(location @python_3_11//:python3)",
+    "$(location //mrjunejune/inference:mock_sidecar)",
   ],
   data = [
     ":mrjunejune_server",
@@ -333,6 +334,7 @@
     "//mrjunejune/inference:copilot_sidecar_zip",
     "//mrjunejune/inference:litellm_config.yaml",
     "//mrjunejune/inference:litellm_proxy_zip",
+    "//mrjunejune/inference:mock_sidecar",
     "@copilot_cli_linux_x86_64//:copilot",
     "@python_3_11//:files",
     "@python_3_11//:python3",
--- a/mrjunejune/README.md	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/README.md	Wed Aug 05 20:38:32 2026 -0700
@@ -28,6 +28,21 @@
   bazel run //mrjunejune:run_inference_stack
 ```
 
+For UI work, start the same Seobeo, Deita, inference bridge, and SSE path with
+deterministic mock responses. This mode does not require Copilot authentication
+or a LiteLLM key:
+
+```bash
+bazel run //mrjunejune:run_inference_stack -- --mock
+```
+
+Send `!hello`, `!response`, `!tool`, `!plan`, or `!error` to run a scripted
+event sequence. Edit `mrjunejune/inference/mock_responses.json` to add commands,
+ordered SSE event payloads, per-event timing, and response chunks. Custom event
+types pass through Seobeo and are published by the page as
+`mjj-jrpg-stream-event`, ready for future tool-call and plan-view components.
+Mock conversations use a separate database under the inference state directory.
+
 Keep `GITHUB_COPILOT_TOKEN_DIR` exported when starting the stack so it reads the
 same tokens. Without an override, both commands use
 `$XDG_STATE_HOME/mrjunejune/inference/litellm-copilot`, or
--- a/mrjunejune/conversation_api.c	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/conversation_api.c	Wed Aug 05 20:38:32 2026 -0700
@@ -16,6 +16,8 @@
 #define CONVERSATION_PROMPT_MAX (32 * 1024)
 #define CONVERSATION_RESPONSE_MAX (256 * 1024)
 #define CONVERSATION_HISTORY_MAX (512 * 1024)
+#define CONVERSATION_CUSTOM_EVENT_MAX (3 * 1024)
+#define CONVERSATION_EVENT_NAME_MAX 64
 #define CONVERSATION_ACTIVE_MAX 4
 #define CONVERSATION_TURNS_PER_MINUTE 60
 
@@ -45,6 +47,40 @@
 
 static Pending_Turn *g_pending_turns = NULL;
 
+static boolean Conversation_API_Is_Handled_Event(const char *type)
+{
+  return
+      strcmp(type, "ready") == 0 ||
+      strcmp(type, "turn.accepted") == 0 ||
+      strcmp(type, "assistant.delta") == 0 ||
+      strcmp(type, "assistant.completed") == 0 ||
+      strcmp(type, "assistant.usage") == 0 ||
+      strcmp(type, "turn.error") == 0 ||
+      strcmp(type, "turn.done") == 0;
+}
+
+static boolean Conversation_API_Is_Safe_Event_Name(const char *type)
+{
+  if (!type || type[0] == '\0')
+    return FALSE;
+  size_t length = strlen(type);
+  if (length > CONVERSATION_EVENT_NAME_MAX)
+    return FALSE;
+  for (size_t i = 0; i < length; i++)
+  {
+    char character = type[i];
+    if (!(
+            (character >= 'a' && character <= 'z') ||
+            (character >= 'A' && character <= 'Z') ||
+            (character >= '0' && character <= '9') ||
+            character == '.' ||
+            character == '_' ||
+            character == '-'))
+      return FALSE;
+  }
+  return TRUE;
+}
+
 static boolean Conversation_API_Acquire_Turn_Slot(void)
 {
   time_t now = time(NULL);
@@ -316,6 +352,48 @@
     return;
   }
 
+  if (!Conversation_API_Is_Handled_Event(p_event->type))
+  {
+    if (!Conversation_API_Is_Safe_Event_Name(p_event->type) ||
+        !p_event->raw_json ||
+        p_event->raw_json_length == 0 ||
+        p_event->raw_json_length > CONVERSATION_CUSTOM_EVENT_MAX)
+    {
+      p_turn->failed = TRUE;
+      snprintf(
+          p_turn->error_message,
+          sizeof(p_turn->error_message),
+          "Invalid custom inference event");
+      Conversation_API_Send_Event(
+          p_turn,
+          "turn.error",
+          "{\"code\":\"invalid_custom_event\","
+          "\"message\":\"Invalid custom inference event\"}");
+      Conversation_API_Finalize_Pending(p_turn);
+    }
+    else if (Conversation_API_Send_Event(
+                 p_turn, p_event->type, p_event->raw_json) < 0)
+    {
+      p_turn->aborted = TRUE;
+      should_abort = TRUE;
+      snprintf(
+          abort_request_id,
+          sizeof(abort_request_id),
+          "%s",
+          p_turn->request_id);
+      snprintf(
+          abort_conversation_id,
+          sizeof(abort_conversation_id),
+          "%s",
+          p_turn->conversation_id);
+    }
+    pthread_mutex_unlock(&g_pending_mutex);
+    if (should_abort)
+      Inference_Bridge_Abort_Turn(
+          g_inference_bridge, abort_request_id, abort_conversation_id);
+    return;
+  }
+
   size_t event_text_length =
       strlen(p_event->delta ? p_event->delta : "") +
       strlen(p_event->content ? p_event->content : "") +
--- a/mrjunejune/inference/BUILD	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/inference/BUILD	Wed Aug 05 20:38:32 2026 -0700
@@ -46,6 +46,13 @@
   visibility = ["//mrjunejune:__pkg__"],
 )
 
+py_binary(
+  name = "mock_sidecar",
+  srcs = ["mock_sidecar.py"],
+  data = ["mock_responses.json"],
+  visibility = ["//mrjunejune:__pkg__"],
+)
+
 python_zip_file(
   name = "copilot_sidecar_zip",
   binary = ":copilot_sidecar",
@@ -100,6 +107,16 @@
 )
 
 py_test(
+  name = "mock_sidecar_test",
+  srcs = [
+    "mock_sidecar.py",
+    "mock_sidecar_test.py",
+  ],
+  data = ["mock_responses.json"],
+  main = "mock_sidecar_test.py",
+)
+
+py_test(
   name = "sdk_runtime_test",
   srcs = ["sdk_runtime_test.py"],
   args = ["$(rootpath @copilot_cli_linux_x86_64//:copilot)"],
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/inference/mock_responses.json	Wed Aug 05 20:38:32 2026 -0700
@@ -0,0 +1,126 @@
+{
+  "delay_ms": 55,
+  "fallback": "!response",
+  "commands": {
+    "!hello": {
+      "description": "Short greeting with exact chunks.",
+      "events": [
+        {
+          "type": "assistant.delta",
+          "delta": "Hello, "
+        },
+        {
+          "type": "assistant.delta",
+          "delta": "traveler."
+        },
+        {
+          "type": "assistant.completed",
+          "content": "Hello, traveler."
+        }
+      ]
+    },
+    "!response": {
+      "description": "Long response for typing, wrapping, and scrolling.",
+      "events": [
+        {
+          "type": "assistant.delta",
+          "delta": "The neon trail stretches across the terminal while "
+        },
+        {
+          "type": "assistant.delta",
+          "delta": "each word arrives through the real Seobeo SSE path. "
+        },
+        {
+          "type": "assistant.delta",
+          "delta": "Edit mock_responses.json to change this sequence."
+        },
+        {
+          "type": "assistant.completed",
+          "content": "The neon trail stretches across the terminal while each word arrives through the real Seobeo SSE path. Edit mock_responses.json to change this sequence."
+        }
+      ]
+    },
+    "!tool": {
+      "description": "Example tool lifecycle for future tool-call UI.",
+      "events": [
+        {
+          "type": "tool.started",
+          "tool": {
+            "id": "mock-tool-1",
+            "name": "site_search",
+            "arguments": {
+              "query": "JRPG design notes"
+            }
+          }
+        },
+        {
+          "type": "tool.completed",
+          "delay_ms": 350,
+          "tool": {
+            "id": "mock-tool-1",
+            "name": "site_search",
+            "result": {
+              "matches": 3
+            }
+          }
+        },
+        {
+          "type": "assistant.delta",
+          "delta": "The mock search found three matching entries."
+        },
+        {
+          "type": "assistant.completed",
+          "content": "The mock search found three matching entries."
+        }
+      ]
+    },
+    "!plan": {
+      "description": "Example plan event for a future plan-view component.",
+      "events": [
+        {
+          "type": "plan.updated",
+          "plan": {
+            "title": "Tune the JRPG chat",
+            "steps": [
+              {
+                "id": "layout",
+                "label": "Adjust layout",
+                "status": "completed"
+              },
+              {
+                "id": "motion",
+                "label": "Review stream motion",
+                "status": "in_progress"
+              },
+              {
+                "id": "details",
+                "label": "Polish tool details",
+                "status": "pending"
+              }
+            ]
+          }
+        },
+        {
+          "type": "assistant.delta",
+          "delta": "I updated the mock implementation plan."
+        },
+        {
+          "type": "assistant.completed",
+          "content": "I updated the mock implementation plan."
+        }
+      ]
+    },
+    "!error": {
+      "description": "Failed turn treatment.",
+      "events": [
+        {
+          "type": "turn.error",
+          "error": {
+            "code": "mock_failure",
+            "message": "The mock crystal was asked to fail."
+          }
+        }
+      ]
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/inference/mock_sidecar.py	Wed Aug 05 20:38:32 2026 -0700
@@ -0,0 +1,526 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+import pathlib
+import re
+import sys
+from dataclasses import dataclass
+from typing import Any, Awaitable, Callable
+
+
+JsonObject = dict[str, Any]
+Emit = Callable[[JsonObject], Awaitable[None]]
+COMMAND_PATTERN = re.compile(r"^![a-z][a-z0-9_-]*$")
+EVENT_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
+RESERVED_EVENT_TYPES = {"bridge.closed", "ready", "turn.accepted", "turn.done"}
+RESERVED_EVENT_FIELDS = {
+    "conversation_id",
+    "mock",
+    "mock_command",
+    "request_id",
+}
+MAX_DELAY_MS = 60_000
+
+
+@dataclass(frozen=True)
+class MockEvent:
+    event_type: str
+    delay_ms: int | None
+    payload: JsonObject
+
+
+@dataclass(frozen=True)
+class MockCommand:
+    name: str
+    description: str
+    events: tuple[MockEvent, ...]
+    failed: bool
+
+
+@dataclass(frozen=True)
+class MockConfig:
+    delay_ms: int
+    fallback: str
+    commands: dict[str, MockCommand]
+
+    def select(self, prompt: str) -> MockCommand:
+        first_token = prompt.strip().split(maxsplit=1)[0].lower()
+        return self.commands.get(first_token, self.commands[self.fallback])
+
+
+@dataclass
+class MockTurn:
+    request_id: str
+    task: asyncio.Task[None]
+
+
+def _require_non_negative_integer(value: Any, description: str) -> int:
+    if not isinstance(value, int) or isinstance(value, bool) or value < 0:
+        raise ValueError(f"{description} must be a non-negative integer")
+    if value > MAX_DELAY_MS:
+        raise ValueError(f"{description} must not exceed {MAX_DELAY_MS}")
+    return value
+
+
+def _validate_event(command_name: str, index: int, raw: Any) -> MockEvent:
+    if not isinstance(raw, dict):
+        raise ValueError(f"{command_name} event {index} must be an object")
+    event_type = raw.get("type")
+    if not isinstance(event_type, str) or not EVENT_PATTERN.fullmatch(event_type):
+        raise ValueError(
+            f"{command_name} event {index} requires a safe event type"
+        )
+    if event_type in RESERVED_EVENT_TYPES:
+        raise ValueError(
+            f"{command_name} cannot script lifecycle event {event_type}"
+        )
+    reserved = set(raw) & RESERVED_EVENT_FIELDS
+    if reserved:
+        raise ValueError(
+            f"{command_name} event {index} uses reserved fields: "
+            f"{', '.join(sorted(reserved))}"
+        )
+    delay_ms = raw.get("delay_ms")
+    if delay_ms is not None:
+        delay_ms = _require_non_negative_integer(
+            delay_ms,
+            f"{command_name} event {index} delay_ms",
+        )
+    payload = {
+        key: value
+        for key, value in raw.items()
+        if key not in {"delay_ms", "type"}
+    }
+    if event_type == "assistant.delta":
+        if not isinstance(payload.get("delta"), str) or not payload["delta"]:
+            raise ValueError(
+                f"{command_name} assistant.delta requires non-empty delta"
+            )
+    elif event_type == "assistant.completed":
+        if not isinstance(payload.get("content"), str):
+            raise ValueError(
+                f"{command_name} assistant.completed requires content"
+            )
+    elif event_type == "assistant.usage":
+        usage = payload.get("usage")
+        if not isinstance(usage, dict):
+            raise ValueError(f"{command_name} assistant.usage requires usage")
+        for key in ("input_tokens", "output_tokens"):
+            _require_non_negative_integer(
+                usage.get(key),
+                f"{command_name} assistant.usage {key}",
+            )
+    elif event_type == "turn.error":
+        error = payload.get("error")
+        if not isinstance(error, dict):
+            raise ValueError(f"{command_name} turn.error requires error")
+        if (
+            not isinstance(error.get("code"), str)
+            or not error["code"]
+            or not isinstance(error.get("message"), str)
+            or not error["message"]
+        ):
+            raise ValueError(
+                f"{command_name} turn.error requires error code and message"
+            )
+    return MockEvent(event_type, delay_ms, payload)
+
+
+def _validate_command(name: str, raw: Any) -> MockCommand:
+    if not COMMAND_PATTERN.fullmatch(name):
+        raise ValueError(f"invalid mock command: {name}")
+    if not isinstance(raw, dict):
+        raise ValueError(f"{name} must contain an object")
+    unknown = set(raw) - {"description", "events"}
+    if unknown:
+        raise ValueError(
+            f"{name} has unknown keys: {', '.join(sorted(unknown))}"
+        )
+    description = raw.get("description", "")
+    if not isinstance(description, str):
+        raise ValueError(f"{name} description must be a string")
+    raw_events = raw.get("events")
+    if not isinstance(raw_events, list) or not raw_events:
+        raise ValueError(f"{name} events must be a non-empty array")
+    if len(raw_events) > 256:
+        raise ValueError(f"{name} has too many events")
+    events = tuple(
+        _validate_event(name, index, event)
+        for index, event in enumerate(raw_events)
+    )
+    errors = [event for event in events if event.event_type == "turn.error"]
+    completed = [
+        event for event in events if event.event_type == "assistant.completed"
+    ]
+    if errors:
+        if len(errors) != 1 or errors[0] is not events[-1]:
+            raise ValueError(f"{name} turn.error must be the final scripted event")
+        if completed:
+            raise ValueError(f"{name} cannot complete and fail the same turn")
+    elif len(completed) != 1:
+        raise ValueError(f"{name} requires one assistant.completed event")
+
+    deltas = "".join(
+        event.payload["delta"]
+        for event in events
+        if event.event_type == "assistant.delta"
+    )
+    if completed:
+        completed_index = events.index(completed[0])
+        if any(
+            event.event_type == "assistant.delta"
+            for event in events[completed_index + 1:]
+        ):
+            raise ValueError(
+                f"{name} assistant.completed must follow all deltas"
+            )
+    if completed and deltas and deltas != completed[0].payload["content"]:
+        raise ValueError(f"{name} deltas must join to completed content")
+    return MockCommand(name, description, events, failed=bool(errors))
+
+
+def load_mock_config(path: str | os.PathLike[str]) -> MockConfig:
+    with open(path, encoding="utf-8") as config_file:
+        payload = json.load(config_file)
+    if not isinstance(payload, dict):
+        raise ValueError("mock response file must contain a JSON object")
+    unknown = set(payload) - {"commands", "delay_ms", "fallback"}
+    if unknown:
+        raise ValueError(f"unknown mock config keys: {', '.join(sorted(unknown))}")
+    delay_ms = _require_non_negative_integer(
+        payload.get("delay_ms", 55),
+        "delay_ms",
+    )
+    fallback = payload.get("fallback")
+    if not isinstance(fallback, str) or not COMMAND_PATTERN.fullmatch(fallback):
+        raise ValueError("fallback must be a !command")
+    raw_commands = payload.get("commands")
+    if not isinstance(raw_commands, dict) or not raw_commands:
+        raise ValueError("commands must be a non-empty object")
+    commands = {
+        name.lower(): _validate_command(name.lower(), raw)
+        for name, raw in raw_commands.items()
+    }
+    if fallback.lower() not in commands:
+        raise ValueError(f"fallback command is not defined: {fallback}")
+    return MockConfig(delay_ms, fallback.lower(), commands)
+
+
+class MockSidecar:
+    def __init__(self, emit: Emit, config: MockConfig) -> None:
+        self._emit = emit
+        self._config = config
+        self._active: dict[str, MockTurn] = {}
+        self.shutting_down = False
+
+    async def announce_ready(self) -> None:
+        await self._send("ready", None, None, status="ok", mock=True)
+
+    async def dispatch(self, command: JsonObject) -> None:
+        command_name = command.get("command")
+        request_id = command.get("request_id")
+        conversation_id = command.get("conversation_id")
+        if not isinstance(request_id, str) or not request_id:
+            await self._fail(
+                request_id if isinstance(request_id, str) else None,
+                conversation_id if isinstance(conversation_id, str) else None,
+                "invalid_request",
+                "request_id is required",
+            )
+            return
+        if command_name == "shutdown":
+            await self._shutdown(request_id, conversation_id)
+            return
+        if command_name == "health":
+            await self._send(
+                "ready",
+                request_id,
+                conversation_id if isinstance(conversation_id, str) else None,
+                status="ok",
+                mock=True,
+            )
+            return
+        if not isinstance(conversation_id, str) or not conversation_id:
+            await self._fail(
+                request_id,
+                None,
+                "invalid_request",
+                "conversation_id is required",
+            )
+            return
+        if command_name == "turn.start":
+            prompt = command.get("prompt")
+            if not isinstance(prompt, str) or not prompt:
+                await self._fail(
+                    request_id,
+                    conversation_id,
+                    "invalid_request",
+                    "prompt is required",
+                )
+                return
+            await self._start_turn(request_id, conversation_id, prompt)
+        elif command_name == "turn.abort":
+            await self._abort_turn(request_id, conversation_id)
+        elif command_name == "conversation.delete":
+            await self._delete_conversation(request_id, conversation_id)
+        else:
+            await self._fail(
+                request_id,
+                conversation_id,
+                "unknown_command",
+                f"unsupported command: {command_name!r}",
+            )
+
+    async def wait_for_idle(self) -> None:
+        tasks = [turn.task for turn in self._active.values()]
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+
+    async def _start_turn(
+        self,
+        request_id: str,
+        conversation_id: str,
+        prompt: str,
+    ) -> None:
+        if conversation_id in self._active:
+            await self._fail(
+                request_id,
+                conversation_id,
+                "turn_in_progress",
+                "the conversation already has an active turn",
+            )
+            return
+        scripted_command = self._config.select(prompt)
+        await self._send(
+            "turn.accepted",
+            request_id,
+            conversation_id,
+            mock=True,
+            mock_command=scripted_command.name,
+        )
+        task = asyncio.create_task(
+            self._stream_turn(request_id, conversation_id, scripted_command)
+        )
+        self._active[conversation_id] = MockTurn(request_id, task)
+
+    async def _stream_turn(
+        self,
+        request_id: str,
+        conversation_id: str,
+        scripted_command: MockCommand,
+    ) -> None:
+        try:
+            for event in scripted_command.events:
+                delay = (
+                    self._config.delay_ms
+                    if event.delay_ms is None
+                    else event.delay_ms
+                )
+                await asyncio.sleep(delay / 1000)
+                await self._send(
+                    event.event_type,
+                    request_id,
+                    conversation_id,
+                    **event.payload,
+                    mock=True,
+                    mock_command=scripted_command.name,
+                )
+            await self._send(
+                "turn.done",
+                request_id,
+                conversation_id,
+                failed=scripted_command.failed,
+                mock=True,
+                mock_command=scripted_command.name,
+            )
+        except asyncio.CancelledError:
+            await self._send(
+                "turn.done",
+                request_id,
+                conversation_id,
+                aborted=True,
+                mock=True,
+                mock_command=scripted_command.name,
+            )
+        except Exception as error:
+            await self._send(
+                "turn.error",
+                request_id,
+                conversation_id,
+                error={
+                    "code": "mock_script_failed",
+                    "message": str(error),
+                },
+                mock_command=scripted_command.name,
+            )
+            await self._send(
+                "turn.done",
+                request_id,
+                conversation_id,
+                failed=True,
+                mock=True,
+                mock_command=scripted_command.name,
+            )
+        finally:
+            active = self._active.get(conversation_id)
+            if active is not None and active.request_id == request_id:
+                self._active.pop(conversation_id, None)
+
+    async def _abort_turn(
+        self,
+        request_id: str,
+        conversation_id: str,
+    ) -> None:
+        active = self._active.get(conversation_id)
+        if active is None:
+            await self._fail(
+                request_id,
+                conversation_id,
+                "no_active_turn",
+                "no active turn to abort",
+            )
+            return
+        await self._send(
+            "turn.accepted",
+            request_id,
+            conversation_id,
+            action="abort",
+            target_request_id=active.request_id,
+            mock=True,
+        )
+        active.task.cancel()
+        await asyncio.gather(active.task, return_exceptions=True)
+        await self._send(
+            "turn.done",
+            request_id,
+            conversation_id,
+            action="abort",
+            target_request_id=active.request_id,
+            mock=True,
+        )
+
+    async def _delete_conversation(
+        self,
+        request_id: str,
+        conversation_id: str,
+    ) -> None:
+        active = self._active.get(conversation_id)
+        if active is not None:
+            active.task.cancel()
+            await asyncio.gather(active.task, return_exceptions=True)
+        await self._send(
+            "turn.done",
+            request_id,
+            conversation_id,
+            action="conversation.delete",
+            mock=True,
+        )
+
+    async def _shutdown(
+        self,
+        request_id: str,
+        conversation_id: Any,
+    ) -> None:
+        self.shutting_down = True
+        tasks = [turn.task for turn in self._active.values()]
+        for task in tasks:
+            task.cancel()
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+        await self._send(
+            "turn.done",
+            request_id,
+            conversation_id if isinstance(conversation_id, str) else None,
+            action="shutdown",
+            mock=True,
+        )
+
+    async def _fail(
+        self,
+        request_id: str | None,
+        conversation_id: str | None,
+        code: str,
+        message: str,
+    ) -> None:
+        await self._send(
+            "turn.error",
+            request_id,
+            conversation_id,
+            error={"code": code, "message": message},
+        )
+        await self._send(
+            "turn.done",
+            request_id,
+            conversation_id,
+            failed=True,
+            mock=True,
+        )
+
+    async def _send(
+        self,
+        event_type: str,
+        request_id: str | None,
+        conversation_id: str | None,
+        **fields: Any,
+    ) -> None:
+        await self._emit(
+            {
+                "type": event_type,
+                "request_id": request_id,
+                "conversation_id": conversation_id,
+                **fields,
+            }
+        )
+
+
+async def run(config: MockConfig) -> None:
+    async def emit(payload: JsonObject) -> None:
+        sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
+        sys.stdout.flush()
+
+    sidecar = MockSidecar(emit, config)
+    await sidecar.announce_ready()
+    while not sidecar.shutting_down:
+        line = await asyncio.to_thread(sys.stdin.readline)
+        if not line:
+            break
+        try:
+            command = json.loads(line)
+            if not isinstance(command, dict):
+                raise ValueError("command must be a JSON object")
+        except (json.JSONDecodeError, ValueError) as error:
+            await sidecar._fail(None, None, "invalid_json", str(error))
+            continue
+        await sidecar.dispatch(command)
+    await sidecar.wait_for_idle()
+
+
+def main() -> None:
+    packaged_responses = pathlib.Path(__file__).with_name("mock_responses.json")
+    parser = argparse.ArgumentParser(description="Scripted JRPG mock sidecar")
+    parser.add_argument("copilot_cli", nargs="?")
+    parser.add_argument("--responses", default=str(packaged_responses))
+    args = parser.parse_args()
+    responses_path = os.environ.get("MRJUNEJUNE_MOCK_RESPONSES") or args.responses
+    config = load_mock_config(responses_path)
+    delay_override = os.environ.get("MRJUNEJUNE_MOCK_DELAY_MS")
+    if delay_override is not None:
+        try:
+            delay_ms = int(delay_override)
+        except ValueError as error:
+            raise ValueError(
+                "MRJUNEJUNE_MOCK_DELAY_MS must be a non-negative integer"
+            ) from error
+        delay_ms = _require_non_negative_integer(
+            delay_ms,
+            "MRJUNEJUNE_MOCK_DELAY_MS",
+        )
+        config = MockConfig(delay_ms, config.fallback, config.commands)
+    asyncio.run(run(config))
+
+
+if __name__ == "__main__":
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/inference/mock_sidecar_test.py	Wed Aug 05 20:38:32 2026 -0700
@@ -0,0 +1,228 @@
+import asyncio
+import json
+import pathlib
+import tempfile
+import unittest
+
+from mrjunejune.inference.mock_sidecar import (
+    MockConfig,
+    MockSidecar,
+    load_mock_config,
+)
+
+
+class MockSidecarTest(unittest.IsolatedAsyncioTestCase):
+    async def asyncSetUp(self):
+        self.events = []
+
+        async def emit(payload):
+            self.events.append(payload)
+
+        packaged = load_mock_config(
+            pathlib.Path(__file__).with_name("mock_responses.json")
+        )
+        self.config = MockConfig(
+            delay_ms=0,
+            fallback=packaged.fallback,
+            commands=packaged.commands,
+        )
+        self.sidecar = MockSidecar(emit, self.config)
+
+    def test_packaged_commands_are_selectable(self):
+        self.assertEqual(self.config.select("!hello").name, "!hello")
+        self.assertEqual(self.config.select("!response please").name, "!response")
+        self.assertEqual(self.config.select("ordinary prompt").name, "!response")
+        self.assertEqual(self.config.select("!tool").name, "!tool")
+        self.assertEqual(self.config.select("!plan").name, "!plan")
+
+    def test_response_file_rejects_invalid_scripts(self):
+        invalid_configs = (
+            {
+                "fallback": "!hello",
+                "commands": {
+                    "!hello": {
+                        "events": [
+                            {
+                                "type": "assistant.completed",
+                                "content": "Hello",
+                            }
+                        ],
+                        "unknown": True,
+                    }
+                },
+            },
+            {
+                "fallback": "hello",
+                "commands": {
+                    "hello": {
+                        "events": [
+                            {
+                                "type": "assistant.completed",
+                                "content": "Hello",
+                            }
+                        ]
+                    }
+                },
+            },
+            {
+                "fallback": "!hello",
+                "commands": {
+                    "!hello": {
+                        "events": [
+                            {
+                                "type": "assistant.delta",
+                                "delta": "Different",
+                            },
+                            {
+                                "type": "assistant.completed",
+                                "content": "Hello",
+                            },
+                        ]
+                    }
+                },
+            },
+            {
+                "delay_ms": 60001,
+                "fallback": "!hello",
+                "commands": {
+                    "!hello": {
+                        "events": [
+                            {
+                                "type": "assistant.completed",
+                                "content": "Hello",
+                            }
+                        ]
+                    }
+                },
+            },
+            {
+                "fallback": "!hello",
+                "commands": {
+                    "!hello": {
+                        "events": [
+                            {
+                                "type": "assistant.completed",
+                                "content": "Hello",
+                            },
+                            {
+                                "type": "assistant.delta",
+                                "delta": "Hello",
+                            },
+                        ]
+                    }
+                },
+            },
+        )
+        for payload in invalid_configs:
+            with self.subTest(payload=payload):
+                with tempfile.TemporaryDirectory() as root:
+                    path = pathlib.Path(root) / "responses.json"
+                    path.write_text(json.dumps(payload), encoding="utf-8")
+                    with self.assertRaises(ValueError):
+                        load_mock_config(path)
+
+    async def test_hello_command_streams_scripted_events(self):
+        await self.sidecar.dispatch(
+            {
+                "command": "turn.start",
+                "request_id": "request-1",
+                "conversation_id": "conversation-1",
+                "prompt": "!hello",
+            }
+        )
+        await self.sidecar.wait_for_idle()
+
+        self.assertEqual(
+            [event["type"] for event in self.events],
+            [
+                "turn.accepted",
+                "assistant.delta",
+                "assistant.delta",
+                "assistant.completed",
+                "turn.done",
+            ],
+        )
+        deltas = "".join(
+            event["delta"]
+            for event in self.events
+            if event["type"] == "assistant.delta"
+        )
+        completed = next(
+            event["content"]
+            for event in self.events
+            if event["type"] == "assistant.completed"
+        )
+        self.assertEqual(deltas, completed)
+        self.assertEqual(self.events[-1]["mock_command"], "!hello")
+
+    async def test_tool_command_preserves_custom_event_payloads(self):
+        await self.sidecar.dispatch(
+            {
+                "command": "turn.start",
+                "request_id": "request-2",
+                "conversation_id": "conversation-2",
+                "prompt": "!tool",
+            }
+        )
+        await self.sidecar.wait_for_idle()
+
+        started = next(
+            event for event in self.events if event["type"] == "tool.started"
+        )
+        completed = next(
+            event for event in self.events if event["type"] == "tool.completed"
+        )
+        self.assertEqual(started["tool"]["name"], "site_search")
+        self.assertEqual(completed["tool"]["result"]["matches"], 3)
+
+    async def test_error_command_emits_failed_turn(self):
+        await self.sidecar.dispatch(
+            {
+                "command": "turn.start",
+                "request_id": "request-3",
+                "conversation_id": "conversation-3",
+                "prompt": "!error",
+            }
+        )
+        await self.sidecar.wait_for_idle()
+
+        self.assertEqual(
+            [event["type"] for event in self.events],
+            ["turn.accepted", "turn.error", "turn.done"],
+        )
+        self.assertTrue(self.events[-1]["failed"])
+
+    async def test_abort_stops_active_command(self):
+        self.sidecar._config = MockConfig(
+            delay_ms=50,
+            fallback=self.config.fallback,
+            commands=self.config.commands,
+        )
+        await self.sidecar.dispatch(
+            {
+                "command": "turn.start",
+                "request_id": "request-4",
+                "conversation_id": "conversation-4",
+                "prompt": "!response",
+            }
+        )
+        await asyncio.sleep(0.01)
+        await self.sidecar.dispatch(
+            {
+                "command": "turn.abort",
+                "request_id": "abort-4",
+                "conversation_id": "conversation-4",
+            }
+        )
+
+        target_done = next(
+            event
+            for event in self.events
+            if event["type"] == "turn.done"
+            and event["request_id"] == "request-4"
+        )
+        self.assertTrue(target_done["aborted"])
+
+
+if __name__ == "__main__":
+    unittest.main()
--- a/mrjunejune/inference_bridge.c	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/inference_bridge.c	Wed Aug 05 20:38:32 2026 -0700
@@ -99,6 +99,8 @@
     .conversation_id = Inference_JSON_String(object, "conversation_id"),
     .delta = Inference_JSON_String(object, "delta"),
     .content = Inference_JSON_String(object, "content"),
+    .raw_json = line,
+    .raw_json_length = length,
     .failed = Inference_JSON_Boolean(object, "failed"),
     .aborted = Inference_JSON_Boolean(object, "aborted"),
   };
@@ -146,7 +148,10 @@
            (line[length - 1] == '\n' || line[length - 1] == '\r'))
       length--;
     if (length > 0)
+    {
+      line[length] = '\0';
       Inference_Bridge_Handle_Line(p_bridge, line, length);
+    }
   }
   free(line);
   atomic_store(&p_bridge->ready, FALSE);
--- a/mrjunejune/inference_bridge.h	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/inference_bridge.h	Wed Aug 05 20:38:32 2026 -0700
@@ -13,6 +13,8 @@
   const char *content;
   const char *error_code;
   const char *error_message;
+  const char *raw_json;
+  size_t raw_json_length;
   int64 input_tokens;
   int64 output_tokens;
   boolean failed;
--- a/mrjunejune/inference_stack.sh	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/inference_stack.sh	Wed Aug 05 20:38:32 2026 -0700
@@ -8,24 +8,27 @@
 litellm_zip="$(realpath "$5")"
 litellm_config="$(realpath "$6")"
 python="$(realpath "$7")"
+mock_sidecar="${8:-}"
+if (( $# >= 8 )); then
+  shift 8
+else
+  shift 7
+fi
 
-: "${LITELLM_MASTER_KEY:?LITELLM_MASTER_KEY must be set}"
+mode=live
+if [[ "${1:-}" == "--mock" ]]; then
+  mode=mock
+  shift
+fi
+if (( $# != 0 )); then
+  echo "Usage: bazel run //mrjunejune:run_inference_stack -- [--mock]" >&2
+  exit 2
+fi
 
 litellm_host="${LITELLM_HOST:-127.0.0.1}"
 litellm_port="${LITELLM_PORT:-4000}"
 default_state_root="${XDG_STATE_HOME:-$HOME/.local/state}/mrjunejune/inference"
 state_root="${MRJUNEJUNE_INFERENCE_STATE:-$default_state_root}"
-token_dir="${GITHUB_COPILOT_TOKEN_DIR:-$state_root/litellm-copilot}"
-mkdir -p "$state_root/copilot" "$token_dir"
-chmod 700 "$state_root" "$state_root/copilot" "$token_dir"
-
-export GITHUB_COPILOT_TOKEN_DIR="$token_dir"
-if [[ ! -s "$GITHUB_COPILOT_TOKEN_DIR/access-token" ]]; then
-  echo "GitHub Copilot is not authenticated for LiteLLM." >&2
-  echo "Run: bazel run //mrjunejune/inference:litellm_proxy -- --authenticate --token-dir \"$GITHUB_COPILOT_TOKEN_DIR\"" >&2
-  exit 1
-fi
-
 litellm_pid=
 server_pid=
 
@@ -51,47 +54,78 @@
 }
 trap cleanup EXIT INT TERM
 
-setsid "$python" "$litellm_zip" \
-  --config "$litellm_config" \
-  --host "$litellm_host" \
-  --port "$litellm_port" &
-litellm_pid=$!
-
-for _ in $(seq 1 150); do
-  if ! kill -0 "$litellm_pid" 2>/dev/null; then
-    echo "LiteLLM exited before becoming ready" >&2
+if [[ "$mode" == "mock" ]]; then
+  if [[ -z "$mock_sidecar" ]]; then
+    echo "Mock sidecar is unavailable in this runtime" >&2
     exit 1
   fi
-  if curl --fail --silent \
+  mock_state="${MRJUNEJUNE_MOCK_STATE:-$state_root/mock}"
+  mkdir -p "$mock_state"
+  chmod 700 "$mock_state"
+  export MRJUNEJUNE_DB_PATH="${MRJUNEJUNE_DB_PATH:-$mock_state/conversations.db}"
+  if [[ "$mock_sidecar" == /* ]]; then
+    export MRJUNEJUNE_INFERENCE_SIDECAR_PATH="$mock_sidecar"
+  else
+    export MRJUNEJUNE_INFERENCE_SIDECAR_PATH="$PWD/$mock_sidecar"
+  fi
+  export MRJUNEJUNE_COPILOT_CLI_PATH="$MRJUNEJUNE_INFERENCE_SIDECAR_PATH"
+  export MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE=1
+  echo "Starting mock inference at http://127.0.0.1:${MRJUNEJUNE_PORT:-6969}/jrpg"
+else
+  : "${LITELLM_MASTER_KEY:?LITELLM_MASTER_KEY must be set}"
+  token_dir="${GITHUB_COPILOT_TOKEN_DIR:-$state_root/litellm-copilot}"
+  mkdir -p "$state_root/copilot" "$token_dir"
+  chmod 700 "$state_root" "$state_root/copilot" "$token_dir"
+
+  export GITHUB_COPILOT_TOKEN_DIR="$token_dir"
+  if [[ ! -s "$GITHUB_COPILOT_TOKEN_DIR/access-token" ]]; then
+    echo "GitHub Copilot is not authenticated for LiteLLM." >&2
+    echo "Run: bazel run //mrjunejune/inference:litellm_proxy -- --authenticate --token-dir \"$GITHUB_COPILOT_TOKEN_DIR\"" >&2
+    exit 1
+  fi
+
+  setsid "$python" "$litellm_zip" \
+    --config "$litellm_config" \
+    --host "$litellm_host" \
+    --port "$litellm_port" &
+  litellm_pid=$!
+
+  for _ in $(seq 1 150); do
+    if ! kill -0 "$litellm_pid" 2>/dev/null; then
+      echo "LiteLLM exited before becoming ready" >&2
+      exit 1
+    fi
+    if curl --fail --silent \
+        --connect-timeout 1 \
+        --max-time 2 \
+        "http://${litellm_host}:${litellm_port}/health/readiness" \
+        >/dev/null; then
+      break
+    fi
+    sleep 0.1
+  done
+
+  if ! curl --fail --silent \
       --connect-timeout 1 \
       --max-time 2 \
       "http://${litellm_host}:${litellm_port}/health/readiness" \
       >/dev/null; then
-    break
+    echo "LiteLLM readiness timed out" >&2
+    exit 1
   fi
-  sleep 0.1
-done
 
-if ! curl --fail --silent \
-    --connect-timeout 1 \
-    --max-time 2 \
-    "http://${litellm_host}:${litellm_port}/health/readiness" \
-    >/dev/null; then
-  echo "LiteLLM readiness timed out" >&2
-  exit 1
+  export MRJUNEJUNE_PYTHON_PATH="$python"
+  export MRJUNEJUNE_SIDECAR_ZIP="$sidecar_zip"
+  export MRJUNEJUNE_INFERENCE_SIDECAR_PATH="$sidecar_launcher"
+  export MRJUNEJUNE_COPILOT_CLI_PATH="$copilot_cli"
+  export COPILOT_SIDECAR_HOME="$state_root/copilot"
+  export LITELLM_BASE_URL="http://${litellm_host}:${litellm_port}/v1"
+  export LITELLM_MODEL="${LITELLM_MODEL:-jrpg-copilot}"
+  export LITELLM_WIRE_API="${LITELLM_WIRE_API:-completions}"
+  export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
+  export MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE="${MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE:-0}"
 fi
 
-export MRJUNEJUNE_PYTHON_PATH="$python"
-export MRJUNEJUNE_SIDECAR_ZIP="$sidecar_zip"
-export MRJUNEJUNE_INFERENCE_SIDECAR_PATH="$sidecar_launcher"
-export MRJUNEJUNE_COPILOT_CLI_PATH="$copilot_cli"
-export COPILOT_SIDECAR_HOME="$state_root/copilot"
-export LITELLM_BASE_URL="http://${litellm_host}:${litellm_port}/v1"
-export LITELLM_MODEL="${LITELLM_MODEL:-jrpg-copilot}"
-export LITELLM_WIRE_API="${LITELLM_WIRE_API:-completions}"
-export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
-export MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE="${MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE:-0}"
-
 setsid "$server" &
 server_pid=$!
 while true; do
@@ -102,7 +136,7 @@
     set -e
     exit "$server_status"
   fi
-  if ! kill -0 "$litellm_pid" 2>/dev/null; then
+  if [[ -n "$litellm_pid" ]] && ! kill -0 "$litellm_pid" 2>/dev/null; then
     wait "$litellm_pid" 2>/dev/null || true
     echo "LiteLLM exited while the public server was running" >&2
     exit 1
--- a/mrjunejune/src/jrpg/index.html	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/src/jrpg/index.html	Wed Aug 05 20:38:32 2026 -0700
@@ -18,7 +18,6 @@
 
             <mjj-jrpg-chat aria-labelledby="conversation-title">
               <div class="jrpg-panel-heading">
-                <span aria-hidden="true">QUEST 01</span>
                 <h1 id="conversation-title">Conversations</h1>
               </div>
               <zen-message-scroller>
@@ -27,23 +26,35 @@
                   data-zen-viewport
                   role="log"
                   aria-live="polite"
-                  aria-relevant="additions"
+                  aria-relevant="additions text"
                 >
                   <ol class="jrpg-messages" data-messages>
-                    <li class="jrpg-message" data-speaker="Epi">
+                    <li class="jrpg-message" data-speaker="Epi" data-turn-group="start">
                       <strong>Epi</strong>
                       <p>Welcome, traveler. Pick a destination or tell me what you are looking for.</p>
                     </li>
-                    <li class="jrpg-message" data-speaker="System">
+                    <li class="jrpg-message" data-speaker="System" data-turn-group="start">
                       <strong>System</strong>
                       <p>The Shiba terminal is ready.</p>
                     </li>
                   </ol>
                 </div>
               </zen-message-scroller>
-              <p class="jrpg-next" aria-hidden="true">
-                NEXT <zen-icon name="chevron-right" size="0.9em"></zen-icon>
-              </p>
+              <nav class="jrpg-turn-navigation" aria-label="Conversation turns">
+                <zen-button appearance="plain" size="sm">
+                  <button type="button" data-turn-previous aria-label="Previous turn" disabled>
+                    <zen-icon name="chevron-left"></zen-icon>
+                    Previous
+                  </button>
+                </zen-button>
+                <span data-turn-position aria-live="polite">START</span>
+                <zen-button appearance="plain" size="sm">
+                  <button type="button" data-turn-next aria-label="Next turn" disabled>
+                    Next
+                    <zen-icon name="chevron-right"></zen-icon>
+                  </button>
+                </zen-button>
+              </nav>
             </mjj-jrpg-chat>
           </section>
 
@@ -64,6 +75,20 @@
                   <h2 data-preview-title>Resume</h2>
                 </zen-heading>
                 <p data-preview-copy>Experience, projects, and the systems I have helped build.</p>
+                <ul class="jrpg-work-showcase" data-work-showcase>
+                  <li>
+                    <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/">
+                      <span>Copilot Tasks</span>
+                      <small>Agentic execution</small>
+                    </a>
+                  </li>
+                  <li>
+                    <a href="https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html">
+                      <span>Copilot SuperApp</span>
+                      <small>AI platform</small>
+                    </a>
+                  </li>
+                </ul>
                 <dl class="jrpg-preview-stats">
                   <div>
                     <dt>TYPE</dt>
@@ -82,7 +107,7 @@
                       <zen-icon name="search"></zen-icon>
                     </button>
                   </zen-button>
-                  <dialog aria-labelledby="jrpg-dialog-title">
+                  <dialog class="jrpg-resume-dialog" aria-labelledby="jrpg-dialog-title">
                     <div class="jrpg-dialog-heading">
                       <p>DESTINATION DATA</p>
                       <zen-button appearance="plain" size="md">
@@ -93,12 +118,28 @@
                     </div>
                     <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2>
                     <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p>
-                    <zen-button appearance="plain" size="lg">
-                      <a href="/resume" data-preview-link>
-                        Open destination
-                        <zen-icon name="arrow-right"></zen-icon>
-                      </a>
-                    </zen-button>
+                    <section
+                      class="jrpg-resume-dossier"
+                      data-resume-dossier
+                      aria-label="Resume dossier"
+                    >
+                      <p data-resume-status>Loading resume dossier...</p>
+                      <div data-resume-content></div>
+                    </section>
+                    <div class="jrpg-dialog-actions">
+                      <zen-button appearance="plain" size="lg">
+                        <a href="/resume" data-preview-link>
+                          Open full page
+                          <zen-icon name="arrow-right"></zen-icon>
+                        </a>
+                      </zen-button>
+                      <zen-button appearance="plain" size="lg" data-resume-download>
+                        <a href="/public/resume.pdf">
+                          Download PDF
+                          <zen-icon name="download"></zen-icon>
+                        </a>
+                      </zen-button>
+                    </div>
                   </dialog>
                 </zen-dialog>
               </article>
@@ -118,18 +159,20 @@
                   required
                 ></textarea>
               </zen-field>
-              <zen-button appearance="plain" size="lg">
-                <button type="submit">
-                  Send
-                  <zen-icon name="arrow-right"></zen-icon>
-                </button>
-              </zen-button>
-              <zen-button appearance="plain" size="lg" data-cancel-control hidden>
-                <button type="button" data-cancel>
-                  Cancel
-                  <zen-icon name="close"></zen-icon>
-                </button>
-              </zen-button>
+              <div class="jrpg-composer-actions">
+                <zen-button appearance="plain" size="lg">
+                  <button type="submit">
+                    Send
+                    <zen-icon name="arrow-right"></zen-icon>
+                  </button>
+                </zen-button>
+                <zen-button appearance="plain" size="lg" data-cancel-control hidden>
+                  <button type="button" data-cancel>
+                    Cancel
+                    <zen-icon name="close"></zen-icon>
+                  </button>
+                </zen-button>
+              </div>
               <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p>
             </form>
           </mjj-jrpg-composer>
--- a/mrjunejune/src/jrpg/jrpg.css	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.css	Wed Aug 05 20:38:32 2026 -0700
@@ -177,7 +177,6 @@
   border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
 }
 
-.jrpg-panel-heading span,
 .jrpg-preview-kicker,
 .jrpg-menu-label,
 .jrpg-brand > p,
@@ -194,14 +193,18 @@
   font-size: var(--zenbu-sys-font-size-lg);
 }
 
-zen-message-scroller,
-.jrpg-message-viewport {
+zen-message-scroller {
   display: block;
+  overflow: hidden;
   min-height: 0;
   height: 100%;
 }
 
-.jrpg-message-viewport {
+zen-message-scroller > .jrpg-message-viewport {
+  box-sizing: border-box;
+  height: 100%;
+  min-height: 0;
+  max-height: none;
   overflow-y: auto;
   overflow-anchor: none;
   overscroll-behavior: contain;
@@ -224,6 +227,10 @@
   border-left: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-accent);
 }
 
+.jrpg-message[hidden] {
+  display: none;
+}
+
 .jrpg-message[data-speaker="June"] {
   border-left-color: var(--mjj-jrpg-info);
 }
@@ -236,16 +243,75 @@
 .jrpg-message p {
   margin: 0;
   color: var(--mjj-jrpg-text);
+  white-space: pre-wrap;
+}
+
+.jrpg-message[data-streaming="true"] p::after {
+  display: inline-block;
+  width: 0.65em;
+  height: 1em;
+  margin-left: var(--zenbu-sys-space-icon-label);
+  background: currentColor;
+  content: "";
+  vertical-align: -0.12em;
+  animation: mjj-jrpg-cursor
+    calc(var(--zenbu-sys-motion-duration-layout) * 2)
+    steps(1, end)
+    infinite;
+}
+
+@keyframes mjj-jrpg-cursor {
+  50% {
+    opacity: 0;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .jrpg-message[data-streaming="true"] p::after {
+    animation: none;
+  }
 }
 
-.jrpg-next {
-  display: flex;
+.jrpg-turn-navigation {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) auto;
   align-items: center;
-  justify-content: end;
   gap: var(--zenbu-sys-space-icon-label);
-  margin: 0;
+  padding-top: var(--zenbu-sys-padding-sm);
+  border-top: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+}
+
+.jrpg-turn-navigation zen-button,
+.jrpg-turn-navigation button {
+  width: 100%;
+}
+
+.jrpg-turn-navigation button {
+  justify-content: center;
+  border: var(--zenbu-sys-stroke-width) solid transparent;
+  border-radius: 0;
+  background: transparent;
   color: var(--mjj-jrpg-primary);
   font-size: var(--zenbu-sys-font-size-xs);
+  cursor: pointer;
+}
+
+.jrpg-turn-navigation button:hover:not(:disabled) {
+  border-color: var(--mjj-jrpg-primary);
+  background: var(--mjj-jrpg-surface-raised);
+}
+
+.jrpg-turn-navigation button:disabled {
+  color: var(--mjj-jrpg-text-muted);
+  cursor: not-allowed;
+  opacity: var(--zenbu-sys-opacity-disabled);
+}
+
+.jrpg-turn-navigation [data-turn-position] {
+  justify-self: center;
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+  letter-spacing: 0.08em;
 }
 
 .jrpg-utility {
@@ -299,7 +365,7 @@
 
 .jrpg-preview-panel {
   display: grid;
-  grid-template-rows: auto auto minmax(0, 1fr) auto auto;
+  grid-template-rows: auto auto auto minmax(0, 1fr) auto auto;
   gap: var(--zenbu-sys-space-group);
   height: 100%;
   padding: var(--zenbu-sys-padding-lg);
@@ -327,6 +393,40 @@
   color: var(--mjj-jrpg-text);
 }
 
+.jrpg-work-showcase {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  align-content: start;
+  gap: var(--zenbu-sys-space-control);
+  min-height: 0;
+  margin: 0;
+  padding: 0;
+  overflow-y: auto;
+  list-style: none;
+}
+
+.jrpg-work-showcase a {
+  display: grid;
+  gap: var(--zenbu-sys-space-icon-label);
+  height: 100%;
+  padding: var(--zenbu-sys-padding-sm);
+  border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  background: var(--mjj-jrpg-panel);
+  color: var(--mjj-jrpg-primary);
+  text-decoration: none;
+}
+
+.jrpg-work-showcase a:hover,
+.jrpg-work-showcase a:focus-visible {
+  border-color: var(--mjj-jrpg-accent);
+  background: var(--mjj-jrpg-surface-raised);
+}
+
+.jrpg-work-showcase small {
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
 .jrpg-preview-stats {
   display: grid;
   grid-template-columns: repeat(2, 1fr);
@@ -434,9 +534,133 @@
   margin-bottom: var(--zenbu-sys-space-content);
 }
 
+.jrpg-preview-dialog dialog.jrpg-resume-dialog {
+  width: min(94vw, 72rem);
+  max-height: calc(100dvh - var(--zenbu-sys-space-container));
+  overflow: hidden;
+}
+
+.jrpg-preview-dialog dialog.jrpg-resume-dialog[open] {
+  display: grid;
+  grid-template-rows: auto auto minmax(0, 1fr) auto;
+  gap: var(--zenbu-sys-space-group);
+}
+
+.jrpg-resume-dossier {
+  min-height: 0;
+  padding: var(--zenbu-sys-padding-lg);
+  overflow: auto;
+  border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  background: var(--mjj-jrpg-surface-sunken);
+  scrollbar-color: var(--mjj-jrpg-frame) transparent;
+}
+
+.jrpg-resume-dossier[hidden],
+[data-dialog-copy][hidden],
+[data-resume-status][hidden],
+[data-resume-download][hidden] {
+  display: none;
+}
+
+.jrpg-resume-dossier [data-resume-status] {
+  margin: 0;
+  color: var(--mjj-jrpg-highlight);
+}
+
+.jrpg-resume-dossier [data-resume-content] {
+  display: grid;
+  gap: var(--zenbu-sys-space-group);
+  color: var(--mjj-jrpg-text);
+}
+
+.jrpg-resume-dossier :where(p, ul) {
+  margin-block: 0;
+}
+
+.jrpg-resume-dossier .info {
+  display: grid;
+  gap: var(--zenbu-sys-space-control);
+  padding: var(--zenbu-sys-padding-lg);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-primary);
+  background: var(--mjj-jrpg-panel);
+}
+
+.jrpg-resume-dossier .info > p {
+  color: var(--mjj-jrpg-primary);
+  font-size: var(--zenbu-sys-font-size-xl);
+}
+
+.jrpg-resume-dossier .header-address-style,
+.jrpg-resume-dossier .header-social-style {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--zenbu-sys-space-control);
+  color: var(--mjj-jrpg-text-muted);
+}
+
+.jrpg-resume-dossier .sub-header {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  align-items: center;
+  gap: var(--zenbu-sys-space-control);
+  margin-top: var(--zenbu-sys-space-content);
+}
+
+.jrpg-resume-dossier .sub-header h2 {
+  margin: 0;
+  color: var(--mjj-jrpg-highlight);
+  font-size: var(--zenbu-sys-font-size-lg);
+  text-transform: uppercase;
+}
+
+.jrpg-resume-dossier .sub-header .line {
+  height: var(--zenbu-sys-stroke-width-emphasis);
+  background: var(--mjj-jrpg-accent);
+}
+
+.jrpg-resume-dossier .flex-box {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: var(--zenbu-sys-space-control);
+  padding-top: var(--zenbu-sys-padding-sm);
+  border-top: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+}
+
+.jrpg-resume-dossier .entry-title-style,
+.jrpg-resume-dossier .entry-position-style {
+  color: var(--mjj-jrpg-primary);
+}
+
+.jrpg-resume-dossier .entry-location-style,
+.jrpg-resume-dossier .entry-date-style,
+.jrpg-resume-dossier .skill-type-style {
+  color: var(--mjj-jrpg-text-muted);
+  text-align: right;
+}
+
+.jrpg-resume-dossier .description-style {
+  display: grid;
+  gap: var(--zenbu-sys-space-control);
+  padding-left: var(--zenbu-sys-padding-xl);
+}
+
+.jrpg-resume-dossier a {
+  color: var(--mjj-jrpg-primary);
+  text-decoration-color: var(--mjj-jrpg-accent);
+  text-underline-offset: var(--zenbu-sys-space-icon-label);
+}
+
+.jrpg-dialog-actions {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: var(--zenbu-sys-space-control);
+}
+
 mjj-jrpg-composer {
   grid-area: composer;
   display: block;
+  min-height: 0;
   padding: var(--zenbu-sys-padding-md);
   background: var(--mjj-jrpg-surface);
 }
@@ -444,26 +668,42 @@
 .jrpg-composer {
   display: grid;
   grid-template-columns: minmax(0, 1fr) auto;
+  grid-template-rows: minmax(0, 1fr) auto;
   gap: var(--zenbu-sys-space-control);
+  height: 100%;
+  min-height: 0;
 }
 
-.jrpg-composer zen-field {
+.jrpg-composer zen-field[appearance="plain"] {
   display: grid;
+  grid-column: 1;
+  grid-row: 1;
+  grid-template-rows: auto minmax(0, 1fr);
   gap: var(--zenbu-sys-space-icon-label);
+  min-height: 0;
 }
 
 .jrpg-composer label,
 .jrpg-composer-hint {
   grid-column: 1 / -1;
+  grid-row: 2;
   color: var(--mjj-jrpg-text-muted);
   font-size: var(--zenbu-sys-font-size-xs);
 }
 
+.jrpg-composer zen-field label {
+  grid-column: 1;
+  grid-row: 1;
+}
+
 .jrpg-composer textarea {
   box-sizing: border-box;
+  grid-column: 1;
+  grid-row: 2;
   width: 100%;
+  height: 100%;
   min-height: calc(var(--zenbu-control-height) * 2);
-  resize: vertical;
+  resize: none;
   padding:
     var(--zenbu-control-padding-block)
     var(--zenbu-control-padding-inline);
@@ -483,11 +723,25 @@
   outline-offset: var(--zenbu-sys-focus-offset);
 }
 
+.jrpg-composer-actions {
+  display: flex;
+  grid-column: 2;
+  grid-row: 1;
+  flex-direction: column;
+  justify-content: flex-end;
+  gap: var(--zenbu-sys-space-control);
+}
+
 .jrpg-composer zen-button {
-  align-self: end;
+  width: 100%;
+}
+
+.jrpg-composer zen-button[hidden] {
+  display: none;
 }
 
 .jrpg-composer zen-button > button {
+  width: 100%;
   border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
   border-radius: 0;
   background: var(--mjj-jrpg-primary);
@@ -655,10 +909,39 @@
 
   .jrpg-composer {
     grid-template-columns: 1fr;
+    grid-template-rows:
+      minmax(calc(var(--zenbu-control-height) * 3), 1fr)
+      auto
+      auto;
+  }
+
+  .jrpg-composer-actions {
+    grid-column: 1;
+    grid-row: 2;
+    flex-direction: row;
+  }
+
+  .jrpg-composer-hint {
+    grid-row: 3;
   }
 
-  .jrpg-composer zen-button {
-    width: 100%;
+  .jrpg-composer-actions zen-button {
+    flex: 1;
+  }
+
+  .jrpg-work-showcase,
+  .jrpg-dialog-actions {
+    grid-template-columns: 1fr;
+  }
+
+  .jrpg-resume-dossier .flex-box {
+    display: grid;
+  }
+
+  .jrpg-resume-dossier .entry-location-style,
+  .jrpg-resume-dossier .entry-date-style,
+  .jrpg-resume-dossier .skill-type-style {
+    text-align: left;
   }
 
   .jrpg-composer zen-button > button {
--- a/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 20:38:32 2026 -0700
@@ -20,6 +20,38 @@
     title: "Resume",
     type: "Profile",
     url: "/resume",
+    works: [
+      {
+        detail: "Agentic execution",
+        label: "Copilot Tasks",
+        url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/",
+      },
+      {
+        detail: "AI platform",
+        label: "Copilot SuperApp",
+        url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html",
+      },
+      {
+        detail: "Build 2026",
+        label: "Code",
+        url: "https://news.microsoft.com/build-2026/",
+      },
+      {
+        detail: "Personal agent",
+        label: "Autopilot",
+        url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
+      },
+      {
+        detail: "Ads systems",
+        label: "Meta",
+        url: "https://www.meta.com/",
+      },
+      {
+        detail: "Workspace platform",
+        label: "Google",
+        url: "https://www.google.com/",
+      },
+    ],
   },
   tools: {
     copy: "Small, focused utilities for writing, media, and experimentation.",
@@ -27,6 +59,11 @@
     title: "Tools",
     type: "Utilities",
     url: "/tools",
+    works: [
+      { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" },
+      { detail: "Media", label: "Converter", url: "/tools/file_converter" },
+      { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" },
+    ],
   },
   blog: {
     copy: "Notes from building systems, games, web tools, and curious prototypes.",
@@ -34,10 +71,102 @@
     title: "Blogs",
     type: "Writing",
     url: "/blog",
+    works: [
+      { detail: "Archive", label: "All posts", url: "/blog" },
+    ],
   },
 });
 
 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id";
+const TYPING_INTERVAL_MS = 18;
+
+class StreamingTextAnimator {
+  constructor(render) {
+    this._render = render;
+    this._displayed = "";
+    this._target = "";
+    this._timer = 0;
+    this._waiters = [];
+    this._reduceMotion = window.matchMedia(
+      "(prefers-reduced-motion: reduce)",
+    ).matches;
+  }
+
+  append(text) {
+    if (typeof text !== "string" || !text) return;
+    this._target += text;
+    this._schedule();
+  }
+
+  complete(text) {
+    if (typeof text === "string") {
+      this._target = text;
+      if (!this._target.startsWith(this._displayed)) {
+        let shared = 0;
+        while (
+          shared < this._target.length &&
+          shared < this._displayed.length &&
+          this._target[shared] === this._displayed[shared]
+        ) {
+          shared++;
+        }
+        this._displayed = this._displayed.slice(0, shared);
+        this._render(this._displayed);
+      }
+    }
+    this._schedule();
+  }
+
+  waitForIdle() {
+    if (!this._timer && this._displayed === this._target) {
+      return Promise.resolve();
+    }
+    return new Promise(resolve => {
+      this._waiters.push(resolve);
+    });
+  }
+
+  cancel() {
+    window.clearTimeout(this._timer);
+    this._timer = 0;
+    this._target = this._displayed;
+    this._settle();
+  }
+
+  _schedule() {
+    if (this._reduceMotion) {
+      this._displayed = this._target;
+      this._render(this._displayed);
+      this._settle();
+      return;
+    }
+    if (this._timer || this._displayed === this._target) return;
+    this._timer = window.setTimeout(() => this._tick(), TYPING_INTERVAL_MS);
+  }
+
+  _tick() {
+    this._timer = 0;
+    const remaining = this._target.length - this._displayed.length;
+    if (remaining <= 0) {
+      this._settle();
+      return;
+    }
+    const amount = Math.max(1, Math.ceil(remaining / 80));
+    this._displayed = this._target.slice(
+      0,
+      this._displayed.length + amount,
+    );
+    this._render(this._displayed);
+    if (this._displayed === this._target) this._settle();
+    else this._schedule();
+  }
+
+  _settle() {
+    if (this._timer || this._displayed !== this._target) return;
+    const waiters = this._waiters.splice(0);
+    for (const resolve of waiters) resolve();
+  }
+}
 
 class MjjJrpgCharacter extends HTMLElement {
   static get observedAttributes() {
@@ -66,33 +195,89 @@
   connectedCallback() {
     this._messages = this.querySelector("[data-messages]");
     this._viewport = this.querySelector("[data-zen-viewport]");
+    this._previousButton = this.querySelector("[data-turn-previous]");
+    this._nextButton = this.querySelector("[data-turn-next]");
+    this._position = this.querySelector("[data-turn-position]");
+    this._initialMessages = [...(this._messages?.children || [])].map(
+      item => item.cloneNode(true),
+    );
+    this._turnSequence = 0;
+    this._activeGroup = null;
+    this._onNavigationClick = event => {
+      const button = event.target.closest("button");
+      if (!button || !this.contains(button) || button.disabled) return;
+      if (button === this._previousButton) this._moveTurn(-1);
+      else if (button === this._nextButton) this._moveTurn(1);
+    };
+    this.addEventListener("click", this._onNavigationClick);
+    this._refreshTurnGroups("start");
+  }
+
+  disconnectedCallback() {
+    this.removeEventListener("click", this._onNavigationClick);
   }
 
   appendMessage(speaker, text) {
+    let group = this._activeGroup || "start";
+    if (speaker === "June") {
+      this._turnSequence++;
+      group = `turn-${this._turnSequence}`;
+      this._activeGroup = group;
+    }
+    return this._createMessage(speaker, text, group, true);
+  }
+
+  _createMessage(speaker, text, group, select) {
     if (!this._messages) return;
     const item = document.createElement("li");
     const name = document.createElement("strong");
     const copy = document.createElement("p");
     item.className = "jrpg-message";
     item.dataset.speaker = speaker;
+    item.dataset.turnGroup = group;
     name.textContent = speaker;
     copy.textContent = text;
     item.append(name, copy);
     this._messages.append(item);
-    this.scrollEnd();
+    this._refreshTurnGroups(select ? group : this._currentGroup);
     return item;
   }
 
   replaceMessages(turns) {
     if (!this._messages) return;
     this._messages.replaceChildren();
-    for (const turn of turns) {
-      if (!["user", "assistant"].includes(turn.role)) continue;
-      this.appendMessage(
+    this._turnSequence = 0;
+    this._activeGroup = null;
+    const visibleTurns = turns.filter(turn =>
+      ["user", "assistant"].includes(turn.role)
+    );
+    if (!visibleTurns.length) {
+      this._messages.append(
+        ...this._initialMessages.map(item => item.cloneNode(true)),
+      );
+      this._refreshTurnGroups("start");
+      return;
+    }
+    let group = null;
+    for (const turn of visibleTurns) {
+      if (turn.role === "user" || !group) {
+        this._turnSequence++;
+        group = `turn-${this._turnSequence}`;
+        this._activeGroup = group;
+      }
+      this._createMessage(
         turn.role === "user" ? "June" : "Epi",
-        turn.content || (turn.status === "failed" ? "The quest failed." : ""),
+        turn.content ||
+          (turn.status === "aborted"
+            ? "Quest cancelled."
+            : turn.status === "failed"
+              ? "The quest failed."
+              : ""),
+        group,
+        false,
       );
     }
+    this._refreshTurnGroups(group);
   }
 
   startAssistantMessage() {
@@ -105,6 +290,64 @@
     this.scrollEnd();
   }
 
+  setMessageStreaming(item, streaming) {
+    if (!item) return;
+    if (streaming) item.dataset.streaming = "true";
+    else delete item.dataset.streaming;
+    this._viewport?.setAttribute("aria-busy", String(streaming));
+  }
+
+  _refreshTurnGroups(preferredGroup) {
+    const groups = [];
+    for (const item of this._messages?.children || []) {
+      const group = item.dataset.turnGroup || "start";
+      item.dataset.turnGroup = group;
+      if (!groups.includes(group)) groups.push(group);
+    }
+    this._turnGroups = groups;
+    const preferredIndex = groups.indexOf(preferredGroup);
+    const currentIndex = groups.indexOf(this._currentGroup);
+    this._turnIndex = preferredIndex >= 0
+      ? preferredIndex
+      : currentIndex >= 0
+        ? currentIndex
+        : Math.max(0, groups.length - 1);
+    this._showTurn();
+  }
+
+  _moveTurn(offset) {
+    const nextIndex = Math.max(
+      0,
+      Math.min(this._turnGroups.length - 1, this._turnIndex + offset),
+    );
+    if (nextIndex === this._turnIndex) return;
+    this._turnIndex = nextIndex;
+    this._showTurn();
+  }
+
+  _showTurn() {
+    const group = this._turnGroups?.[this._turnIndex];
+    this._currentGroup = group;
+    for (const item of this._messages?.children || []) {
+      item.hidden = item.dataset.turnGroup !== group;
+    }
+    if (this._previousButton) this._previousButton.disabled = this._turnIndex <= 0;
+    if (this._nextButton) {
+      this._nextButton.disabled =
+        this._turnIndex >= (this._turnGroups?.length || 0) - 1;
+    }
+    if (this._position) {
+      if (group === "start") {
+        this._position.textContent = "START";
+      } else {
+        const turns = this._turnGroups.filter(item => item !== "start");
+        this._position.textContent =
+          `TURN ${turns.indexOf(group) + 1} / ${turns.length}`;
+      }
+    }
+    this.scrollEnd();
+  }
+
   scrollEnd() {
     requestAnimationFrame(() => {
       if (this._viewport) {
@@ -161,6 +404,10 @@
     if (this._cancelControl) this._cancelControl.hidden = !busy;
   }
 
+  setCancellable(cancellable) {
+    if (this._cancelControl) this._cancelControl.hidden = !cancellable;
+  }
+
   setDisabled(disabled) {
     if (this._textarea) this._textarea.disabled = disabled;
     if (this._button) this._button.disabled = disabled;
@@ -190,9 +437,22 @@
 
 class MjjJrpgPreview extends HTMLElement {
   connectedCallback() {
+    this._onClick = event => {
+      if (
+        event.target.closest("[data-zen-trigger]") &&
+        this.dataset.selection === "resume"
+      ) {
+        void this.loadResume();
+      }
+    };
+    this.addEventListener("click", this._onClick);
     this.show(this.dataset.selection || "resume");
   }
 
+  disconnectedCallback() {
+    this.removeEventListener("click", this._onClick);
+  }
+
   show(selection) {
     const preview = PREVIEWS[selection] || PREVIEWS.resume;
     this.dataset.selection = selection;
@@ -202,9 +462,79 @@
     this.querySelector("[data-preview-type]").textContent = preview.type;
     this.querySelector("[data-dialog-title]").textContent = preview.title;
     this.querySelector("[data-dialog-copy]").textContent = preview.copy;
+    const showcase = this.querySelector("[data-work-showcase]");
+    showcase.replaceChildren(...preview.works.map(work => {
+      const item = document.createElement("li");
+      const link = document.createElement("a");
+      const label = document.createElement("span");
+      const detail = document.createElement("small");
+      link.href = work.url;
+      if (new URL(work.url, window.location.href).origin !== window.location.origin) {
+        link.target = "_blank";
+        link.rel = "noreferrer";
+      }
+      label.textContent = work.label;
+      detail.textContent = work.detail;
+      link.append(label, detail);
+      item.append(link);
+      return item;
+    }));
     const link = this.querySelector("[data-preview-link]");
     link.href = preview.url;
     link.setAttribute("aria-label", `Open ${preview.title}`);
+    const resumeDossier = this.querySelector("[data-resume-dossier]");
+    const resumeDownload = this.querySelector("[data-resume-download]");
+    const isResume = selection === "resume";
+    resumeDossier.hidden = !isResume;
+    resumeDownload.hidden = !isResume;
+    this.querySelector("[data-dialog-copy]").hidden = isResume;
+  }
+
+  async loadResume() {
+    if (this._resumeLoaded || this._resumeLoading) return;
+    this._resumeLoading = true;
+    const status = this.querySelector("[data-resume-status]");
+    const content = this.querySelector("[data-resume-content]");
+    try {
+      const response = await fetch("/resume", {
+        headers: { Accept: "text/html" },
+      });
+      if (!response.ok) {
+        throw new Error(`Resume request failed (${response.status})`);
+      }
+      const documentCopy = new DOMParser().parseFromString(
+        await response.text(),
+        "text/html",
+      );
+      const resume = documentCopy.querySelector("main");
+      if (!resume) throw new Error("Resume content is unavailable");
+      for (const unsafe of resume.querySelectorAll(
+        "embed, iframe, object, script, style, svg",
+      )) {
+        unsafe.remove();
+      }
+      for (const element of resume.querySelectorAll("*")) {
+        for (const attribute of [...element.attributes]) {
+          if (attribute.name.toLowerCase().startsWith("on")) {
+            element.removeAttribute(attribute.name);
+          }
+        }
+      }
+      for (const anchor of resume.querySelectorAll("a")) {
+        const target = new URL(anchor.href, window.location.href);
+        if (target.origin !== window.location.origin) {
+          anchor.target = "_blank";
+          anchor.rel = "noreferrer";
+        }
+      }
+      content.replaceChildren(...resume.childNodes);
+      status.hidden = true;
+      this._resumeLoaded = true;
+    } catch (error) {
+      status.textContent = `Unable to load resume: ${error.message}`;
+    } finally {
+      this._resumeLoading = false;
+    }
   }
 }
 
@@ -327,9 +657,15 @@
     character?.setAttribute("state", "thinking");
     window.clearTimeout(characterTimer);
     const assistantItem = chat?.startAssistantMessage();
-    let assistantText = "";
+    chat?.setMessageStreaming(assistantItem, true);
+    const assistantText = new StreamingTextAnimator(text => {
+      chat?.setMessageText(assistantItem, text);
+    });
     let turnFinished = false;
     activeController = new AbortController();
+    activeController.signal.addEventListener("abort", () => {
+      assistantText.cancel();
+    }, { once: true });
 
     try {
       const createConversation = async () => {
@@ -370,23 +706,42 @@
         throw new Error(message);
       }
       await consumeSse(response, (eventName, data) => {
+        shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", {
+          bubbles: true,
+          detail: {
+            data,
+            type: eventName,
+          },
+        }));
         if (eventName === "assistant.delta") {
-          assistantText += data.delta || "";
-          chat?.setMessageText(assistantItem, assistantText);
+          assistantText.append(data.delta || "");
         } else if (eventName === "assistant.completed") {
-          assistantText = data.content || assistantText;
-          chat?.setMessageText(assistantItem, assistantText);
+          assistantText.complete(data.content);
         } else if (eventName === "turn.error") {
-          throw new Error(data.message || "Inference turn failed");
+          throw new Error(
+            data.error?.message || data.message || "Inference turn failed",
+          );
         } else if (eventName === "turn.done") {
           turnFinished = true;
-          if (data.failed) throw new Error("Inference turn failed");
+          composer?.setCancellable(false);
+          if (data.failed || data.aborted) {
+            throw new Error(
+              data.aborted ? "Inference turn aborted" : "Inference turn failed",
+            );
+          }
         }
       });
       if (!turnFinished) throw new Error("Inference stream ended unexpectedly");
+      await assistantText.waitForIdle();
+      if (activeController.signal.aborted) {
+        throw new DOMException("Quest cancelled", "AbortError");
+      }
+      chat?.setMessageStreaming(assistantItem, false);
       character?.setAttribute("state", "default");
     } catch (error) {
       const aborted = error instanceof DOMException && error.name === "AbortError";
+      assistantText.cancel();
+      chat?.setMessageStreaming(assistantItem, false);
       chat?.setMessageText(
         assistantItem,
         aborted ? "Quest cancelled." : `System error: ${error.message}`,
--- a/mrjunejune/src/sw.js	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/src/sw.js	Wed Aug 05 20:38:32 2026 -0700
@@ -1,5 +1,5 @@
 // Root-scoped Service Worker for MrJuneJune PWA
-const CACHE_VERSION = 'v10-inference-chat';
+const CACHE_VERSION = 'v13-turn-resume';
 const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`;
 
 // Files to cache immediately on install
--- a/mrjunejune/test/conversation_api_test.js	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/test/conversation_api_test.js	Wed Aug 05 20:38:32 2026 -0700
@@ -149,6 +149,8 @@
     );
     const stream = await response.text();
     assert.match(stream, /event: turn\.accepted/);
+    assert.match(stream, /event: tool\.started/);
+    assert.match(stream, /"tool":\{"name":"mock-search"\}/);
     assert.match(stream, /event: assistant\.delta/);
     assert.match(stream, /hello \\"traveler\\"\\\\path/);
     assert.match(stream, /event: assistant\.completed/);
--- a/mrjunejune/test/inference_bridge_fake_sidecar.c	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/test/inference_bridge_fake_sidecar.c	Wed Aug 05 20:38:32 2026 -0700
@@ -51,6 +51,12 @@
           request_id,
           conversation_id);
       printf(
+          "{\"type\":\"tool.started\",\"request_id\":\"%s\","
+          "\"conversation_id\":\"%s\","
+          "\"tool\":{\"name\":\"mock-search\"}}\n",
+          request_id,
+          conversation_id);
+      printf(
           "{\"type\":\"assistant.delta\",\"request_id\":\"%s\","
           "\"conversation_id\":\"%s\","
           "\"delta\":\"hello \\\"traveler\\\"\\\\path\"}\n",
--- a/mrjunejune/test/inference_bridge_test.c	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/test/inference_bridge_test.c	Wed Aug 05 20:38:32 2026 -0700
@@ -13,9 +13,11 @@
   int deltas;
   int completed;
   int usage;
+  int custom;
   int done;
   int closed;
   char delta[128];
+  char custom_json[256];
   int64 input_tokens;
   int64 output_tokens;
 } Test_State;
@@ -41,6 +43,15 @@
     p_state->input_tokens = p_event->input_tokens;
     p_state->output_tokens = p_event->output_tokens;
   }
+  else if (strcmp(p_event->type, "tool.started") == 0)
+  {
+    p_state->custom++;
+    snprintf(
+        p_state->custom_json,
+        sizeof(p_state->custom_json),
+        "%s",
+        p_event->raw_json);
+  }
   else if (strcmp(p_event->type, "turn.done") == 0 &&
            strncmp(p_event->request_id, "request-", 8) == 0)
   {
@@ -83,7 +94,9 @@
   assert(state.deltas == 1);
   assert(state.completed == 1);
   assert(state.usage == 1);
+  assert(state.custom == 1);
   assert(strcmp(state.delta, "hello \"traveler\"\\path") == 0);
+  assert(strstr(state.custom_json, "\"name\":\"mock-search\""));
   assert(state.input_tokens == 7);
   assert(state.output_tokens == 3);
 
--- a/mrjunejune/test/production_inference_bundle_test.sh	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/test/production_inference_bundle_test.sh	Wed Aug 05 20:38:32 2026 -0700
@@ -26,6 +26,11 @@
   fi
 done
 
+if grep -Eq '/mock_sidecar($|\\.)|/mock_responses\\.json$' <<<"$files"; then
+  echo "Production bundle contains the development mock inference runtime" >&2
+  exit 1
+fi
+
 config="$(find -L "$bundle" -type f -name litellm_config.yaml -print -quit)"
 grep -q 'os.environ/LITELLM_MASTER_KEY' "$config"
 if grep -Eq 'sk-[A-Za-z0-9_-]{16,}|gh[opurs]_[A-Za-z0-9]{16,}' "$config"; then
--- a/mrjunejune/test/snapshots/jrpg.snapshot	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/test/snapshots/jrpg.snapshot	Wed Aug 05 20:38:32 2026 -0700
@@ -47,7 +47,6 @@
 
             <mjj-jrpg-chat aria-labelledby="conversation-title">
               <div class="jrpg-panel-heading">
-                <span aria-hidden="true">QUEST 01</span>
                 <h1 id="conversation-title">Conversations</h1>
               </div>
               <zen-message-scroller>
@@ -56,23 +55,35 @@
                   data-zen-viewport
                   role="log"
                   aria-live="polite"
-                  aria-relevant="additions"
+                  aria-relevant="additions text"
                 >
                   <ol class="jrpg-messages" data-messages>
-                    <li class="jrpg-message" data-speaker="Epi">
+                    <li class="jrpg-message" data-speaker="Epi" data-turn-group="start">
                       <strong>Epi</strong>
                       <p>Welcome, traveler. Pick a destination or tell me what you are looking for.</p>
                     </li>
-                    <li class="jrpg-message" data-speaker="System">
+                    <li class="jrpg-message" data-speaker="System" data-turn-group="start">
                       <strong>System</strong>
                       <p>The Shiba terminal is ready.</p>
                     </li>
                   </ol>
                 </div>
               </zen-message-scroller>
-              <p class="jrpg-next" aria-hidden="true">
-                NEXT <zen-icon name="chevron-right" size="0.9em"></zen-icon>
-              </p>
+              <nav class="jrpg-turn-navigation" aria-label="Conversation turns">
+                <zen-button appearance="plain" size="sm">
+                  <button type="button" data-turn-previous aria-label="Previous turn" disabled>
+                    <zen-icon name="chevron-left"></zen-icon>
+                    Previous
+                  </button>
+                </zen-button>
+                <span data-turn-position aria-live="polite">START</span>
+                <zen-button appearance="plain" size="sm">
+                  <button type="button" data-turn-next aria-label="Next turn" disabled>
+                    Next
+                    <zen-icon name="chevron-right"></zen-icon>
+                  </button>
+                </zen-button>
+              </nav>
             </mjj-jrpg-chat>
           </section>
 
@@ -93,6 +104,20 @@
                   <h2 data-preview-title>Resume</h2>
                 </zen-heading>
                 <p data-preview-copy>Experience, projects, and the systems I have helped build.</p>
+                <ul class="jrpg-work-showcase" data-work-showcase>
+                  <li>
+                    <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/">
+                      <span>Copilot Tasks</span>
+                      <small>Agentic execution</small>
+                    </a>
+                  </li>
+                  <li>
+                    <a href="https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html">
+                      <span>Copilot SuperApp</span>
+                      <small>AI platform</small>
+                    </a>
+                  </li>
+                </ul>
                 <dl class="jrpg-preview-stats">
                   <div>
                     <dt>TYPE</dt>
@@ -111,7 +136,7 @@
                       <zen-icon name="search"></zen-icon>
                     </button>
                   </zen-button>
-                  <dialog aria-labelledby="jrpg-dialog-title">
+                  <dialog class="jrpg-resume-dialog" aria-labelledby="jrpg-dialog-title">
                     <div class="jrpg-dialog-heading">
                       <p>DESTINATION DATA</p>
                       <zen-button appearance="plain" size="md">
@@ -122,12 +147,28 @@
                     </div>
                     <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2>
                     <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p>
-                    <zen-button appearance="plain" size="lg">
-                      <a href="/resume" data-preview-link>
-                        Open destination
-                        <zen-icon name="arrow-right"></zen-icon>
-                      </a>
-                    </zen-button>
+                    <section
+                      class="jrpg-resume-dossier"
+                      data-resume-dossier
+                      aria-label="Resume dossier"
+                    >
+                      <p data-resume-status>Loading resume dossier...</p>
+                      <div data-resume-content></div>
+                    </section>
+                    <div class="jrpg-dialog-actions">
+                      <zen-button appearance="plain" size="lg">
+                        <a href="/resume" data-preview-link>
+                          Open full page
+                          <zen-icon name="arrow-right"></zen-icon>
+                        </a>
+                      </zen-button>
+                      <zen-button appearance="plain" size="lg" data-resume-download>
+                        <a href="/public/resume.pdf">
+                          Download PDF
+                          <zen-icon name="download"></zen-icon>
+                        </a>
+                      </zen-button>
+                    </div>
                   </dialog>
                 </zen-dialog>
               </article>
@@ -147,18 +188,20 @@
                   required
                 ></textarea>
               </zen-field>
-              <zen-button appearance="plain" size="lg">
-                <button type="submit">
-                  Send
-                  <zen-icon name="arrow-right"></zen-icon>
-                </button>
-              </zen-button>
-              <zen-button appearance="plain" size="lg" data-cancel-control hidden>
-                <button type="button" data-cancel>
-                  Cancel
-                  <zen-icon name="close"></zen-icon>
-                </button>
-              </zen-button>
+              <div class="jrpg-composer-actions">
+                <zen-button appearance="plain" size="lg">
+                  <button type="submit">
+                    Send
+                    <zen-icon name="arrow-right"></zen-icon>
+                  </button>
+                </zen-button>
+                <zen-button appearance="plain" size="lg" data-cancel-control hidden>
+                  <button type="button" data-cancel>
+                    Cancel
+                    <zen-icon name="close"></zen-icon>
+                  </button>
+                </zen-button>
+              </div>
               <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p>
             </form>
           </mjj-jrpg-composer>
--- a/mrjunejune/test/theme_and_webp_test.js	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/test/theme_and_webp_test.js	Wed Aug 05 20:38:32 2026 -0700
@@ -476,8 +476,21 @@
     const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
     const composer = document.querySelector('mjj-jrpg-composer')
       .getBoundingClientRect();
+    const composerField = document.querySelector('.jrpg-composer zen-field')
+      .getBoundingClientRect();
+    const composerFieldStyle = getComputedStyle(
+      document.querySelector('.jrpg-composer zen-field'),
+    );
+    const composerLabel = document.querySelector('.jrpg-composer label')
+      .getBoundingClientRect();
+    const composerTextarea = document.querySelector('#jrpg-message')
+      .getBoundingClientRect();
     const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
     const character = document.querySelector('[data-character]');
+    const messageScroller = document.querySelector('zen-message-scroller')
+      .getBoundingClientRect();
+    const messageViewport = document.querySelector('.jrpg-message-viewport')
+      .getBoundingClientRect();
     const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
     const main = document.querySelector('.jrpg-main').getBoundingClientRect();
     const colorProbe = document.createElement('span');
@@ -558,6 +571,27 @@
       characterLoaded: character.complete && character.naturalWidth > 0,
       colors,
       composerBelowScene: composer.top >= scene.bottom,
+      composerTextareaFills: (
+        Math.abs(composerTextarea.left - composerField.left) < 1 &&
+        Math.abs(composerTextarea.right - composerField.right) < 1 &&
+        Math.abs(composerTextarea.bottom - composerField.bottom) < 1 &&
+        composerTextarea.height > 60
+      ),
+      composerTextareaGeometry: {
+        fieldBottom: composerField.bottom,
+        fieldDisplay: composerFieldStyle.display,
+        fieldGridRows: composerFieldStyle.gridTemplateRows,
+        fieldLeft: composerField.left,
+        fieldRight: composerField.right,
+        fieldTop: composerField.top,
+        labelBottom: composerLabel.bottom,
+        labelTop: composerLabel.top,
+        textareaBottom: composerTextarea.bottom,
+        textareaHeight: composerTextarea.height,
+        textareaLeft: composerTextarea.left,
+        textareaRight: composerTextarea.right,
+        textareaTop: composerTextarea.top,
+      },
       fillsViewport: shell.left === 0 &&
         shell.top === 0 &&
         Math.abs(shell.right - innerWidth) < 1 &&
@@ -572,8 +606,14 @@
         font.status === 'loaded'
       ),
       menuRightOfComposer: menu.left >= composer.right,
+      messageViewportFills: (
+        Math.abs(messageViewport.top - messageScroller.top) < 1 &&
+        Math.abs(messageViewport.bottom - messageScroller.bottom) < 1 &&
+        messageViewport.height > 192
+      ),
       noOverflow: document.documentElement.scrollWidth <= innerWidth,
       nonPixelText,
+      questLabelAbsent: !document.body.textContent.includes('QUEST 01'),
       theme: document.documentElement.dataset.zenTheme,
       utilityRightOfScene: utility.left >= scene.right,
     };
@@ -592,12 +632,19 @@
   assert.equal(desktop.colors.submit, desktop.colors.primary);
   assert.equal(desktop.colors.utility, desktop.colors.surface);
   assert.equal(desktop.composerBelowScene, true);
+  assert.equal(
+    desktop.composerTextareaFills,
+    true,
+    JSON.stringify(desktop.composerTextareaGeometry),
+  );
   assert.equal(desktop.fillsViewport, true);
   assert.ok(desktop.fonts.every(font => font === '"Pixel Mplus"'));
   assert.equal(desktop.fontLoaded, true);
   assert.equal(desktop.menuRightOfComposer, true);
+  assert.equal(desktop.messageViewportFills, true);
   assert.equal(desktop.noOverflow, true);
   assert.deepEqual(desktop.nonPixelText, []);
+  assert.equal(desktop.questLabelAbsent, true);
   assert.equal(desktop.theme, 'cyberpunk');
   assert.equal(desktop.utilityRightOfScene, true);
   await page.locator('.jrpg-brand a').focus();
@@ -618,6 +665,27 @@
     await page.locator('meta[name="theme-color"]').first().getAttribute('content'),
     desktop.colors.canvas,
   );
+  assert.equal(await page.locator('[data-work-showcase] a').count(), 6);
+  assert.match(
+    await page.locator('[data-work-showcase]').textContent(),
+    /Copilot Tasks/,
+  );
+  assert.match(
+    await page.locator('[data-work-showcase]').textContent(),
+    /Copilot SuperApp/,
+  );
+  assert.equal(
+    await page.locator('[data-turn-position]').textContent(),
+    'START',
+  );
+  assert.equal(
+    await page.getByRole('button', { name: 'Previous turn' }).isDisabled(),
+    true,
+  );
+  assert.equal(
+    await page.getByRole('button', { name: 'Next turn' }).isDisabled(),
+    true,
+  );
 
   await page.getByRole('button', { name: 'Tools', exact: true }).click();
   assert.equal(
@@ -633,6 +701,7 @@
       .getAttribute('aria-pressed'),
     'true',
   );
+  assert.equal(await page.locator('[data-work-showcase] a').count(), 3);
 
   await page.getByRole('button', { name: /Inspect/ }).click();
   await page.locator('.jrpg-preview-dialog dialog[open]').waitFor();
@@ -643,10 +712,49 @@
   await page.getByRole('button', {
     name: 'Close destination details',
   }).click();
+  await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' });
 
+  await page.getByRole('button', { name: 'Resume', exact: true }).click();
+  await page.getByRole('button', { name: /Inspect/ }).click();
+  await page.locator('[data-resume-content] .sub-header').first().waitFor();
+  const resumeDossier = await page.locator('[data-resume-dossier]').evaluate(
+    dossier => ({
+      fontFamily: getComputedStyle(dossier).fontFamily,
+      text: dossier.textContent,
+    }),
+  );
+  assert.equal(resumeDossier.fontFamily, '"Pixel Mplus"');
+  assert.match(resumeDossier.text, /Copilot Tasks/);
+  assert.match(resumeDossier.text, /Microsoft/);
+  assert.match(resumeDossier.text, /Meta/);
+  assert.equal(
+    await page.locator('[data-resume-content] a[target="_blank"]').count() > 0,
+    true,
+  );
+  await page.getByRole('button', {
+    name: 'Close destination details',
+  }).click();
+
+  await page.evaluate(() => {
+    window.__jrpgStreamEvents = [];
+    document.querySelector('mjj-jrpg-shell').addEventListener(
+      'mjj-jrpg-stream-event',
+      event => window.__jrpgStreamEvents.push(event.detail),
+    );
+  });
   await page.locator('#jrpg-message').fill('Where should I begin?');
   await page.locator('#jrpg-message').press('Enter');
   await page.locator('.jrpg-message[data-speaker="June"]').last().waitFor();
+  const streamingMessage = page.locator(
+    '.jrpg-message[data-speaker="Epi"][data-streaming="true"]',
+  ).last();
+  await streamingMessage.waitFor();
+  await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' });
+  await streamingMessage.waitFor();
+  assert.notEqual(
+    await streamingMessage.locator('p').textContent(),
+    'hello traveler',
+  );
   await page.getByRole('button', { name: 'Blogs', exact: true }).click();
   assert.equal(
     await page.locator('.jrpg-message[data-speaker="June"] p').last()
@@ -658,6 +766,66 @@
       'default' &&
     document.querySelectorAll('.jrpg-message[data-speaker="Epi"]').length > 1
   );
+  assert.equal(
+    await page.locator('.jrpg-message[data-speaker="Epi"] p').last()
+      .textContent(),
+    'hello traveler',
+  );
+  assert.equal(
+    await page.locator(
+      '.jrpg-message[data-speaker="Epi"][data-streaming="true"]',
+    ).count(),
+    0,
+  );
+  assert.deepEqual(
+    await page.evaluate(() => {
+      const event = window.__jrpgStreamEvents.find(
+        item => item.type === 'tool.started',
+      );
+      return {
+        name: event?.data.tool?.name,
+        type: event?.type,
+      };
+    }),
+    {
+      name: 'mock-search',
+      type: 'tool.started',
+    },
+  );
+  assert.equal(
+    await page.locator('[data-turn-position]').textContent(),
+    'TURN 1 / 1',
+  );
+  assert.equal(
+    await page.getByRole('button', { name: 'Previous turn' }).isEnabled(),
+    true,
+  );
+  assert.equal(
+    await page.getByRole('button', { name: 'Next turn' }).isDisabled(),
+    true,
+  );
+  await page.getByRole('button', { name: 'Previous turn' }).click();
+  assert.equal(
+    await page.locator('[data-turn-position]').textContent(),
+    'START',
+  );
+  assert.equal(
+    await page.locator('.jrpg-message[data-speaker="June"]:visible').count(),
+    0,
+  );
+  assert.equal(
+    await page.locator('.jrpg-message[data-speaker="System"]:visible').count(),
+    1,
+  );
+  await page.getByRole('button', { name: 'Next turn' }).click();
+  assert.equal(
+    await page.locator('[data-turn-position]').textContent(),
+    'TURN 1 / 1',
+  );
+  assert.equal(
+    await page.locator('.jrpg-message[data-speaker="June"]:visible').count(),
+    1,
+  );
   assert.equal(await page.locator('#jrpg-message').isEnabled(), true);
 
   await page.waitForFunction(() => navigator.serviceWorker?.controller);
@@ -1041,7 +1209,7 @@
     for (const source of [home, dogGame, manifest]) {
       assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
     }
-    assert.match(serviceWorker, /v10-inference-chat/);
+    assert.match(serviceWorker, /v13-turn-resume/);
     assert.match(
       await (
         await fetch(`${baseUrl}/public/pwa-register.js`)