tenseleyflow/documentlanguagemodel / 1fdf194

Browse files

Finish CLI M3 coverage pass

Authored by espadonne
SHA
1fdf1940830d54f7dde07b9eab7c8a5a63598db4
Parents
cda2c03
Tree
2884406

6 changed files

StatusFile+-
M src/dlm/cli/commands.py 15 16
M tests/integration/audio/test_audio_training.py 11 4
M tests/integration/vl/test_vl_training.py 11 4
A tests/unit/cli/test_export_vl_cache_fallback.py 74 0
A tests/unit/cli/test_misc_commands_coverage.py 520 0
A tests/unit/cli/test_train_prompt_repl_coverage.py 465 0
src/dlm/cli/commands.pymodified
@@ -1776,19 +1776,24 @@ def export_cmd(
17761776
     store.ensure_layout()
17771777
 
17781778
     # VL bases: arch-probe + try single-file GGUF on SUPPORTED (with
1779
-    # fallback to HF-snapshot on refusal or subprocess failure). We
1780
-    # still need the resolved plan + cached base dir for the GGUF
1781
-    # path, so resolve those first, then let the dispatcher decide
1782
-    # whether to use them.
1779
+    # fallback to HF-snapshot on refusal or subprocess failure). A
1780
+    # missing local base snapshot should not hard-fail the whole
1781
+    # export — the dispatcher can still emit the HF-snapshot path
1782
+    # without GGUF context.
17831783
     if export_dispatch.accepts_images:
1784
+        gguf_emission_context = None
17841785
         try:
17851786
             cached_vl = download_spec(spec, local_files_only=True)
17861787
         except RuntimeError as exc:
1787
-            console.print(
1788
-                f"[red]export:[/red] base model not in local cache "
1789
-                f"— run `dlm train` first.\n  {exc}"
1790
-            )
1791
-            raise typer.Exit(code=1) from exc
1788
+            _ = exc
1789
+        else:
1790
+            gguf_emission_context = {
1791
+                "plan": plan,
1792
+                "cached_base_dir": cached_vl.path,
1793
+                "source_dlm_path": path.resolve(),
1794
+                "training_sequence_len": parsed.frontmatter.training.sequence_len,
1795
+                "dlm_version": f"v{parsed.frontmatter.dlm_version}",
1796
+            }
17921797
         try:
17931798
             dispatch_result = export_dispatch.dispatch_export(
17941799
                 store=store,
@@ -1797,13 +1802,7 @@ def export_cmd(
17971802
                 quant=quant,
17981803
                 merged=merged,
17991804
                 adapter_mix_raw=adapter_mix,
1800
-                gguf_emission_context={
1801
-                    "plan": plan,
1802
-                    "cached_base_dir": cached_vl.path,
1803
-                    "source_dlm_path": path.resolve(),
1804
-                    "training_sequence_len": parsed.frontmatter.training.sequence_len,
1805
-                    "dlm_version": f"v{parsed.frontmatter.dlm_version}",
1806
-                },
1805
+                gguf_emission_context=gguf_emission_context,
18071806
             )
18081807
         except ExportError as exc:
18091808
             console.print(f"[red]export:[/red] {exc}")
tests/integration/audio/test_audio_training.pymodified
@@ -114,7 +114,9 @@ def test_qwen2_audio_one_cycle_end_to_end( # pragma: no cover — slow + audio
114114
 ) -> None:
115115
     """Full audio cycle: init → ingest wav → train 1 step → verify adapter."""
116116
     import dlm.train as dlm_train
117
+    from dlm.base_models import resolve as resolve_base_model
117118
     from dlm.doc.parser import parse_file
119
+    from dlm.hardware import doctor
118120
     from dlm.store.manifest import load_manifest
119121
     from dlm.store.paths import for_dlm
120122
 
@@ -133,16 +135,21 @@ def test_qwen2_audio_one_cycle_end_to_end( # pragma: no cover — slow + audio
133135
 
134136
     parsed = parse_file(doc)
135137
     store = for_dlm(parsed.frontmatter.dlm_id, home=tmp_home)
138
+    spec = resolve_base_model(parsed.frontmatter.base_model, accept_license=True)
139
+    plan = doctor(training_config=parsed.frontmatter.training).plan
140
+    if plan is None:
141
+        pytest.skip("no viable plan on this host — audio body needs a real trainer")
136142
 
137143
     # Cap steps to 1 so the test completes on commodity hardware.
138
-    result = dlm_train.run(
139
-        doc,
144
+    dlm_train.run(
145
+        store,
146
+        parsed,
147
+        spec,
148
+        plan,
140149
         mode="fresh",
141150
         seed=42,
142151
         max_steps=1,
143
-        home=tmp_home,
144152
     )
145
-    assert result is not None
146153
 
147154
     # Adapter committed under v0001/.
148155
     adapter_dir = store.resolve_current_adapter()
tests/integration/vl/test_vl_training.pymodified
@@ -133,7 +133,9 @@ def test_vl_one_cycle_end_to_end( # pragma: no cover — slow + vl
133133
     aren't locally cached.
134134
     """
135135
     import dlm.train as dlm_train
136
+    from dlm.base_models import resolve as resolve_base_model
136137
     from dlm.doc.parser import parse_file
138
+    from dlm.hardware import doctor
137139
     from dlm.store.manifest import load_manifest
138140
     from dlm.store.paths import for_dlm
139141
 
@@ -150,16 +152,21 @@ def test_vl_one_cycle_end_to_end( # pragma: no cover — slow + vl
150152
 
151153
     parsed = parse_file(doc)
152154
     store = for_dlm(parsed.frontmatter.dlm_id, home=tmp_home)
155
+    spec = resolve_base_model(parsed.frontmatter.base_model, accept_license=True)
156
+    plan = doctor(training_config=parsed.frontmatter.training).plan
157
+    if plan is None:
158
+        pytest.skip("no viable plan on this host — VL body needs a real trainer")
153159
 
154160
     # Cap steps to 1 so the test completes on commodity hardware.
155
-    result = dlm_train.run(
156
-        doc,
161
+    dlm_train.run(
162
+        store,
163
+        parsed,
164
+        spec,
165
+        plan,
157166
         mode="fresh",
158167
         seed=42,
159168
         max_steps=1,
160
-        home=tmp_home,
161169
     )
162
-    assert result is not None
163170
 
164171
     # Adapter committed under v0001/.
165172
     adapter_dir = store.resolve_current_adapter()
tests/unit/cli/test_export_vl_cache_fallback.pyadded
@@ -0,0 +1,74 @@
1
+"""CLI coverage for VL export when GGUF context is unavailable."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+from types import SimpleNamespace
7
+from typing import Any
8
+
9
+from typer.testing import CliRunner
10
+
11
+from dlm.cli.app import app
12
+
13
+
14
+def _scaffold_vl_doc(tmp_path: Path) -> Path:
15
+    doc = tmp_path / "vl.dlm"
16
+    runner = CliRunner()
17
+    result = runner.invoke(
18
+        app,
19
+        [
20
+            "--home",
21
+            str(tmp_path / "home"),
22
+            "init",
23
+            str(doc),
24
+            "--base",
25
+            "paligemma-3b-mix-224",
26
+            "--multimodal",
27
+            "--i-accept-license",
28
+        ],
29
+    )
30
+    assert result.exit_code == 0, result.output
31
+    return doc
32
+
33
+
34
+class _FakeVlDispatch:
35
+    accepts_images = True
36
+    accepts_audio = False
37
+
38
+    def __init__(self) -> None:
39
+        self.calls: list[dict[str, object]] = []
40
+
41
+    def dispatch_export(self, **kwargs: object) -> object:
42
+        self.calls.append(dict(kwargs))
43
+        return SimpleNamespace(
44
+            export_dir=Path("/tmp/hf-snapshot"),
45
+            manifest_path=Path("/tmp/hf-snapshot/snapshot_manifest.json"),
46
+            artifacts=[],
47
+            banner_lines=["[green]export:[/green] HF snapshot fallback reached"],
48
+        )
49
+
50
+
51
+def test_vl_export_without_cached_base_uses_snapshot_dispatch(
52
+    tmp_path: Path,
53
+    monkeypatch: Any,
54
+) -> None:
55
+    doc = _scaffold_vl_doc(tmp_path)
56
+    fake_dispatch = _FakeVlDispatch()
57
+
58
+    def _raise_cache_miss(*_: object, **__: object) -> object:
59
+        raise RuntimeError("google/paligemma-3b-mix-224 not found in local cache")
60
+
61
+    monkeypatch.setattr("dlm.base_models.download_spec", _raise_cache_miss)
62
+    monkeypatch.setattr("dlm.modality.modality_for", lambda _spec: fake_dispatch)
63
+
64
+    runner = CliRunner()
65
+    result = runner.invoke(
66
+        app,
67
+        ["--home", str(tmp_path / "home"), "export", str(doc)],
68
+    )
69
+
70
+    assert result.exit_code == 0, result.output
71
+    assert len(fake_dispatch.calls) == 1
72
+    assert fake_dispatch.calls[0]["gguf_emission_context"] is None
73
+    assert "HF snapshot fallback reached" in result.output
74
+    assert "base model not in local cache" not in result.output
tests/unit/cli/test_misc_commands_coverage.pyadded
@@ -0,0 +1,520 @@
1
+"""Coverage-oriented command tests for the remaining CLI surface."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+from pathlib import Path
7
+from types import SimpleNamespace
8
+from typing import Any
9
+
10
+import pytest
11
+from click.exceptions import Exit as ClickExit
12
+from typer.testing import CliRunner
13
+
14
+from dlm.cli import commands
15
+from dlm.cli.app import app
16
+from dlm.share.signing import VerifyStatus
17
+from dlm.store.paths import for_dlm
18
+
19
+
20
+def _write_minimal_dlm(
21
+    path: Path,
22
+    *,
23
+    dlm_id: str = "01KPQ9M3" + "0" * 18,
24
+    base_model: str = "smollm2-135m",
25
+) -> None:
26
+    path.write_text(
27
+        f"---\ndlm_id: {dlm_id}\ndlm_version: 12\nbase_model: {base_model}\n---\nbody\n",
28
+        encoding="utf-8",
29
+    )
30
+
31
+
32
+def _joined_output(result: object) -> str:
33
+    text = getattr(result, "output", "") + getattr(result, "stderr", "")
34
+    return " ".join(text.split())
35
+
36
+
37
+class TestMetricsAndDoctor:
38
+    def test_metrics_run_id_json_and_csv(self, tmp_path: Path, monkeypatch: Any) -> None:
39
+        doc = tmp_path / "doc.dlm"
40
+        _write_minimal_dlm(doc)
41
+        monkeypatch.setenv("DLM_HOME", str(tmp_path / "home"))
42
+
43
+        run = SimpleNamespace(
44
+            run_id=7,
45
+            phase="sft",
46
+            seed=42,
47
+            status="ok",
48
+            started_at="2026-04-21T10:00:00Z",
49
+            ended_at="2026-04-21T10:01:00Z",
50
+        )
51
+        steps = [SimpleNamespace(step=1, loss=0.5, lr=1e-4, grad_norm=0.9)]
52
+        evals = [SimpleNamespace(step=1, val_loss=0.4, perplexity=1.5)]
53
+
54
+        monkeypatch.setattr("dlm.metrics.queries.recent_runs", lambda *args, **kwargs: [run])
55
+        monkeypatch.setattr("dlm.metrics.queries.steps_for_run", lambda *args, **kwargs: steps)
56
+        monkeypatch.setattr("dlm.metrics.queries.evals_for_run", lambda *args, **kwargs: evals)
57
+        monkeypatch.setattr(
58
+            "dlm.metrics.queries.runs_to_dict",
59
+            lambda runs: [
60
+                {
61
+                    "run_id": r.run_id,
62
+                    "phase": r.phase,
63
+                    "seed": r.seed,
64
+                    "status": r.status,
65
+                }
66
+                for r in runs
67
+            ],
68
+        )
69
+        monkeypatch.setattr(
70
+            "dlm.metrics.queries.steps_to_dict",
71
+            lambda rows: [{"step": r.step, "loss": r.loss} for r in rows],
72
+        )
73
+        monkeypatch.setattr(
74
+            "dlm.metrics.queries.evals_to_dict",
75
+            lambda rows: [{"step": r.step, "val_loss": r.val_loss} for r in rows],
76
+        )
77
+
78
+        import sys
79
+        from io import StringIO
80
+
81
+        old_stdout = sys.stdout
82
+        try:
83
+            json_buf = StringIO()
84
+            sys.stdout = json_buf
85
+            commands.metrics_cmd(doc, json_out=True, run_id=7)
86
+        finally:
87
+            sys.stdout = old_stdout
88
+        payload = json.loads(json_buf.getvalue())
89
+        assert payload["run"]["run_id"] == 7
90
+        assert payload["steps"][0]["step"] == 1
91
+        assert payload["evals"][0]["val_loss"] == 0.4
92
+
93
+        old_stdout = sys.stdout
94
+        try:
95
+            csv_buf = StringIO()
96
+            sys.stdout = csv_buf
97
+            commands.metrics_cmd(doc, csv_out=True, run_id=7)
98
+        finally:
99
+            sys.stdout = old_stdout
100
+        csv_text = csv_buf.getvalue()
101
+        assert "step,loss,lr,grad_norm,val_loss" in csv_text
102
+        assert "1,0.5,0.0001,0.9,0.4" in csv_text
103
+
104
+    def test_metrics_since_parse_and_watch(
105
+        self, tmp_path: Path, monkeypatch: Any, capsys: Any
106
+    ) -> None:
107
+        doc = tmp_path / "doc.dlm"
108
+        home = tmp_path / "home"
109
+        _write_minimal_dlm(doc)
110
+        monkeypatch.setenv("DLM_HOME", str(home))
111
+
112
+        with pytest.raises(ClickExit) as excinfo:
113
+            commands.metrics_cmd(doc, since="bogus")
114
+        assert excinfo.type is ClickExit
115
+        err = capsys.readouterr().err
116
+        assert "not an integer+unit" in err
117
+
118
+        monkeypatch.setattr("dlm.metrics.queries.latest_run_id", lambda *_: 7)
119
+        monkeypatch.setattr(
120
+            "dlm.metrics.queries.steps_for_run",
121
+            lambda *args, **kwargs: [SimpleNamespace(step=3, loss=0.25, lr=1e-4, grad_norm=0.8)],
122
+        )
123
+        monkeypatch.setattr(
124
+            "dlm.metrics.queries.evals_for_run",
125
+            lambda *args, **kwargs: [SimpleNamespace(step=3, val_loss=0.2, perplexity=1.2)],
126
+        )
127
+
128
+        def _interrupt(*_: object) -> None:
129
+            raise KeyboardInterrupt
130
+
131
+        monkeypatch.setattr("time.sleep", _interrupt)
132
+        commands.metrics_watch_cmd(doc, poll_seconds=0.0)
133
+        out = capsys.readouterr().out
134
+        assert "following run_id=7" in out
135
+        assert "eval @ step 3" in out
136
+        assert "bye" in out
137
+
138
+    def test_doctor_json_and_text(self, monkeypatch: Any, capsys: Any) -> None:
139
+        fake = SimpleNamespace(to_dict=lambda: {"plan": "ok"})
140
+        monkeypatch.setattr("dlm.hardware.doctor", lambda: fake)
141
+        monkeypatch.setattr("dlm.hardware.render_text", lambda result: f"doctor text: {result!r}")
142
+
143
+        commands.doctor_cmd(json_out=True)
144
+        json_out = capsys.readouterr().out
145
+        assert '"plan": "ok"' in json_out
146
+
147
+        commands.doctor_cmd(json_out=False)
148
+        text_out = capsys.readouterr().out
149
+        assert "doctor text" in text_out
150
+
151
+
152
+class TestPackUnpackVerify:
153
+    def test_pack_and_unpack_success(self, tmp_path: Path, monkeypatch: Any) -> None:
154
+        runner = CliRunner()
155
+        pack_out = tmp_path / "bundle.dlm.pack"
156
+        monkeypatch.setattr(
157
+            "dlm.pack.packer.pack",
158
+            lambda *args, **kwargs: SimpleNamespace(
159
+                path=pack_out,
160
+                bytes_written=5 * 1024 * 1024,
161
+                content_type="application/dlm-pack",
162
+            ),
163
+        )
164
+        pack_result = runner.invoke(
165
+            app, ["pack", str(tmp_path / "doc.dlm"), "--out", str(pack_out)]
166
+        )
167
+        assert pack_result.exit_code == 0, pack_result.output
168
+        assert "packed:" in pack_result.output
169
+        assert "application/dlm-pack" in pack_result.output
170
+
171
+        monkeypatch.setattr(
172
+            "dlm.pack.unpacker.unpack",
173
+            lambda *args, **kwargs: SimpleNamespace(
174
+                dlm_path=tmp_path / "restored.dlm",
175
+                store_path=tmp_path / "home" / "store" / "01XYZ",
176
+                dlm_id="01XYZ",
177
+                header=SimpleNamespace(pack_format_version=2),
178
+                applied_migrations=[2, 3],
179
+            ),
180
+        )
181
+        unpack_result = runner.invoke(
182
+            app,
183
+            ["unpack", str(pack_out), "--out", str(tmp_path / "restore")],
184
+        )
185
+        assert unpack_result.exit_code == 0, unpack_result.output
186
+        assert "unpacked:" in unpack_result.output
187
+        assert "migrated: v2" in unpack_result.output
188
+
189
+    def test_verify_success_and_unsigned(self, tmp_path: Path, monkeypatch: Any) -> None:
190
+        runner = CliRunner()
191
+        pack_path = tmp_path / "bundle.dlm.pack"
192
+        pack_path.write_bytes(b"pack")
193
+
194
+        provenance = SimpleNamespace(
195
+            adapter_sha256="a" * 64,
196
+            base_revision="main",
197
+            corpus_root_sha256="b" * 64,
198
+            signed_at="2026-04-21T12:00:00Z",
199
+        )
200
+        monkeypatch.setattr(
201
+            "dlm.pack.unpacker.read_pack_member_bytes", lambda *args, **kwargs: b"{}"
202
+        )
203
+        monkeypatch.setattr("dlm.share.provenance.load_provenance_json", lambda *_: provenance)
204
+        monkeypatch.setattr(
205
+            "dlm.share.provenance.verify_provenance",
206
+            lambda *args, **kwargs: SimpleNamespace(
207
+                signer_fingerprint="FINGERPRINT",
208
+                trusted_key_path=tmp_path / "trusted.pub",
209
+                tofu_recorded=True,
210
+            ),
211
+        )
212
+
213
+        result = runner.invoke(app, ["verify", str(pack_path), "--trust-on-first-use"])
214
+        assert result.exit_code == 0, result.output
215
+        assert "verified:" in result.output
216
+        assert "recorded new trust entry" in result.output
217
+
218
+        monkeypatch.setattr(
219
+            "dlm.pack.unpacker.read_pack_member_bytes",
220
+            lambda *args, **kwargs: None,
221
+        )
222
+        unsigned = runner.invoke(app, ["verify", str(pack_path)])
223
+        assert unsigned.exit_code == 1, unsigned.output
224
+        assert "is unsigned" in unsigned.output
225
+
226
+
227
+class TestMigrateTemplatesShareAndServe:
228
+    def test_migrate_templates_push_pull_and_serve(self, tmp_path: Path, monkeypatch: Any) -> None:
229
+        runner = CliRunner()
230
+        doc = tmp_path / "doc.dlm"
231
+        home = tmp_path / "home"
232
+        _write_minimal_dlm(doc)
233
+
234
+        monkeypatch.setattr(
235
+            "dlm.doc.migrate.migrate_file",
236
+            lambda *args, **kwargs: SimpleNamespace(
237
+                applied=[11],
238
+                target_version=12,
239
+                backup_path=tmp_path / "doc.dlm.bak",
240
+            ),
241
+        )
242
+        migrate_result = runner.invoke(app, ["migrate", str(doc)])
243
+        assert migrate_result.exit_code == 0, migrate_result.output
244
+        assert "migrated:" in migrate_result.output
245
+
246
+        template = SimpleNamespace(
247
+            name="starter",
248
+            meta=SimpleNamespace(
249
+                title="Starter",
250
+                domain_tags=("docs",),
251
+                recommended_base="smollm2-135m",
252
+                expected_steps=25,
253
+                expected_duration={"minutes": 5},
254
+                summary="Starter template",
255
+                sample_prompts=("hello",),
256
+            ),
257
+        )
258
+        monkeypatch.setattr("dlm.templates.list_bundled", lambda: [template])
259
+        templates_result = runner.invoke(app, ["templates", "list", "--json"])
260
+        assert templates_result.exit_code == 0, templates_result.output
261
+        assert '"name":"starter"' in templates_result.output.replace(" ", "")
262
+
263
+        templates_text = runner.invoke(app, ["templates", "list"])
264
+        assert templates_text.exit_code == 0, templates_text.output
265
+        assert "Starter" in templates_text.output
266
+        assert "smollm2-135m" in templates_text.output
267
+
268
+        from dlm.share.sinks import SinkKind
269
+
270
+        monkeypatch.setattr(
271
+            "dlm.share.push",
272
+            lambda *args, **kwargs: SimpleNamespace(
273
+                destination="hf:org/repo",
274
+                bytes_sent=3 * 1024 * 1024,
275
+                sink_kind=SinkKind.HF,
276
+                detail="done",
277
+            ),
278
+        )
279
+        push_result = runner.invoke(
280
+            app,
281
+            ["push", str(doc), "--to", "hf:org/repo"],
282
+        )
283
+        assert push_result.exit_code == 0, push_result.output
284
+        assert "pushed:" in push_result.output
285
+        assert "dlm pull hf:org/repo" in push_result.output
286
+
287
+        monkeypatch.setattr(
288
+            "dlm.share.pull",
289
+            lambda *args, **kwargs: SimpleNamespace(
290
+                source="hf:org/repo",
291
+                dlm_path=tmp_path / "restored.dlm",
292
+                bytes_received=2 * 1024 * 1024,
293
+                verification=SimpleNamespace(
294
+                    status=VerifyStatus.VERIFIED,
295
+                    key_path=tmp_path / "trusted.pub",
296
+                    detail="",
297
+                ),
298
+            ),
299
+        )
300
+        pull_result = runner.invoke(app, ["pull", "hf:org/repo"])
301
+        assert pull_result.exit_code == 0, pull_result.output
302
+        assert "pulled:" in pull_result.output
303
+        assert "verified:" in pull_result.output
304
+
305
+        store = for_dlm("01KPQ9M3" + "0" * 18, home=home)
306
+        store.ensure_layout()
307
+        store.manifest.write_text("{}", encoding="utf-8")
308
+
309
+        def _fake_pack(path: Path, *, out: Path) -> None:
310
+            out.write_bytes(b"pack")
311
+
312
+        handle = SimpleNamespace(
313
+            bind_host="127.0.0.1",
314
+            port=7337,
315
+            peer_url="peer://127.0.0.1:7337/01KPQ9M3",
316
+            wait_shutdown=lambda: None,
317
+        )
318
+        monkeypatch.setattr("dlm.pack.packer.pack", _fake_pack)
319
+        monkeypatch.setattr("dlm.share.serve", lambda *args, **kwargs: handle)
320
+        serve_result = runner.invoke(
321
+            app,
322
+            ["--home", str(home), "serve", str(doc)],
323
+        )
324
+        assert serve_result.exit_code == 0, serve_result.output
325
+        assert "serving:" in serve_result.output
326
+        assert "peer URL:" in serve_result.output
327
+
328
+    def test_templates_refresh_empty_gallery_and_pull_statuses(
329
+        self,
330
+        tmp_path: Path,
331
+        monkeypatch: Any,
332
+    ) -> None:
333
+        runner = CliRunner()
334
+
335
+        from dlm.templates.fetcher import RemoteFetchUnavailable
336
+
337
+        monkeypatch.setattr(
338
+            "dlm.templates.fetcher.fetch_all",
339
+            lambda *args, **kwargs: (_ for _ in ()).throw(RemoteFetchUnavailable("offline")),
340
+        )
341
+        monkeypatch.setattr("dlm.templates.fetcher.cache_dir", lambda: tmp_path / "cache")
342
+        monkeypatch.setattr("dlm.templates.list_bundled", lambda: [])
343
+        empty = runner.invoke(app, ["templates", "list", "--refresh"])
344
+        assert empty.exit_code == 1, empty.output
345
+        joined = _joined_output(empty)
346
+        assert "Falling back to the bundled gallery" in joined
347
+        assert "no bundled templates found" in joined
348
+
349
+        monkeypatch.setattr(
350
+            "dlm.share.pull",
351
+            lambda *args, **kwargs: SimpleNamespace(
352
+                source="hf:org/repo",
353
+                dlm_path=tmp_path / "restored.dlm",
354
+                bytes_received=1 * 1024 * 1024,
355
+                verification=SimpleNamespace(
356
+                    status=VerifyStatus.UNVERIFIED,
357
+                    key_path=None,
358
+                    detail="missing key",
359
+                ),
360
+            ),
361
+        )
362
+        unverified = runner.invoke(app, ["pull", "hf:org/repo"])
363
+        assert unverified.exit_code == 0, unverified.output
364
+        assert "unverified:" in unverified.output
365
+
366
+        monkeypatch.setattr(
367
+            "dlm.share.pull",
368
+            lambda *args, **kwargs: SimpleNamespace(
369
+                source="hf:org/repo",
370
+                dlm_path=tmp_path / "restored.dlm",
371
+                bytes_received=1 * 1024 * 1024,
372
+                verification=SimpleNamespace(
373
+                    status=VerifyStatus.UNSIGNED,
374
+                    key_path=None,
375
+                    detail="",
376
+                ),
377
+            ),
378
+        )
379
+        unsigned = runner.invoke(app, ["pull", "hf:org/repo"])
380
+        assert unsigned.exit_code == 0, unsigned.output
381
+        assert "unsigned" in unsigned.output
382
+
383
+
384
+class TestExportAndCacheCoverage:
385
+    def test_export_standard_path_success_and_errors(
386
+        self, tmp_path: Path, monkeypatch: Any
387
+    ) -> None:
388
+        doc = tmp_path / "doc.dlm"
389
+        runner = CliRunner()
390
+        result = runner.invoke(
391
+            app,
392
+            [
393
+                "--home",
394
+                str(tmp_path / "home"),
395
+                "init",
396
+                str(doc),
397
+                "--base",
398
+                "smollm2-135m",
399
+            ],
400
+        )
401
+        assert result.exit_code == 0, result.output
402
+
403
+        cached = SimpleNamespace(path=tmp_path / "base")
404
+        monkeypatch.setattr("dlm.base_models.download_spec", lambda *args, **kwargs: cached)
405
+        monkeypatch.setattr(
406
+            "dlm.export.run_export",
407
+            lambda *args, **kwargs: SimpleNamespace(
408
+                cached=True,
409
+                export_dir=tmp_path / "exports" / "Q4_K_M",
410
+                artifacts=[SimpleNamespace(name="base.gguf"), SimpleNamespace(name="adapter.gguf")],
411
+                ollama_name="my-model",
412
+                ollama_version=1,
413
+                smoke_output_first_line="hello",
414
+            ),
415
+        )
416
+        export_ok = runner.invoke(
417
+            app,
418
+            ["--home", str(tmp_path / "home"), "export", str(doc), "--skip-ollama"],
419
+        )
420
+        assert export_ok.exit_code == 0, export_ok.output
421
+        assert "exported:" in export_ok.output
422
+        assert "ollama:" in export_ok.output
423
+        assert "smoke:" in export_ok.output
424
+
425
+        monkeypatch.setattr(
426
+            "dlm.base_models.download_spec",
427
+            lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("offline cache miss")),
428
+        )
429
+        cache_miss = runner.invoke(app, ["--home", str(tmp_path / "home"), "export", str(doc)])
430
+        assert cache_miss.exit_code == 1, cache_miss.output
431
+        assert "base model not in local cache" in cache_miss.output
432
+
433
+        from dlm.export.ollama.errors import OllamaBinaryNotFoundError
434
+
435
+        monkeypatch.setattr("dlm.base_models.download_spec", lambda *args, **kwargs: cached)
436
+        monkeypatch.setattr(
437
+            "dlm.export.run_export",
438
+            lambda *args, **kwargs: (_ for _ in ()).throw(OllamaBinaryNotFoundError("missing")),
439
+        )
440
+        no_ollama = runner.invoke(app, ["--home", str(tmp_path / "home"), "export", str(doc)])
441
+        assert no_ollama.exit_code == 1, no_ollama.output
442
+        assert "install from https://ollama.com/download" in no_ollama.output
443
+
444
+    def test_cache_show_prune_and_clear(self, tmp_path: Path, monkeypatch: Any) -> None:
445
+        doc = tmp_path / "doc.dlm"
446
+        _write_minimal_dlm(doc)
447
+
448
+        class _FakeCache:
449
+            def __init__(self, entry_count: int, total_bytes: int) -> None:
450
+                self.entry_count = entry_count
451
+                self.total_bytes = total_bytes
452
+
453
+            def prune(self, *, older_than_seconds: float) -> int:
454
+                self.pruned_seconds = older_than_seconds
455
+                return 2
456
+
457
+            def clear(self) -> int:
458
+                return 5
459
+
460
+            def save_manifest(self) -> None:
461
+                return None
462
+
463
+        fake_cache = _FakeCache(entry_count=5, total_bytes=2048)
464
+        monkeypatch.setattr("dlm.directives.cache.TokenizedCache.open", lambda *_: fake_cache)
465
+        monkeypatch.setattr(
466
+            "dlm.metrics.queries.latest_tokenization",
467
+            lambda *_: SimpleNamespace(run_id=9, hit_rate=0.75, cache_hits=3, cache_misses=1),
468
+        )
469
+
470
+        runner = CliRunner()
471
+        show_json = runner.invoke(
472
+            app,
473
+            ["--home", str(tmp_path / "home"), "cache", "show", str(doc), "--json"],
474
+        )
475
+        assert show_json.exit_code == 0, show_json.output
476
+        assert '"entry_count": 5' in show_json.output
477
+
478
+        monkeypatch.setattr("dlm.metrics.queries.latest_tokenization", lambda *_: None)
479
+        show_text = runner.invoke(
480
+            app,
481
+            ["--home", str(tmp_path / "home"), "cache", "show", str(doc)],
482
+        )
483
+        assert show_text.exit_code == 0, show_text.output
484
+        assert "Cache for" in show_text.output
485
+        assert "no tokenization runs yet" in show_text.output
486
+
487
+        prune_bad = runner.invoke(
488
+            app,
489
+            ["--home", str(tmp_path / "home"), "cache", "prune", str(doc), "--older-than", "bad"],
490
+        )
491
+        assert prune_bad.exit_code == 2, prune_bad.output
492
+        assert "invalid --older-than" in prune_bad.output
493
+
494
+        prune_ok = runner.invoke(
495
+            app,
496
+            ["--home", str(tmp_path / "home"), "cache", "prune", str(doc)],
497
+        )
498
+        assert prune_ok.exit_code == 0, prune_ok.output
499
+        assert "older than 90d" in prune_ok.output
500
+
501
+        monkeypatch.setattr("typer.confirm", lambda prompt: False)
502
+        clear_cancel = runner.invoke(
503
+            app,
504
+            ["--home", str(tmp_path / "home"), "cache", "clear", str(doc)],
505
+        )
506
+        assert clear_cancel.exit_code == 0, clear_cancel.output
507
+        assert "clear cancelled" in clear_cancel.output
508
+
509
+        clear_force = runner.invoke(
510
+            app,
511
+            ["--home", str(tmp_path / "home"), "cache", "clear", str(doc), "--force"],
512
+        )
513
+        assert clear_force.exit_code == 0, clear_force.output
514
+        assert "cleared 5" in clear_force.output
515
+
516
+    def test_parse_duration_helper(self) -> None:
517
+        assert commands._parse_duration("30m") == 1800.0
518
+        assert commands._parse_duration("2h") == 7200.0
519
+        assert commands._parse_duration("1d") == 86400.0
520
+        assert commands._parse_duration("7x") is None
tests/unit/cli/test_train_prompt_repl_coverage.pyadded
@@ -0,0 +1,465 @@
1
+"""Coverage-oriented tests for train/prompt/repl command bodies."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+from types import SimpleNamespace
7
+from typing import Any
8
+
9
+from typer.testing import CliRunner
10
+
11
+from dlm.cli.app import app
12
+
13
+
14
+def _init_doc(tmp_path: Path, *, base: str = "smollm2-135m") -> Path:
15
+    doc = tmp_path / "doc.dlm"
16
+    runner = CliRunner()
17
+    result = runner.invoke(
18
+        app,
19
+        [
20
+            "--home",
21
+            str(tmp_path / "home"),
22
+            "init",
23
+            str(doc),
24
+            "--base",
25
+            base,
26
+        ],
27
+    )
28
+    assert result.exit_code == 0, result.output
29
+    return doc
30
+
31
+
32
+def _fake_doctor_result() -> object:
33
+    return SimpleNamespace(plan=object(), capabilities=object())
34
+
35
+
36
+class TestTrainCommandCoverage:
37
+    def test_train_success_prints_phase_summary(self, tmp_path: Path, monkeypatch: Any) -> None:
38
+        doc = _init_doc(tmp_path)
39
+        runner = CliRunner()
40
+
41
+        fake_result = SimpleNamespace(
42
+            adapter_version=1,
43
+            steps=3,
44
+            seed=42,
45
+            determinism=SimpleNamespace(class_="strict"),
46
+            adapter_path=tmp_path / "adapter",
47
+            log_path=tmp_path / "train.jsonl",
48
+            final_train_loss=0.125,
49
+        )
50
+        fake_phase = SimpleNamespace(phase="sft", result=fake_result)
51
+
52
+        monkeypatch.setattr("dlm.hardware.doctor", lambda **kwargs: _fake_doctor_result())
53
+        monkeypatch.setattr("dlm.train.distributed.detect_world_size", lambda: 1)
54
+        monkeypatch.setattr(
55
+            "dlm.train.preference.phase_orchestrator.run_phases",
56
+            lambda *args, **kwargs: [fake_phase],
57
+        )
58
+
59
+        result = runner.invoke(
60
+            app,
61
+            ["--home", str(tmp_path / "home"), "train", str(doc), "--max-steps", "3"],
62
+        )
63
+        assert result.exit_code == 0, result.output
64
+        assert "sft:" in result.output
65
+        assert "adapter:" in result.output
66
+        assert "0.125" in result.output
67
+
68
+    def test_train_watch_with_rpc_starts_server(self, tmp_path: Path, monkeypatch: Any) -> None:
69
+        doc = _init_doc(tmp_path)
70
+        runner = CliRunner()
71
+
72
+        fake_result = SimpleNamespace(
73
+            adapter_version=1,
74
+            steps=1,
75
+            seed=7,
76
+            determinism=SimpleNamespace(class_="strict"),
77
+            adapter_path=tmp_path / "adapter",
78
+            log_path=tmp_path / "train.jsonl",
79
+            final_train_loss=None,
80
+        )
81
+        fake_phase = SimpleNamespace(phase="sft", result=fake_result)
82
+
83
+        class _FakeQueue:
84
+            capacity = 123
85
+
86
+            def drain(self) -> list[object]:
87
+                return []
88
+
89
+        class _FakeServer:
90
+            def __init__(self, *, host: str, port: int, token: str, queue: object) -> None:
91
+                self.address = (host, port)
92
+
93
+            def start(self) -> None:
94
+                return None
95
+
96
+            def stop(self) -> None:
97
+                return None
98
+
99
+        monkeypatch.setenv("DLM_PROBE_TOKEN", "secret")
100
+        monkeypatch.setattr("dlm.hardware.doctor", lambda **kwargs: _fake_doctor_result())
101
+        monkeypatch.setattr("dlm.train.distributed.detect_world_size", lambda: 1)
102
+        monkeypatch.setattr(
103
+            "dlm.train.preference.phase_orchestrator.run_phases",
104
+            lambda *args, **kwargs: [fake_phase],
105
+        )
106
+        monkeypatch.setattr("dlm.train.inject.InjectedProbeQueue", _FakeQueue)
107
+        monkeypatch.setattr("dlm.train.rpc.ProbeRpcServer", _FakeServer)
108
+        monkeypatch.setattr("dlm.watch.loop.run_watch", lambda *args, **kwargs: 0)
109
+
110
+        result = runner.invoke(
111
+            app,
112
+            [
113
+                "--home",
114
+                str(tmp_path / "home"),
115
+                "train",
116
+                str(doc),
117
+                "--watch",
118
+                "--listen-rpc",
119
+                "127.0.0.1:7777",
120
+            ],
121
+        )
122
+        assert result.exit_code == 0, result.output
123
+        assert "rpc:" in result.output
124
+        assert "watch:" in result.output
125
+
126
+    def test_train_noop_watch_repl_and_bounded_rpc_refusals(
127
+        self,
128
+        tmp_path: Path,
129
+        monkeypatch: Any,
130
+    ) -> None:
131
+        doc = _init_doc(tmp_path)
132
+        runner = CliRunner()
133
+
134
+        monkeypatch.setattr("dlm.hardware.doctor", lambda **kwargs: _fake_doctor_result())
135
+        monkeypatch.setattr("dlm.train.distributed.detect_world_size", lambda: 1)
136
+        monkeypatch.setattr(
137
+            "dlm.train.preference.phase_orchestrator.run_phases",
138
+            lambda *args, **kwargs: [],
139
+        )
140
+
141
+        no_op = runner.invoke(
142
+            app,
143
+            ["--home", str(tmp_path / "home"), "train", str(doc)],
144
+        )
145
+        assert no_op.exit_code == 0, no_op.output
146
+        assert "nothing to train" in no_op.output
147
+
148
+        fake_result = SimpleNamespace(
149
+            adapter_version=1,
150
+            steps=1,
151
+            seed=42,
152
+            determinism=SimpleNamespace(class_="strict"),
153
+            adapter_path=tmp_path / "adapter",
154
+            log_path=tmp_path / "train.jsonl",
155
+            final_train_loss=None,
156
+        )
157
+        fake_phase = SimpleNamespace(phase="sft", result=fake_result)
158
+        monkeypatch.setattr(
159
+            "dlm.train.preference.phase_orchestrator.run_phases",
160
+            lambda *args, **kwargs: [fake_phase],
161
+        )
162
+
163
+        watch_repl = runner.invoke(
164
+            app,
165
+            ["--home", str(tmp_path / "home"), "train", str(doc), "--watch", "--repl"],
166
+        )
167
+        assert watch_repl.exit_code == 2, watch_repl.output
168
+        assert "not yet implemented" in watch_repl.output
169
+
170
+        monkeypatch.setenv("DLM_PROBE_TOKEN", "secret")
171
+        bounded_rpc = runner.invoke(
172
+            app,
173
+            [
174
+                "--home",
175
+                str(tmp_path / "home"),
176
+                "train",
177
+                str(doc),
178
+                "--listen-rpc",
179
+                "127.0.0.1:7777",
180
+                "--max-cycles",
181
+                "1",
182
+            ],
183
+        )
184
+        assert bounded_rpc.exit_code == 2, bounded_rpc.output
185
+        assert "--watch for now" in bounded_rpc.output
186
+
187
+    def test_multi_gpu_helper_and_strip(self, monkeypatch: Any) -> None:
188
+        from rich.console import Console
189
+
190
+        from dlm.cli.commands import _maybe_dispatch_multi_gpu, _strip_gpus_from_argv
191
+        from dlm.train.distributed import UnsupportedGpuSpecError
192
+
193
+        class _GpuSpec:
194
+            def __init__(self, device_ids: tuple[int, ...]) -> None:
195
+                self._device_ids = device_ids
196
+
197
+            def resolve(self, device_count: int) -> tuple[int, ...]:
198
+                return self._device_ids
199
+
200
+        console = Console(stderr=True)
201
+        monkeypatch.setattr(
202
+            "dlm.train.distributed.parse_gpus",
203
+            lambda raw: (_ for _ in ()).throw(UnsupportedGpuSpecError("bad gpus")),
204
+        )
205
+        assert _maybe_dispatch_multi_gpu("bogus", ["dlm", "train"], console) == 2
206
+
207
+        monkeypatch.setattr("dlm.train.distributed.parse_gpus", lambda raw: _GpuSpec((0,)))
208
+        import torch
209
+
210
+        monkeypatch.setattr(torch.cuda, "device_count", lambda: 2)
211
+        assert _maybe_dispatch_multi_gpu("1", ["dlm", "train"], console) is None
212
+
213
+        launched: dict[str, object] = {}
214
+        monkeypatch.setattr("dlm.train.distributed.parse_gpus", lambda raw: _GpuSpec((1, 3)))
215
+        monkeypatch.setattr(
216
+            "dlm.train.distributed.launch_multi_gpu",
217
+            lambda device_ids, cli_args, mixed_precision="bf16": (
218
+                launched.update(
219
+                    {
220
+                        "device_ids": device_ids,
221
+                        "cli_args": cli_args,
222
+                        "mixed_precision": mixed_precision,
223
+                    }
224
+                )
225
+                or 17
226
+            ),
227
+        )
228
+        exit_code = _maybe_dispatch_multi_gpu(
229
+            "1,3",
230
+            ["dlm", "train", "doc.dlm", "--gpus", "1,3"],
231
+            console,
232
+        )
233
+        assert exit_code == 17
234
+        assert launched["device_ids"] == (1, 3)
235
+        assert launched["cli_args"] == ["train", "doc.dlm"]
236
+        assert _strip_gpus_from_argv(["dlm", "train", "--gpus=0,1", "doc.dlm"]) == [
237
+            "train",
238
+            "doc.dlm",
239
+        ]
240
+
241
+    def test_train_error_mappings(self, tmp_path: Path, monkeypatch: Any) -> None:
242
+        doc = _init_doc(tmp_path)
243
+        runner = CliRunner()
244
+
245
+        from dlm.lock.errors import LockValidationError
246
+        from dlm.train.errors import DiskSpaceError, OOMError, ResumeIntegrityError, TrainingError
247
+        from dlm.train.preference.errors import (
248
+            DpoPhaseError,
249
+            NoPreferenceContentError,
250
+            PriorAdapterRequiredError,
251
+        )
252
+
253
+        monkeypatch.setattr("dlm.hardware.doctor", lambda **kwargs: _fake_doctor_result())
254
+        monkeypatch.setattr("dlm.train.distributed.detect_world_size", lambda: 1)
255
+
256
+        cases = [
257
+            (
258
+                LockValidationError(path=tmp_path / "dlm.lock", reasons=["torch drift"]),
259
+                "Re-run with",
260
+            ),
261
+            (DiskSpaceError(required_bytes=2_000_000_000, free_bytes=1_000_000_000), "disk:"),
262
+            (ResumeIntegrityError("resume mismatch"), "resume:"),
263
+            (NoPreferenceContentError("no preferences"), "dpo:"),
264
+            (PriorAdapterRequiredError("need prior adapter"), "dpo:"),
265
+            (DpoPhaseError("dpo failed"), "dpo:"),
266
+            (TrainingError("trainer failed"), "training:"),
267
+        ]
268
+        for error, needle in cases:
269
+            monkeypatch.setattr(
270
+                "dlm.train.preference.phase_orchestrator.run_phases",
271
+                lambda *args, _error=error, **kwargs: (_ for _ in ()).throw(_error),
272
+            )
273
+            result = runner.invoke(
274
+                app,
275
+                ["--home", str(tmp_path / "home"), "train", str(doc)],
276
+            )
277
+            assert result.exit_code == 1, result.output
278
+            assert needle in result.output
279
+
280
+        monkeypatch.setattr(
281
+            "dlm.train.preference.phase_orchestrator.run_phases",
282
+            lambda *args, **kwargs: (_ for _ in ()).throw(
283
+                OOMError(
284
+                    step=5,
285
+                    peak_bytes=2_000,
286
+                    free_at_start_bytes=4_000,
287
+                    current_grad_accum=1,
288
+                    recommended_grad_accum=4,
289
+                )
290
+            ),
291
+        )
292
+        monkeypatch.setattr("dlm.train.format_oom_message", lambda **kwargs: "OOM advice")
293
+        oom = runner.invoke(
294
+            app,
295
+            ["--home", str(tmp_path / "home"), "train", str(doc)],
296
+        )
297
+        assert oom.exit_code == 1, oom.output
298
+        assert "OOM advice" in oom.output
299
+
300
+
301
+class TestPromptAndReplCoverage:
302
+    def test_prompt_text_backend_reads_stdin_and_generates(
303
+        self, tmp_path: Path, monkeypatch: Any
304
+    ) -> None:
305
+        doc = _init_doc(tmp_path)
306
+        runner = CliRunner()
307
+
308
+        class _FakeBackend:
309
+            def load(self, spec: object, store: object, adapter_name: str | None = None) -> None:
310
+                return None
311
+
312
+            def generate(self, query: str, **kwargs: object) -> str:
313
+                return f"reply:{query}"
314
+
315
+        monkeypatch.setattr("dlm.hardware.doctor", lambda: SimpleNamespace(capabilities=object()))
316
+        monkeypatch.setattr(
317
+            "dlm.inference.backends.select_backend", lambda *args, **kwargs: "pytorch"
318
+        )
319
+        monkeypatch.setattr(
320
+            "dlm.inference.backends.build_backend", lambda *args, **kwargs: _FakeBackend()
321
+        )
322
+
323
+        result = runner.invoke(
324
+            app,
325
+            ["--home", str(tmp_path / "home"), "prompt", str(doc)],
326
+            input="hello from stdin\n",
327
+        )
328
+        assert result.exit_code == 0, result.output
329
+        assert "reply:hello from stdin" in result.output
330
+
331
+    def test_repl_success_and_adapter_validation(self, tmp_path: Path, monkeypatch: Any) -> None:
332
+        doc = _init_doc(tmp_path)
333
+        runner = CliRunner()
334
+
335
+        adapter_bad = runner.invoke(
336
+            app,
337
+            ["--home", str(tmp_path / "home"), "repl", str(doc), "--adapter", "knowledge"],
338
+        )
339
+        assert adapter_bad.exit_code == 2, adapter_bad.output
340
+        assert "only valid on multi-adapter" in adapter_bad.output
341
+
342
+        class _FakeBackend:
343
+            def __init__(self) -> None:
344
+                self._loaded = SimpleNamespace(tokenizer="tok")
345
+
346
+            def load(self, spec: object, store: object, adapter_name: str | None = None) -> None:
347
+                return None
348
+
349
+        monkeypatch.setattr("dlm.hardware.doctor", lambda: SimpleNamespace(capabilities=object()))
350
+        monkeypatch.setattr(
351
+            "dlm.inference.backends.select_backend", lambda *args, **kwargs: "pytorch"
352
+        )
353
+        monkeypatch.setattr(
354
+            "dlm.inference.backends.build_backend", lambda *args, **kwargs: _FakeBackend()
355
+        )
356
+        monkeypatch.setattr("dlm.repl.app.run_repl", lambda session, console: 5)
357
+
358
+        repl_ok = runner.invoke(
359
+            app,
360
+            ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "pytorch"],
361
+        )
362
+        assert repl_ok.exit_code == 5, repl_ok.output
363
+
364
+    def test_repl_error_mappings(self, tmp_path: Path, monkeypatch: Any) -> None:
365
+        doc = _init_doc(tmp_path)
366
+        runner = CliRunner()
367
+
368
+        from dlm.base_models.errors import GatedModelError
369
+        from dlm.inference import AdapterNotFoundError
370
+        from dlm.inference.backends.select import UnsupportedBackendError
371
+
372
+        original = doc.read_text(encoding="utf-8")
373
+        fm_end = original.find("\n---\n", original.find("---") + 3)
374
+        multi = tmp_path / "multi.dlm"
375
+        multi.write_text(
376
+            original[:fm_end] + "\ntraining:\n  adapters:\n    knowledge: {}\n" + original[fm_end:],
377
+            encoding="utf-8",
378
+        )
379
+        unknown = runner.invoke(
380
+            app,
381
+            ["--home", str(tmp_path / "home"), "repl", str(multi), "--adapter", "ghost"],
382
+        )
383
+        assert unknown.exit_code == 2, unknown.output
384
+        assert "not declared" in unknown.output
385
+
386
+        monkeypatch.setattr(
387
+            "dlm.base_models.resolve",
388
+            lambda *args, **kwargs: (_ for _ in ()).throw(
389
+                GatedModelError("hf/model", "https://license")
390
+            ),
391
+        )
392
+        gated = runner.invoke(
393
+            app,
394
+            ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "pytorch"],
395
+        )
396
+        assert gated.exit_code == 1, gated.output
397
+        assert "run `dlm train --i-accept-license` first" in gated.output
398
+
399
+        monkeypatch.setattr("dlm.base_models.resolve", lambda *args, **kwargs: SimpleNamespace())
400
+        monkeypatch.setattr("dlm.hardware.doctor", lambda: SimpleNamespace(capabilities=object()))
401
+        monkeypatch.setattr(
402
+            "dlm.inference.backends.select_backend",
403
+            lambda *args, **kwargs: (_ for _ in ()).throw(
404
+                UnsupportedBackendError("backend not available")
405
+            ),
406
+        )
407
+        unsupported = runner.invoke(
408
+            app,
409
+            ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "pytorch"],
410
+        )
411
+        assert unsupported.exit_code == 2, unsupported.output
412
+        assert "backend not available" in unsupported.output
413
+
414
+        class _MissingAdapterBackend:
415
+            def load(self, spec: object, store: object, adapter_name: str | None = None) -> None:
416
+                raise AdapterNotFoundError("missing adapter")
417
+
418
+        monkeypatch.setattr(
419
+            "dlm.inference.backends.select_backend", lambda *args, **kwargs: "pytorch"
420
+        )
421
+        monkeypatch.setattr(
422
+            "dlm.inference.backends.build_backend",
423
+            lambda *args, **kwargs: _MissingAdapterBackend(),
424
+        )
425
+        missing = runner.invoke(
426
+            app,
427
+            ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "pytorch"],
428
+        )
429
+        assert missing.exit_code == 1, missing.output
430
+        assert "missing adapter" in missing.output
431
+
432
+    def test_prompt_empty_query_and_repl_invalid_backend(
433
+        self,
434
+        tmp_path: Path,
435
+        monkeypatch: Any,
436
+    ) -> None:
437
+        doc = _init_doc(tmp_path)
438
+        runner = CliRunner()
439
+
440
+        class _FakeBackend:
441
+            def load(self, spec: object, store: object, adapter_name: str | None = None) -> None:
442
+                return None
443
+
444
+        monkeypatch.setattr("dlm.hardware.doctor", lambda: SimpleNamespace(capabilities=object()))
445
+        monkeypatch.setattr(
446
+            "dlm.inference.backends.select_backend", lambda *args, **kwargs: "pytorch"
447
+        )
448
+        monkeypatch.setattr(
449
+            "dlm.inference.backends.build_backend", lambda *args, **kwargs: _FakeBackend()
450
+        )
451
+
452
+        prompt_result = runner.invoke(
453
+            app,
454
+            ["--home", str(tmp_path / "home"), "prompt", str(doc)],
455
+            input="",
456
+        )
457
+        assert prompt_result.exit_code == 2, prompt_result.output
458
+        assert "empty query" in prompt_result.output
459
+
460
+        repl_result = runner.invoke(
461
+            app,
462
+            ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "bogus"],
463
+        )
464
+        assert repl_result.exit_code == 2, repl_result.output
465
+        assert "--backend must be" in repl_result.output