tenseleyflow/loader / e82c3e2

Browse files

Normalize home-rooted path prompts

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e82c3e2841eee6899eea021a770304dc3566e908
Parents
ab475b4
Tree
475a1d4

5 changed files

StatusFile+-
A src/loader/runtime/path_display.py 25 0
M src/loader/runtime/repair.py 30 21
M src/loader/runtime/tool_batches.py 11 8
M tests/test_repair.py 75 12
M tests/test_tool_batches.py 109 2
src/loader/runtime/path_display.pyadded
@@ -0,0 +1,25 @@
1
+"""Helpers for consistent user-facing runtime path rendering."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+
7
+
8
+def display_runtime_path(path_value: str | Path) -> str:
9
+    """Render a stable user-facing path for prompts and steering messages.
10
+
11
+    Paths under the active HOME are shown as `~/...` to avoid leaking
12
+    platform-specific symlink expansions such as `/private/tmp/...`.
13
+    Other paths keep the current normalized absolute rendering.
14
+    """
15
+
16
+    path = Path(path_value).expanduser()
17
+    resolved = path.resolve(strict=False)
18
+    home = Path.home().expanduser().resolve(strict=False)
19
+    try:
20
+        relative = resolved.relative_to(home)
21
+    except ValueError:
22
+        return str(resolved)
23
+    if not relative.parts:
24
+        return "~"
25
+    return f"~/{relative.as_posix()}"
src/loader/runtime/repair.pymodified
@@ -17,6 +17,7 @@ from .dod import (
1717
     planned_artifact_target_satisfied,
1818
 )
1919
 from .parsing import parse_tool_calls
20
+from .path_display import display_runtime_path
2021
 from .recovery import detect_missing_mutation_payload
2122
 from .workflow import (
2223
     infer_output_outline_label,
@@ -369,11 +370,12 @@ class ResponseRepairer:
369370
 
370371
         target = fix["file_path"] or self._preferred_retry_target(dod)
371372
         invalid = ", ".join(f"`{field}`" for field in fix["invalid_fields"])
373
+        display_target = display_runtime_path(target) if target else None
372374
         if fix.get("kind") == "missing_target":
373375
             if attempt.tool_name == "write":
374376
                 target_line = (
375
-                    f"Last tool failure: resend `write` for `{target}` with a valid `file_path` and real `content`."
376
-                    if target
377
+                    f"Last tool failure: resend `write` for `{display_target}` with a valid `file_path` and real `content`."
378
+                    if display_target
377379
                     else "Last tool failure: resend `write` with a valid `file_path` and real `content`."
378380
                 )
379381
                 return [
@@ -388,8 +390,8 @@ class ResponseRepairer:
388390
                 ]
389391
             if attempt.tool_name == "edit":
390392
                 target_line = (
391
-                    f"Last tool failure: resend `edit` for `{target}` with a valid `file_path` plus real `old_string`/`new_string`."
392
-                    if target
393
+                    f"Last tool failure: resend `edit` for `{display_target}` with a valid `file_path` plus real `old_string`/`new_string`."
394
+                    if display_target
393395
                     else "Last tool failure: resend `edit` with a valid `file_path` plus real `old_string`/`new_string`."
394396
                 )
395397
                 return [
@@ -404,8 +406,8 @@ class ResponseRepairer:
404406
                 ]
405407
             if attempt.tool_name == "patch":
406408
                 target_line = (
407
-                    f"Last tool failure: resend `patch` for `{target}` with a valid `file_path` and real patch text or `hunks`."
408
-                    if target
409
+                    f"Last tool failure: resend `patch` for `{display_target}` with a valid `file_path` and real patch text or `hunks`."
410
+                    if display_target
409411
                     else "Last tool failure: resend `patch` with a valid `file_path` and real patch text or `hunks`."
410412
                 )
411413
                 return [
@@ -421,8 +423,8 @@ class ResponseRepairer:
421423
         if attempt.tool_name == "write":
422424
             lines = [
423425
                 (
424
-                    f"Last tool failure: resend `write` for `{target}` with real `content`, not just summary fields."
425
-                    if target
426
+                    f"Last tool failure: resend `write` for `{display_target}` with real `content`, not just summary fields."
427
+                    if display_target
426428
                     else "Last tool failure: resend `write` with real `content`, not just summary fields."
427429
                 ),
428430
             ]
@@ -438,8 +440,8 @@ class ResponseRepairer:
438440
         if attempt.tool_name == "edit":
439441
             lines = [
440442
                 (
441
-                    f"Last tool failure: resend `edit` for `{target}` with the real text payload."
442
-                    if target
443
+                    f"Last tool failure: resend `edit` for `{display_target}` with the real text payload."
444
+                    if display_target
443445
                     else "Last tool failure: resend `edit` with the real text payload."
444446
                 ),
445447
                 f"Do not use {invalid} in place of `old_string`/`new_string`.",
@@ -455,8 +457,8 @@ class ResponseRepairer:
455457
         if attempt.tool_name == "patch":
456458
             lines = [
457459
                 (
458
-                    f"Last tool failure: resend `patch` for `{target}` with real patch text or structured hunks."
459
-                    if target
460
+                    f"Last tool failure: resend `patch` for `{display_target}` with real patch text or structured hunks."
461
+                    if display_target
460462
                     else "Last tool failure: resend `patch` with real patch text or structured hunks."
461463
                 ),
462464
                 f"Do not use {invalid} in place of the real patch payload.",
@@ -744,7 +746,8 @@ class ResponseRepairer:
744746
             lines = [
745747
                 f"Resume with this exact next step: create `{concrete_target.name}`.",
746748
                 f"It is the next concrete output needed to continue `{next_pending}`.",
747
-                f"Prefer one `write(content=...)` call for `{concrete_target}` before more research.",
749
+                "Prefer one `write(content=...)` call for "
750
+                f"`{display_runtime_path(concrete_target)}` before more research.",
748751
                 self._mutation_tool_scaffold(
749752
                     concrete_target,
750753
                     tool_name="write",
@@ -794,14 +797,16 @@ class ResponseRepairer:
794797
             ]
795798
             if inferred_is_directory:
796799
                 lines.append(
797
-                    f"Prefer one concrete directory-creation step for `{inferred_pending_target}` before more research."
800
+                    "Prefer one concrete directory-creation step for "
801
+                    f"`{display_runtime_path(inferred_pending_target)}` before more research."
798802
                 )
799803
                 lines.append(
800804
                     self._directory_creation_scaffold(inferred_pending_target)
801805
                 )
802806
             else:
803807
                 lines.append(
804
-                    f"Prefer one `write(content=...)` call for `{inferred_pending_target}` before more research."
808
+                    "Prefer one `write(content=...)` call for "
809
+                    f"`{display_runtime_path(inferred_pending_target)}` before more research."
805810
                 )
806811
                 lines.append(
807812
                     self._mutation_tool_scaffold(
@@ -882,7 +887,8 @@ class ResponseRepairer:
882887
                         )
883888
                     )
884889
                     lines.append(
885
-                        f"Prefer one `write` call for `{next_output_file}` before more research."
890
+                        "Prefer one `write` call for "
891
+                        f"`{display_runtime_path(next_output_file)}` before more research."
886892
                     )
887893
                     lines.append(
888894
                         self._mutation_tool_scaffold(
@@ -921,17 +927,20 @@ class ResponseRepairer:
921927
                         f"under {label}."
922928
                     ]
923929
                 lines.append(
924
-                    f"Prefer one concrete `write` call for a file inside `{target}` before more research."
930
+                    "Prefer one concrete `write` call for a file inside "
931
+                    f"`{display_runtime_path(target)}` before more research."
925932
                 )
926933
             else:
927934
                 lines = [f"Resume with this exact next step: create {label}."]
928935
             if expect_directory and not target.is_dir():
929936
                 lines.append(
930
-                    f"Prefer one concrete directory-creation step for `{target}` before more research."
937
+                    "Prefer one concrete directory-creation step for "
938
+                    f"`{display_runtime_path(target)}` before more research."
931939
                 )
932940
             elif not expect_directory:
933941
                 lines.append(
934
-                    f"Prefer one `write` call for `{target}` before any more reference reads."
942
+                    "Prefer one `write` call for "
943
+                    f"`{display_runtime_path(target)}` before any more reference reads."
935944
                 )
936945
                 if not target.parent.exists():
937946
                     lines.append(
@@ -1154,7 +1163,7 @@ class ResponseRepairer:
11541163
 
11551164
     @staticmethod
11561165
     def _mutation_tool_scaffold(path: Path, *, tool_name: str) -> str:
1157
-        normalized_path = json.dumps(str(path.expanduser().resolve(strict=False)))
1166
+        normalized_path = json.dumps(display_runtime_path(path))
11581167
         if tool_name == "edit":
11591168
             signature = (
11601169
                 f"edit(file_path={normalized_path}, old_string=\"...\", "
@@ -1168,7 +1177,7 @@ class ResponseRepairer:
11681177
 
11691178
     @staticmethod
11701179
     def _directory_creation_scaffold(path: Path) -> str:
1171
-        command = f"mkdir -p {shlex.quote(str(path.expanduser().resolve(strict=False)))}"
1180
+        command = f"mkdir -p {shlex.quote(display_runtime_path(path))}"
11721181
         return f"Emit this tool shape now: `bash(command={json.dumps(command)})`."
11731182
 
11741183
 
src/loader/runtime/tool_batches.pymodified
@@ -29,6 +29,7 @@ from .events import AgentEvent, TurnSummary
2929
 from .evidence_provenance import EvidenceProvenance, EvidenceProvenanceStatus
3030
 from .executor import ToolExecutionState, ToolExecutor
3131
 from .logging import get_runtime_logger
32
+from .path_display import display_runtime_path
3233
 from .policy_timeline import append_verification_timeline_entry
3334
 from .recovery import RecoveryContext, detect_missing_mutation_payload
3435
 from .repair_focus import extract_active_repair_context
@@ -1939,6 +1940,7 @@ def _resume_suffix_for_target(
19391940
     allow_inferred_child: bool = True,
19401941
 ) -> str:
19411942
     label = target.name or str(target)
1943
+    display_target = display_runtime_path(target)
19421944
     if expect_directory and not label.endswith("/"):
19431945
         label += "/"
19441946
     if expect_directory:
@@ -1960,7 +1962,7 @@ def _resume_suffix_for_target(
19601962
                 guidance = (
19611963
                     f" Resume by creating `{next_output_file.name}` now. {guidance_origin} "
19621964
                     f"Prefer one `write` call for "
1963
-                    f"`{next_output_file}` instead of more rereads."
1965
+                    f"`{display_runtime_path(next_output_file)}` instead of more rereads."
19641966
                 )
19651967
                 if not next_output_file.parent.exists():
19661968
                     guidance += (
@@ -1975,16 +1977,16 @@ def _resume_suffix_for_target(
19751977
         if target.is_dir():
19761978
             return (
19771979
                 f" Resume by creating the next output file under `{label}` now. Prefer one "
1978
-                f"concrete `write` call for a file inside `{target}` instead of more rereads."
1980
+                f"concrete `write` call for a file inside `{display_target}` instead of more rereads."
19791981
                 " Make your next response the concrete mutation tool call itself, not another"
19801982
                 " bookkeeping-only turn."
19811983
             )
19821984
         return (
19831985
             f" Resume by creating `{label}` now. Prefer one concrete directory-creation "
1984
-            f"step for `{target}` instead of more rereads."
1986
+            f"step for `{display_target}` instead of more rereads."
19851987
         )
19861988
     guidance = (
1987
-        f" Resume by creating `{label}` now. Prefer one `write` call for `{target}` "
1989
+        f" Resume by creating `{label}` now. Prefer one `write` call for `{display_target}` "
19881990
         "instead of more rereads."
19891991
     )
19901992
     if not target.parent.exists():
@@ -2012,6 +2014,7 @@ def _compact_missing_artifact_handoff(
20122014
 
20132015
     target, expect_directory = missing_artifact
20142016
     label = target.name or str(target)
2017
+    display_target = display_runtime_path(target)
20152018
     if expect_directory and not label.endswith("/"):
20162019
         label += "/"
20172020
     if expect_directory:
@@ -2024,15 +2027,15 @@ def _compact_missing_artifact_handoff(
20242027
             if target.is_dir():
20252028
                 return (
20262029
                     f"Next step: create the next output file under `{label}`. Prefer one "
2027
-                    f"concrete `write` call inside `{target}` now."
2030
+                    f"concrete `write` call inside `{display_target}` now."
20282031
                 )
20292032
             return (
20302033
                 f"Next step: create `{label}`. Prefer one concrete directory-creation step "
2031
-                f"for `{target}` now."
2034
+                f"for `{display_target}` now."
20322035
             )
20332036
         guidance = (
20342037
             f"Next step: create `{next_output_file.name}`. Prefer one "
2035
-            f"`write(file_path=..., content=...)` call for `{next_output_file}` now."
2038
+            f"`write(file_path=..., content=...)` call for `{display_runtime_path(next_output_file)}` now."
20362039
         )
20372040
         if not next_output_file.parent.exists():
20382041
             guidance += (
@@ -2043,7 +2046,7 @@ def _compact_missing_artifact_handoff(
20432046
 
20442047
     guidance = (
20452048
         f"Next step: create `{label}`. Prefer one "
2046
-        f"`write(file_path=..., content=...)` call for `{target}` now."
2049
+        f"`write(file_path=..., content=...)` call for `{display_target}` now."
20472050
     )
20482051
     if not target.parent.exists():
20492052
         guidance += (
tests/test_repair.pymodified
@@ -6,9 +6,12 @@ import json
66
 from pathlib import Path
77
 from types import SimpleNamespace
88
 
9
+import pytest
10
+
911
 from loader.llm.base import Message, Role, ToolCall
1012
 from loader.runtime.context import RuntimeContext
1113
 from loader.runtime.dod import create_definition_of_done
14
+from loader.runtime.path_display import display_runtime_path
1215
 from loader.runtime.permissions import (
1316
     PermissionMode,
1417
     build_permission_policy,
@@ -312,7 +315,8 @@ def test_empty_response_retry_mentions_write_can_create_missing_parent_directori
312315
         in decision.retry_message
313316
     )
314317
     assert (
315
-        f"Prefer one `write` call for `{index_path}` before any more reference reads."
318
+        "Prefer one `write` call for "
319
+        f"`{display_runtime_path(index_path)}` before any more reference reads."
316320
         in decision.retry_message
317321
     )
318322
     assert (
@@ -320,7 +324,7 @@ def test_empty_response_retry_mentions_write_can_create_missing_parent_directori
320324
         in decision.retry_message
321325
     )
322326
     assert (
323
-        f'Emit this tool shape now: `write(file_path="{index_path.resolve(strict=False)}", content="...")`.'
327
+        f'Emit this tool shape now: `write(file_path="{display_runtime_path(index_path)}", content="...")`.'
324328
         in decision.retry_message
325329
     )
326330
     assert "Do not restart discovery unless one specific missing fact blocks this step." in decision.retry_message
@@ -378,15 +382,69 @@ def test_empty_response_retry_uses_directory_creation_for_setup_targets(
378382
         in decision.retry_message
379383
     )
380384
     assert (
381
-        f"Prefer one concrete directory-creation step for `{chapters_path}` before more research."
385
+        "Prefer one concrete directory-creation step for "
386
+        f"`{display_runtime_path(chapters_path)}` before more research."
382387
         in decision.retry_message
383388
     )
384
-    expected_command = f"mkdir -p {chapters_path.resolve(strict=False)}"
389
+    expected_command = f"mkdir -p {display_runtime_path(chapters_path)}"
385390
     assert (
386391
         f'Emit this tool shape now: `bash(command="{expected_command}")`.'
387392
         in decision.retry_message
388393
     )
389
-    assert f'write(file_path="{chapters_path.resolve(strict=False)}"' not in decision.retry_message
394
+    assert f'write(file_path="{display_runtime_path(chapters_path)}"' not in decision.retry_message
395
+
396
+
397
+def test_empty_response_retry_uses_home_relative_path_for_home_artifacts(
398
+    temp_dir: Path,
399
+    monkeypatch: pytest.MonkeyPatch,
400
+) -> None:
401
+    monkeypatch.setenv("HOME", str(temp_dir.resolve(strict=False)))
402
+    context = build_context(
403
+        temp_dir=temp_dir,
404
+        use_react=False,
405
+    )
406
+    repairer = ResponseRepairer(context)
407
+
408
+    guide_root = temp_dir / "Loader" / "guides" / "nginx"
409
+    index_path = guide_root / "index.html"
410
+
411
+    implementation_plan = temp_dir / "implementation.md"
412
+    implementation_plan.write_text(
413
+        "\n".join(
414
+            [
415
+                "# Implementation Plan",
416
+                "",
417
+                "## File Changes",
418
+                f"- `{index_path}`",
419
+                "",
420
+            ]
421
+        )
422
+    )
423
+
424
+    dod = create_definition_of_done("Create a multi-file nginx guide.")
425
+    dod.implementation_plan = str(implementation_plan)
426
+    dod.pending_items.extend(
427
+        [
428
+            "Create nginx guide directory structure",
429
+            "Write main index.html for nginx guide",
430
+        ]
431
+    )
432
+
433
+    decision = repairer.handle_empty_response(
434
+        task="Create a multi-file nginx guide.",
435
+        original_task=None,
436
+        empty_retry_count=1,
437
+        max_empty_retries=2,
438
+        dod=dod,
439
+    )
440
+
441
+    assert decision.should_continue is True
442
+    assert decision.retry_message is not None
443
+    assert "`~/Loader/guides/nginx/index.html`" in decision.retry_message
444
+    assert (
445
+        'Emit this tool shape now: `write(file_path="~/Loader/guides/nginx/index.html", content="...")`.'
446
+        in decision.retry_message
447
+    )
390448
 
391449
 
392450
 def test_empty_response_retry_recovers_blocked_empty_file_path_to_concrete_target(
@@ -448,12 +506,13 @@ def test_empty_response_retry_recovers_blocked_empty_file_path_to_concrete_targe
448506
     assert decision.should_continue is True
449507
     assert decision.retry_message is not None
450508
     assert (
451
-        f"Last tool failure: resend `write` for `{second_chapter}` with a valid `file_path` and real `content`."
509
+        "Last tool failure: resend `write` for "
510
+        f"`{display_runtime_path(second_chapter)}` with a valid `file_path` and real `content`."
452511
         in decision.retry_message
453512
     )
454513
     assert "Do not leave `file_path` empty" in decision.retry_message
455514
     assert (
456
-        f'Emit this tool shape now: `write(file_path="{second_chapter.resolve(strict=False)}", content="...")`.'
515
+        f'Emit this tool shape now: `write(file_path="{display_runtime_path(second_chapter)}", content="...")`.'
457516
         in decision.retry_message
458517
     )
459518
 
@@ -822,7 +881,8 @@ def test_empty_response_retry_points_at_next_output_file_when_planned_directory_
822881
         in decision.retry_message
823882
     )
824883
     assert (
825
-        f"Prefer one concrete `write` call for a file inside `{chapters}` before more research."
884
+        "Prefer one concrete `write` call for a file inside "
885
+        f"`{display_runtime_path(chapters)}` before more research."
826886
         in decision.retry_message
827887
     )
828888
 
@@ -1081,7 +1141,8 @@ def test_empty_response_retry_prefers_output_index_over_reference_index_with_sam
10811141
     assert decision.should_continue is True
10821142
     assert decision.retry_message is not None
10831143
     assert (
1084
-        f"Prefer one `write(content=...)` call for `{output_index}` before more research."
1144
+        "Prefer one `write(content=...)` call for "
1145
+        f"`{display_runtime_path(output_index)}` before more research."
10851146
         in decision.retry_message
10861147
     )
10871148
     assert str(reference_index) not in decision.retry_message
@@ -1217,7 +1278,8 @@ def test_empty_response_retry_infers_concrete_file_from_pending_todo_after_broad
12171278
         in decision.retry_message
12181279
     )
12191280
     assert (
1220
-        f"Prefer one `write(content=...)` call for `{chapters / '02-installation.html'}` "
1281
+        "Prefer one `write(content=...)` call for "
1282
+        f"`{display_runtime_path(chapters / '02-installation.html')}` "
12211283
         "before more research."
12221284
         in decision.retry_message
12231285
     )
@@ -1294,12 +1356,13 @@ def test_empty_response_retry_maps_title_style_todo_to_html_graph_target(
12941356
         in decision.retry_message
12951357
     )
12961358
     assert (
1297
-        f"Prefer one `write(content=...)` call for `{(chapters / '02-installation.html').resolve(strict=False)}` "
1359
+        "Prefer one `write(content=...)` call for "
1360
+        f"`{display_runtime_path(chapters / '02-installation.html')}` "
12981361
         "before more research."
12991362
         in decision.retry_message
13001363
     )
13011364
     assert (
1302
-        f'Emit this tool shape now: `write(file_path="{(chapters / "02-installation.html").resolve(strict=False)}", content="...")`.'
1365
+        f'Emit this tool shape now: `write(file_path="{display_runtime_path(chapters / "02-installation.html")}", content="...")`.'
13031366
         in decision.retry_message
13041367
     )
13051368
     assert (
tests/test_tool_batches.pymodified
@@ -16,6 +16,7 @@ from loader.runtime.dod import (
1616
 )
1717
 from loader.runtime.events import AgentEvent, TurnSummary
1818
 from loader.runtime.executor import ToolExecutionOutcome, ToolExecutionState
19
+from loader.runtime.path_display import display_runtime_path
1920
 from loader.runtime.permissions import (
2021
     PermissionMode,
2122
     build_permission_policy,
@@ -693,7 +694,8 @@ async def test_tool_batch_runner_queues_duplicate_observation_nudge(
693694
     assert "A declared output artifact is still missing." in persistent_messages[0]
694695
     assert "Resume by creating `04-variables.html` now." in persistent_messages[0]
695696
     assert (
696
-        f"Prefer one `write` call for `{temp_dir / 'chapters' / '04-variables.html'}` instead of more rereads."
697
+        "Prefer one `write` call for "
698
+        f"`{display_runtime_path(temp_dir / 'chapters' / '04-variables.html')}` instead of more rereads."
697699
         in persistent_messages[0]
698700
     )
699701
     assert ephemeral_messages == []
@@ -2362,6 +2364,111 @@ async def test_tool_batch_runner_first_chapter_handoff_becomes_ephemeral_after_f
23622364
     assert "Do not reread reference material or spend the next turn on bookkeeping." in message
23632365
 
23642366
 
2367
+@pytest.mark.asyncio
2368
+async def test_tool_batch_runner_directory_handoff_uses_home_relative_path(
2369
+    temp_dir: Path,
2370
+    monkeypatch: pytest.MonkeyPatch,
2371
+) -> None:
2372
+    monkeypatch.setenv("HOME", str(temp_dir.resolve(strict=False)))
2373
+
2374
+    async def assess_confidence(
2375
+        tool_name: str,
2376
+        tool_args: dict,
2377
+        context: str,
2378
+    ) -> ConfidenceAssessment:
2379
+        raise AssertionError("Confidence scoring should be disabled in this scenario")
2380
+
2381
+    async def verify_action(
2382
+        tool_name: str,
2383
+        tool_args: dict,
2384
+        result: str,
2385
+        expected: str = "",
2386
+    ) -> ActionVerification:
2387
+        raise AssertionError("Verification should not run for this scenario")
2388
+
2389
+    nginx_root = temp_dir / "Loader" / "guides" / "nginx"
2390
+    chapters = nginx_root / "chapters"
2391
+    index_path = nginx_root / "index.html"
2392
+
2393
+    implementation_plan = temp_dir / "implementation.md"
2394
+    implementation_plan.write_text(
2395
+        "\n".join(
2396
+            [
2397
+                "# Implementation Plan",
2398
+                "",
2399
+                "## File Changes",
2400
+                f"- `{chapters}/`",
2401
+                f"- `{index_path}`",
2402
+                "",
2403
+            ]
2404
+        )
2405
+    )
2406
+
2407
+    context = build_context(
2408
+        temp_dir=temp_dir,
2409
+        messages=[],
2410
+        safeguards=FakeSafeguards(),
2411
+        assess_confidence=assess_confidence,
2412
+        verify_action=verify_action,
2413
+        auto_recover=False,
2414
+    )
2415
+    persistent_messages: list[str] = []
2416
+    context.queue_steering_message_callback = persistent_messages.append
2417
+    runner = ToolBatchRunner(context, DefinitionOfDoneStore(temp_dir))
2418
+    dod = create_definition_of_done("Create a multi-file nginx guide.")
2419
+    dod.implementation_plan = str(implementation_plan)
2420
+    sync_todos_to_definition_of_done(
2421
+        dod,
2422
+        [
2423
+            {
2424
+                "content": "Create the nginx directory structure",
2425
+                "active_form": "Creating the nginx directory structure",
2426
+                "status": "pending",
2427
+            },
2428
+            {
2429
+                "content": "Develop the main index.html file with proper structure",
2430
+                "active_form": "Developing the main index.html file with proper structure",
2431
+                "status": "pending",
2432
+            },
2433
+        ],
2434
+    )
2435
+
2436
+    tool_call = ToolCall(
2437
+        id="mkdir-nginx-home",
2438
+        name="bash",
2439
+        arguments={"command": f"mkdir -p {chapters}"},
2440
+    )
2441
+    executor = FakeExecutor(
2442
+        [
2443
+            tool_outcome(
2444
+                tool_call=tool_call,
2445
+                output="",
2446
+                is_error=False,
2447
+            )
2448
+        ]
2449
+    )
2450
+
2451
+    summary = TurnSummary(final_response="")
2452
+    await runner.execute_batch(
2453
+        tool_calls=[tool_call],
2454
+        tool_source="assistant",
2455
+        pending_tool_calls_seen=set(),
2456
+        emit=_noop_emit,
2457
+        summary=summary,
2458
+        dod=dod,
2459
+        executor=executor,  # type: ignore[arg-type]
2460
+        on_confirmation=None,
2461
+        on_user_question=None,
2462
+        emit_confirmation=None,
2463
+        consecutive_errors=0,
2464
+    )
2465
+
2466
+    assert persistent_messages
2467
+    message = persistent_messages[-1]
2468
+    assert "Next step: create `index.html`." in message
2469
+    assert "`~/Loader/guides/nginx/index.html`" in message
2470
+
2471
+
23652472
 @pytest.mark.asyncio
23662473
 async def test_tool_batch_runner_redirects_post_write_self_audit_to_next_missing_artifact(
23672474
     temp_dir: Path,
@@ -5468,7 +5575,7 @@ async def test_tool_batch_runner_blocked_empty_file_path_nudges_concrete_next_ar
54685575
     assert "did not provide a valid `file_path`" in queued[0]
54695576
     assert "Resume by creating `02-installation.html` now." in queued[0]
54705577
     assert (
5471
-        f"Prefer one `write` call for `{chapter_two}` instead of more rereads."
5578
+        f"Prefer one `write` call for `{display_runtime_path(chapter_two)}` instead of more rereads."
54725579
         in queued[0]
54735580
     )
54745581
     assert context.recovery_context is not None