test_summon.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961
  1. #!/usr/bin/env python3
  2. """Regression tests for summon.py — selection/confirmation flow + encoding safety.
  3. Hermetic: builds a throwaway Claude Desktop directory tree in a temp sandbox
  4. and points the script at it via HOME/USERPROFILE/APPDATA. No network, no real
  5. account data touched.
  6. Covers:
  7. 1. --yes does NOT bypass the selection picker (piped picks are honoured)
  8. 2. --select answers the picker non-interactively
  9. 3. --select all selects everything
  10. 4. Without --yes, declining the confirmation cancels (nothing copied)
  11. 5. Without --yes, confirming 'y' proceeds
  12. 6. --yes with empty stdin cancels instead of auto-selecting all
  13. 7. Non-cp1252 chars in session titles don't crash on cp1252 stdout
  14. Toolbox modes (rebind / recover / pick / doctor):
  15. 8. rebind rewrites cwd + rebases originCwd/worktreePath, backs up outside
  16. the store, bridges the transcript into the new munged dir, exits 0
  17. 9. rebind --dry-run touches nothing
  18. 10. rebind with unknown id exits 3; ambiguous prefix exits 2
  19. 11. rebind to a nonexistent path exits 3 without --force, 0 with it
  20. 12. doctor flags the broken-cwd session (exit 10, --json envelope), and
  21. goes healthy (exit 0) after the rebind
  22. 13. recover --no-distill emits the plain pointer prompt on stdout; transcript
  23. found via the scan fallback when the munged dir doesn't derive from the
  24. recorded cwd; no handover cache is written
  25. 14. recover with unknown id exits 3
  26. 15. pick --select N emits the recovery prompt for the Nth candidate
  27. 16. rebind into a .claude/worktrees/ path prints the `git worktree repair` hint
  28. Distilled handover (recover -> extract -> distill -> cache -> emit):
  29. 17. extract_conversation keeps user/assistant text, skips tool_use inputs and
  30. tool_result blobs (fixture JSONL); respects the char budget with the
  31. verbatim tail winning
  32. 18. recover distills via a PATH-shimmed fake `claude`, emits brief + pointer
  33. clause, caches at <transcript>.handover.md
  34. 19. cache hit: unchanged transcript reuses the cached brief (no re-distill);
  35. --refresh forces re-distillation; a newer transcript mtime busts the cache
  36. 20. degrade: `claude` absent from PATH -> plain pointer prompt, exit 0,
  37. stderr warning; failing `claude` (exit 1) -> same advisory fallback
  38. Pick --json (the in-chat visual picker's data feed):
  39. 21. pick --json emits a parseable claude-mods.summon.pick/v1 envelope on a
  40. stdout free of panel glyphs (pure-ASCII JSON, nothing before/after it)
  41. 22. worktree cwds are split into projectRoot + worktree; non-worktree cwds
  42. get worktree ""; brokenCwd flags the session whose recorded cwd is gone;
  43. transcriptPath is resolved (scan fallback included) and ISO timestamps
  44. parse
  45. In-chat picker asset:
  46. 23. assets/picker-widget.html exists, carries the <script id="D"> injection
  47. block + sendPrompt wiring, and is cited from SKILL.md
  48. Widget builder (summon widget -> finished card-picker HTML):
  49. 24. replaces the <script id="D"> payload, drops 0-turn stubs by default
  50. (--include-stubs keeps them), honours --limit, keeps only whitelisted
  51. keys, emits one session object per line, downsamples density 24->12
  52. (--full-density keeps 24), sets the window <select>, and holds the HTML
  53. under the byte budget (capping with a stderr note under a tight --max-kb)
  54. """
  55. from __future__ import annotations
  56. import json
  57. import os
  58. import subprocess
  59. import sys
  60. import tempfile
  61. import time
  62. import shutil
  63. from pathlib import Path
  64. HERE = Path(__file__).resolve().parent
  65. SCRIPT = HERE.parent / "scripts" / "summon.py"
  66. SRC_UUID = "aaaaaaaa-1111-4111-8111-111111111111"
  67. DEST_UUID = "bbbbbbbb-2222-4222-8222-222222222222"
  68. PASS = 0
  69. FAIL = 0
  70. def ok(name: str) -> None:
  71. global PASS
  72. PASS += 1
  73. print(f" PASS {name}")
  74. def no(name: str, detail: str = "") -> None:
  75. global FAIL
  76. FAIL += 1
  77. print(f" FAIL {name}" + (f" — {detail}" if detail else ""))
  78. def claude_dir(env: dict) -> Path:
  79. if sys.platform == "win32":
  80. return Path(env["APPDATA"]) / "Claude"
  81. if sys.platform == "darwin":
  82. return Path(env["HOME"]) / "Library/Application Support/Claude"
  83. return Path(env["HOME"]) / ".config/Claude"
  84. def build_sandbox(tmp: Path, titles: list[str]) -> tuple[dict, Path, Path]:
  85. """Create src account (len(titles) sessions, newest first = #1) + dest account."""
  86. home = tmp / "home"
  87. appdata = home / "AppData" / "Roaming"
  88. env = os.environ.copy()
  89. env["HOME"] = str(home)
  90. env["USERPROFILE"] = str(home)
  91. env["APPDATA"] = str(appdata)
  92. env.pop("PYTHONIOENCODING", None)
  93. env.pop("TERM_ASCII", None)
  94. cdir = claude_dir(env)
  95. now_ms = int(time.time() * 1000)
  96. src_ws = cdir / "claude-code-sessions" / SRC_UUID / "11111111-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
  97. src_ws.mkdir(parents=True)
  98. for i, title in enumerate(titles):
  99. sid = f"src-sess-{i}"
  100. (src_ws / f"local_{sid}.json").write_text(json.dumps({
  101. "sessionId": f"local_{sid}",
  102. "cliSessionId": f"cli-{sid}",
  103. "title": title,
  104. "cwd": "", # no cwd -> no transcript lookup -> copy proceeds
  105. "lastActivityAt": now_ms - (i + 1) * 60_000, # newest first
  106. "completedTurns": 5 + i,
  107. }), encoding="utf-8")
  108. dest_ws = cdir / "claude-code-sessions" / DEST_UUID / "22222222-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
  109. dest_ws.mkdir(parents=True)
  110. (dest_ws / "local_dest-own.json").write_text(json.dumps({
  111. "sessionId": "local_dest-own",
  112. "cliSessionId": "cli-dest-own",
  113. "title": "dest resident",
  114. "cwd": "",
  115. "lastActivityAt": now_ms,
  116. "completedTurns": 1,
  117. }), encoding="utf-8")
  118. return env, src_ws, dest_ws
  119. def run_summon(env: dict, extra_args: list[str], stdin_text: str = "") -> tuple[int, str]:
  120. cmd = [sys.executable, str(SCRIPT),
  121. "--to", DEST_UUID[:8], "--from", SRC_UUID[:8], "--days", "3"] + extra_args
  122. r = subprocess.run(cmd, input=stdin_text.encode("utf-8"),
  123. env=env, capture_output=True, timeout=60)
  124. out = (r.stdout.decode("utf-8", "replace")
  125. + r.stderr.decode("utf-8", "replace"))
  126. return r.returncode, out
  127. def copied_titles(dest_ws: Path) -> set[str]:
  128. titles = set()
  129. for f in dest_ws.glob("local_src-sess-*.json"):
  130. titles.add(json.loads(f.read_text(encoding="utf-8"))["title"])
  131. return titles
  132. def with_sandbox(titles=("alpha", "beta", "gamma")):
  133. tmp = Path(tempfile.mkdtemp(prefix="summon-test-"))
  134. env, src_ws, dest_ws = build_sandbox(tmp, list(titles))
  135. return tmp, env, src_ws, dest_ws
  136. def encode_cwd(cwd: str) -> str:
  137. """Mirror of summon.py's munging: cwd -> ~/.claude/projects/ subdir name."""
  138. return (cwd.replace(":", "-").replace("\\", "-")
  139. .replace("/", "-").replace(".", "-"))
  140. def build_toolbox_sandbox(tmp: Path) -> dict:
  141. """One account, three sessions exercising the toolbox modes.
  142. sess-a 'moved' worktree session; recorded cwd no longer exists (project
  143. moved old-root -> new-root); transcript under enc(old cwd)
  144. sess-b 'healthy' cwd exists, transcript at the expected munged path
  145. sess-c 'mismatch' cwd exists, but the transcript lives under a munged dir
  146. that does NOT derive from the recorded cwd (scan-fallback)
  147. """
  148. home = tmp / "home"
  149. appdata = home / "AppData" / "Roaming"
  150. env = os.environ.copy()
  151. env["HOME"] = str(home)
  152. env["USERPROFILE"] = str(home)
  153. env["APPDATA"] = str(appdata)
  154. env.pop("PYTHONIOENCODING", None)
  155. env.pop("TERM_ASCII", None)
  156. cdir = claude_dir(env)
  157. projects = home / ".claude" / "projects"
  158. now_ms = int(time.time() * 1000)
  159. old_root = tmp / "proj-old" # moved away -> missing
  160. new_root = tmp / "proj-new"
  161. old_wt = old_root / ".claude" / "worktrees" / "wt1"
  162. new_wt = new_root / ".claude" / "worktrees" / "wt1"
  163. new_wt.mkdir(parents=True)
  164. good = tmp / "proj-good"
  165. good.mkdir()
  166. ws = cdir / "claude-code-sessions" / SRC_UUID / "11111111-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
  167. ws.mkdir(parents=True)
  168. def wrapper(sid, cli, title, cwd, age_min, **extra):
  169. d = {"sessionId": f"local_{sid}", "cliSessionId": cli, "title": title,
  170. "cwd": cwd, "lastActivityAt": now_ms - age_min * 60_000,
  171. "completedTurns": 7}
  172. d.update(extra)
  173. (ws / f"local_{sid}.json").write_text(json.dumps(d), encoding="utf-8")
  174. wrapper("aaaa-moved", "cli-moved", "moved session", str(old_wt), 30,
  175. originCwd=str(old_root), worktreePath=str(old_wt),
  176. branch="claude/wt1")
  177. wrapper("bbbb-healthy", "cli-healthy", "healthy session", str(good), 60)
  178. wrapper("cccc-mismatch", "cli-mismatch", "mismatch session", str(good), 90)
  179. for enc_dir, cli in ((encode_cwd(str(old_wt)), "cli-moved"),
  180. (encode_cwd(str(good)), "cli-healthy"),
  181. ("X--some-unrelated-munged-dir", "cli-mismatch")):
  182. d = projects / enc_dir
  183. d.mkdir(parents=True, exist_ok=True)
  184. (d / f"{cli}.jsonl").write_text(
  185. '{"type":"user","message":{"content":"hello"}}\n', encoding="utf-8")
  186. return {"env": env, "ws": ws, "projects": projects,
  187. "old_wt": old_wt, "new_wt": new_wt, "new_root": new_root,
  188. "old_root": old_root, "home": home}
  189. def run_mode(env: dict, argv: list[str], stdin_text: str = "") -> tuple[int, str, str]:
  190. r = subprocess.run([sys.executable, str(SCRIPT)] + argv,
  191. input=stdin_text.encode("utf-8"),
  192. env=env, capture_output=True, timeout=60)
  193. return (r.returncode,
  194. r.stdout.decode("utf-8", "replace"),
  195. r.stderr.decode("utf-8", "replace"))
  196. def toolbox_tests() -> None:
  197. tmp = Path(tempfile.mkdtemp(prefix="summon-toolbox-"))
  198. try:
  199. sb = build_toolbox_sandbox(tmp)
  200. env, ws = sb["env"], sb["ws"]
  201. new_wt, new_root = sb["new_wt"], sb["new_root"]
  202. wrapper_a = ws / "local_aaaa-moved.json"
  203. # 9. dry-run first (order matters: before the real rebind)
  204. rc, out, err = run_mode(env, ["rebind", "aaaa-moved", "--cwd", str(new_wt),
  205. "--dry-run"])
  206. data = json.loads(wrapper_a.read_text(encoding="utf-8"))
  207. backups = sb["home"] / ".claude" / "summon-backups"
  208. if rc == 0 and data["cwd"] == str(sb["old_wt"]) and not backups.exists():
  209. ok("rebind --dry-run touches nothing")
  210. else:
  211. no("rebind --dry-run touches nothing",
  212. f"rc={rc} cwd={data['cwd']!r} backups={backups.exists()}")
  213. # 10a. unknown id
  214. rc, _, _ = run_mode(env, ["rebind", "zzzz-nope", "--cwd", str(new_wt)])
  215. if rc == 3:
  216. ok("rebind unknown id exits 3")
  217. else:
  218. no("rebind unknown id exits 3", f"rc={rc}")
  219. # 10b. ambiguous prefix: 'cli-' hits several cliSessionIds... use
  220. # wrapper-id ambiguity instead — 'aaaa' vs 'aaaa-moved' is unique, so
  221. # craft the collision on the shared '' prefix? No: use 'cli-m' which
  222. # prefixes cli-moved and cli-mismatch — two distinct sessions.
  223. rc, _, err = run_mode(env, ["rebind", "cli-m", "--cwd", str(new_wt)])
  224. if rc == 2 and "different sessions" in err:
  225. ok("rebind ambiguous prefix exits 2")
  226. else:
  227. no("rebind ambiguous prefix exits 2", f"rc={rc} err-tail={err[-200:]!r}")
  228. # 11. nonexistent target path
  229. ghost = tmp / "not-there-yet"
  230. rc, _, _ = run_mode(env, ["rebind", "aaaa-moved", "--cwd", str(ghost)])
  231. if rc == 3:
  232. ok("rebind to nonexistent path exits 3 without --force")
  233. else:
  234. no("rebind to nonexistent path exits 3 without --force", f"rc={rc}")
  235. # 12a. doctor pre-rebind: flags exactly the moved session, exit 10
  236. rc, out, _ = run_mode(env, ["doctor", "--json"])
  237. try:
  238. env_doc = json.loads(out)
  239. except json.JSONDecodeError:
  240. env_doc = {"data": [], "meta": {}}
  241. ids = [f["sessionId"] for f in env_doc.get("data", [])]
  242. if (rc == 10 and ids == ["local_aaaa-moved"]
  243. and env_doc["meta"].get("checked") == 3
  244. and env_doc["meta"].get("schema") == "claude-mods.summon.doctor/v1"):
  245. ok("doctor flags the broken cwd (exit 10, --json envelope)")
  246. else:
  247. no("doctor flags the broken cwd (exit 10, --json envelope)",
  248. f"rc={rc} ids={ids} meta={env_doc.get('meta')}")
  249. # 8. real rebind: wrapper rewritten, siblings rebased, backup taken,
  250. # transcript bridged into enc(new cwd)
  251. rc, out, err = run_mode(env, ["rebind", "aaaa-moved", "--cwd", str(new_wt)])
  252. data = json.loads(wrapper_a.read_text(encoding="utf-8"))
  253. resolved_new_wt = str(new_wt.resolve())
  254. resolved_new_root = str(new_root.resolve())
  255. bridged = (sb["projects"] / encode_cwd(resolved_new_wt) / "cli-moved.jsonl")
  256. original = (sb["projects"] / encode_cwd(str(sb["old_wt"])) / "cli-moved.jsonl")
  257. backup_files = list(backups.rglob("*.json")) if backups.exists() else []
  258. checks = {
  259. "rc": rc == 0,
  260. "cwd": data["cwd"] == resolved_new_wt,
  261. "originCwd": data["originCwd"] == resolved_new_root,
  262. "worktreePath": data["worktreePath"] == resolved_new_wt,
  263. "backup": len(backup_files) == 1,
  264. "bridged": bridged.exists(),
  265. "copy-not-move": original.exists(),
  266. }
  267. if all(checks.values()):
  268. ok("rebind rewrites wrapper + backup + transcript bridge")
  269. else:
  270. no("rebind rewrites wrapper + backup + transcript bridge",
  271. f"failed={[k for k, v in checks.items() if not v]} rc={rc}")
  272. # 16. rebind into a worktree path prints the `git worktree repair` hint
  273. if "git worktree repair" in out and str(new_wt.resolve()) in out:
  274. ok("rebind worktree hint present (git worktree repair)")
  275. else:
  276. no("rebind worktree hint present (git worktree repair)",
  277. f"out-tail={out[-300:]!r}")
  278. # 12b. doctor post-rebind: healthy, exit 0
  279. rc, out, _ = run_mode(env, ["doctor", "--json"])
  280. try:
  281. env_doc = json.loads(out)
  282. except json.JSONDecodeError:
  283. env_doc = {"data": ["unparsed"]}
  284. if rc == 0 and env_doc.get("data") == []:
  285. ok("doctor healthy after rebind (exit 0)")
  286. else:
  287. no("doctor healthy after rebind (exit 0)",
  288. f"rc={rc} data={env_doc.get('data')}")
  289. # 11b. --force allows a not-yet-existing path (rebind back and forth)
  290. rc, _, _ = run_mode(env, ["rebind", "aaaa-moved", "--cwd", str(ghost), "--force"])
  291. data = json.loads(wrapper_a.read_text(encoding="utf-8"))
  292. if rc == 0 and data["cwd"] == str(ghost):
  293. ok("rebind --force accepts a nonexistent path")
  294. else:
  295. no("rebind --force accepts a nonexistent path", f"rc={rc} cwd={data['cwd']!r}")
  296. # 13. recover --no-distill: plain pointer prompt on stdout, scan
  297. # fallback for the mismatched dir, no handover cache written
  298. rc, out, err = run_mode(env, ["recover", "cccc-mismatch", "--no-distill"])
  299. scan_path = sb["projects"] / "X--some-unrelated-munged-dir" / "cli-mismatch.jsonl"
  300. cache = scan_path.with_name(scan_path.name + ".handover.md")
  301. if (rc == 0 and str(scan_path) in out
  302. and "Continue a previous Claude session" in out
  303. and "TAIL of the transcript" in out
  304. and "Continue a previous" not in err
  305. and not cache.exists()):
  306. ok("recover --no-distill emits pointer prompt; scan fallback; no cache")
  307. else:
  308. no("recover --no-distill emits pointer prompt; scan fallback; no cache",
  309. f"rc={rc} cache={cache.exists()} out-head={out[:200]!r}")
  310. # 14. recover unknown id
  311. rc, _, _ = run_mode(env, ["recover", "zzzz-nope"])
  312. if rc == 3:
  313. ok("recover unknown id exits 3")
  314. else:
  315. no("recover unknown id exits 3", f"rc={rc}")
  316. # 15. pick --select 2 -> second-newest candidate (healthy session)
  317. rc, out, _ = run_mode(env, ["pick", "--select", "2", "--no-distill"])
  318. if rc == 0 and "healthy session" in out and "cli-healthy.jsonl" in out:
  319. ok("pick --select 2 recovers the 2nd candidate")
  320. else:
  321. no("pick --select 2 recovers the 2nd candidate",
  322. f"rc={rc} out-head={out[:200]!r}")
  323. finally:
  324. shutil.rmtree(tmp, ignore_errors=True)
  325. def _load_summon_module():
  326. """Import summon.py as a module for unit-testing extraction (no side effects
  327. at import time beyond terminal-capability sniffing)."""
  328. import importlib.util
  329. spec = importlib.util.spec_from_file_location("summon_under_test", SCRIPT)
  330. assert spec is not None and spec.loader is not None
  331. mod = importlib.util.module_from_spec(spec)
  332. sys.modules[spec.name] = mod # dataclass needs the module registered
  333. spec.loader.exec_module(mod)
  334. return mod
  335. def extraction_tests() -> None:
  336. """17. extract_conversation: tool blobs skipped, budget respected."""
  337. mod = _load_summon_module()
  338. tmp = Path(tempfile.mkdtemp(prefix="summon-extract-"))
  339. try:
  340. tr = tmp / "fixture.jsonl"
  341. recs = [
  342. {"type": "user", "message": {"content": "Build the frobnicator widget"}},
  343. {"type": "assistant", "message": {"content": [
  344. {"type": "text", "text": "Plan: refactor the gadget first"},
  345. {"type": "tool_use", "name": "Bash",
  346. "input": {"command": "echo TOOL-INPUT-NOISE && make all"}},
  347. ]}},
  348. {"type": "user", "message": {"content": [
  349. {"type": "tool_result", "tool_use_id": "t1",
  350. "content": "TOOL-RESULT-BLOB " * 200},
  351. ]}},
  352. {"type": "summary", "summary": "not a conversation turn"},
  353. {"type": "assistant", "message": {"content": [
  354. {"type": "text", "text": "Done; committed abc1234 on lane/foo"},
  355. ]}},
  356. ]
  357. tr.write_text("\n".join(json.dumps(r) for r in recs) + "\n", encoding="utf-8")
  358. out = mod.extract_conversation(tr, 120_000)
  359. checks = {
  360. "user-text": "frobnicator" in out,
  361. "assistant-text": "abc1234" in out,
  362. "roles": "USER:" in out and "ASSISTANT:" in out,
  363. "no-tool-input": "TOOL-INPUT-NOISE" not in out,
  364. "no-tool-result": "TOOL-RESULT-BLOB" not in out,
  365. }
  366. if all(checks.values()):
  367. ok("extraction keeps text turns, skips tool_use/tool_result blobs")
  368. else:
  369. no("extraction keeps text turns, skips tool_use/tool_result blobs",
  370. f"failed={[k for k, v in checks.items() if not v]}")
  371. # Budget: 40 turns x ~1KB, budget 5000 -> verbatim tail wins, earliest
  372. # turns dropped, output within budget.
  373. tr2 = tmp / "long.jsonl"
  374. recs2 = [{"type": "user" if i % 2 == 0 else "assistant",
  375. "message": {"content": f"turn-{i} " + "x" * 1000}}
  376. for i in range(40)]
  377. tr2.write_text("\n".join(json.dumps(r) for r in recs2) + "\n", encoding="utf-8")
  378. out2 = mod.extract_conversation(tr2, 5_000)
  379. if len(out2) <= 5_000 and "turn-39" in out2 and "turn-0 " not in out2:
  380. ok("extraction respects char budget; verbatim tail wins")
  381. else:
  382. no("extraction respects char budget; verbatim tail wins",
  383. f"len={len(out2)} tail-present={'turn-39' in out2}")
  384. finally:
  385. shutil.rmtree(tmp, ignore_errors=True)
  386. def make_claude_shim(shim_dir: Path, brief_file: Path | None, fail: bool = False) -> None:
  387. """Drop a fake `claude` onto PATH: prints brief_file's content, or exits 1."""
  388. if sys.platform == "win32":
  389. bat = shim_dir / "claude.bat"
  390. if fail:
  391. bat.write_text("@echo off\r\nexit /b 1\r\n", encoding="ascii")
  392. else:
  393. bat.write_text(f'@echo off\r\ntype "{brief_file}"\r\n', encoding="ascii")
  394. else:
  395. sh = shim_dir / "claude"
  396. if fail:
  397. sh.write_text("#!/bin/sh\ncat >/dev/null\nexit 1\n", encoding="ascii")
  398. else:
  399. sh.write_text(f'#!/bin/sh\ncat >/dev/null\ncat "{brief_file}"\n',
  400. encoding="ascii")
  401. sh.chmod(0o755)
  402. def distill_tests() -> None:
  403. """18-20. recover distillation: shim claude, cache lifecycle, degrade paths."""
  404. tmp = Path(tempfile.mkdtemp(prefix="summon-distill-"))
  405. try:
  406. sb = build_toolbox_sandbox(tmp)
  407. env = sb["env"]
  408. good = tmp / "proj-good"
  409. transcript = sb["projects"] / encode_cwd(str(good)) / "cli-healthy.jsonl"
  410. cache = transcript.with_name(transcript.name + ".handover.md")
  411. shim = tmp / "shim"
  412. shim.mkdir()
  413. brief_file = tmp / "brief.txt"
  414. brief_file.write_text("## Goal\nBRIEF-ONE\n", encoding="utf-8")
  415. make_claude_shim(shim, brief_file)
  416. env_shim = dict(env)
  417. env_shim["PATH"] = str(shim) + os.pathsep + env.get("PATH", "")
  418. # 18. distill + cache write + brief-and-pointer emission
  419. rc, out, err = run_mode(env_shim, ["recover", "bbbb-healthy"])
  420. if (rc == 0 and "BRIEF-ONE" in out
  421. and "consult it only if something specific is missing" in out
  422. and str(transcript) in out
  423. and cache.exists() and "BRIEF-ONE" in cache.read_text(encoding="utf-8")
  424. and "BRIEF-ONE" not in err):
  425. ok("recover distills via shim claude; brief + pointer; cache written")
  426. else:
  427. no("recover distills via shim claude; brief + pointer; cache written",
  428. f"rc={rc} cache={cache.exists()} out-head={out[:200]!r} err-tail={err[-200:]!r}")
  429. # 19a. cache hit: shim now yields BRIEF-TWO but the cache must win
  430. brief_file.write_text("## Goal\nBRIEF-TWO\n", encoding="utf-8")
  431. rc, out, err = run_mode(env_shim, ["recover", "bbbb-healthy"])
  432. if rc == 0 and "BRIEF-ONE" in out and "BRIEF-TWO" not in out and "cached" in err:
  433. ok("cache hit: unchanged transcript reuses cached brief")
  434. else:
  435. no("cache hit: unchanged transcript reuses cached brief",
  436. f"rc={rc} out-head={out[:200]!r}")
  437. # 19b. --refresh forces re-distillation
  438. rc, out, _ = run_mode(env_shim, ["recover", "bbbb-healthy", "--refresh"])
  439. if rc == 0 and "BRIEF-TWO" in out:
  440. ok("--refresh forces re-distillation")
  441. else:
  442. no("--refresh forces re-distillation", f"rc={rc} out-head={out[:200]!r}")
  443. # 19c. newer transcript mtime busts the cache
  444. brief_file.write_text("## Goal\nBRIEF-THREE\n", encoding="utf-8")
  445. newer = cache.stat().st_mtime + 10
  446. os.utime(transcript, (newer, newer))
  447. rc, out, _ = run_mode(env_shim, ["recover", "bbbb-healthy"])
  448. if rc == 0 and "BRIEF-THREE" in out:
  449. ok("newer transcript mtime busts the cache")
  450. else:
  451. no("newer transcript mtime busts the cache", f"rc={rc} out-head={out[:200]!r}")
  452. # 20a. degrade: claude absent from PATH -> pointer prompt, exit 0, warning
  453. emptybin = tmp / "emptybin"
  454. emptybin.mkdir()
  455. env_absent = dict(env)
  456. env_absent["PATH"] = str(emptybin)
  457. rc, out, err = run_mode(env_absent, ["recover", "cccc-mismatch"])
  458. if (rc == 0 and "TAIL of the transcript" in out
  459. and "not on PATH" in err):
  460. ok("degrade: absent claude falls back to pointer prompt (exit 0)")
  461. else:
  462. no("degrade: absent claude falls back to pointer prompt (exit 0)",
  463. f"rc={rc} err-tail={err[-300:]!r}")
  464. # 20b. degrade: failing claude (exit 1) -> same advisory fallback
  465. shim_fail = tmp / "shim-fail"
  466. shim_fail.mkdir()
  467. make_claude_shim(shim_fail, None, fail=True)
  468. env_fail = dict(env)
  469. env_fail["PATH"] = str(shim_fail)
  470. rc, out, err = run_mode(env_fail, ["recover", "cccc-mismatch"])
  471. if (rc == 0 and "TAIL of the transcript" in out
  472. and "exited 1" in err):
  473. ok("degrade: failing claude falls back to pointer prompt (exit 0)")
  474. else:
  475. no("degrade: failing claude falls back to pointer prompt (exit 0)",
  476. f"rc={rc} err-tail={err[-300:]!r}")
  477. finally:
  478. shutil.rmtree(tmp, ignore_errors=True)
  479. def pick_json_tests() -> None:
  480. """21-22. pick --json: envelope shape, clean stdout, worktree split, brokenCwd."""
  481. import re
  482. tmp = Path(tempfile.mkdtemp(prefix="summon-pickjson-"))
  483. try:
  484. sb = build_toolbox_sandbox(tmp)
  485. env = sb["env"]
  486. # _is_live also checks wrapper mtime — the fixtures were just written,
  487. # so backdate them past the 10-minute liveness window.
  488. stale = time.time() - 3600
  489. for f in sb["ws"].glob("local_*.json"):
  490. os.utime(f, (stale, stale))
  491. # 21. parseable envelope, schema match, stdout clean of panel glyphs
  492. rc, out, _ = run_mode(env, ["pick", "--json"])
  493. try:
  494. envlp = json.loads(out)
  495. except json.JSONDecodeError:
  496. envlp = {}
  497. pure_ascii = all(ord(c) < 128 for c in out)
  498. checks = {
  499. "rc": rc == 0,
  500. "parses": bool(envlp),
  501. "schema": envlp.get("meta", {}).get("schema") == "claude-mods.summon.pick/v1",
  502. "count": envlp.get("meta", {}).get("count") == 3 == len(envlp.get("data", [])),
  503. "stdout-json-only": out.lstrip().startswith("{") and pure_ascii,
  504. "no-glyphs": not any(g in out for g in ("╭", "│", "╰", "├", "\x1b[")),
  505. }
  506. if all(checks.values()):
  507. ok("pick --json emits parseable pick/v1 envelope; stdout clean")
  508. else:
  509. no("pick --json emits parseable pick/v1 envelope; stdout clean",
  510. f"failed={[k for k, v in checks.items() if not v]} rc={rc} out-head={out[:200]!r}")
  511. return
  512. rows = {r["sessionId"]: r for r in envlp["data"]}
  513. moved = rows.get("local_aaaa-moved", {})
  514. healthy = rows.get("local_bbbb-healthy", {})
  515. mismatch = rows.get("local_cccc-mismatch", {})
  516. # 22a. worktree cwd split into projectRoot + worktree
  517. if (moved.get("projectRoot") == str(sb["old_root"])
  518. and moved.get("worktree") == "wt1"
  519. and healthy.get("worktree") == ""
  520. and healthy.get("projectRoot") == healthy.get("cwd")):
  521. ok("pick --json splits worktree cwd into projectRoot + worktree")
  522. else:
  523. no("pick --json splits worktree cwd into projectRoot + worktree",
  524. f"moved-root={moved.get('projectRoot')!r} wt={moved.get('worktree')!r}")
  525. # 22b. brokenCwd flags the moved session only
  526. if (moved.get("brokenCwd") is True and healthy.get("brokenCwd") is False
  527. and mismatch.get("brokenCwd") is False):
  528. ok("pick --json flags brokenCwd for the missing-cwd session")
  529. else:
  530. no("pick --json flags brokenCwd for the missing-cwd session",
  531. f"moved={moved.get('brokenCwd')} healthy={healthy.get('brokenCwd')}")
  532. # 22c. transcriptPath resolved (incl. scan fallback), booleans + ISO stamps
  533. scan_path = sb["projects"] / "X--some-unrelated-munged-dir" / "cli-mismatch.jsonl"
  534. iso_re = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
  535. checks = {
  536. "scan-fallback": mismatch.get("transcriptPath") == str(scan_path),
  537. "expected-path": str(healthy.get("transcriptPath") or "").endswith("cli-healthy.jsonl"),
  538. "branch": moved.get("branch") == "claude/wt1",
  539. "not-running": healthy.get("isRunning") is False,
  540. "not-archived": healthy.get("isArchived") is False,
  541. "iso": all(iso_re.match(r["lastActivityAt"]) for r in envlp["data"]),
  542. "short-id": moved.get("id") == "aaaa-mov",
  543. }
  544. if all(checks.values()):
  545. ok("pick --json resolves transcriptPath; ISO stamps + field types sane")
  546. else:
  547. no("pick --json resolves transcriptPath; ISO stamps + field types sane",
  548. f"failed={[k for k, v in checks.items() if not v]}")
  549. # 22d. --rich advances the schema to pick/v2 and adds display metrics
  550. rc, out, _ = run_mode(env, ["pick", "--json", "--rich"])
  551. try:
  552. rich = json.loads(out)
  553. except json.JSONDecodeError:
  554. rich = {}
  555. rrows = rich.get("data", [])
  556. checks = {
  557. "rc": rc == 0,
  558. "schema": rich.get("meta", {}).get("schema") == "claude-mods.summon.pick/v2",
  559. "count": len(rrows) == 3,
  560. "metric-keys": all(
  561. all(k in r for k in ("events", "toolCalls", "densityBuckets",
  562. "durationMin", "sizeKB", "ctxTokens", "ctxPeak",
  563. "ctxWindow", "ctxPct", "ctxPeakPct", "firstAsk",
  564. "model", "effort"))
  565. for r in rrows),
  566. "buckets-24": all(isinstance(r.get("densityBuckets"), list)
  567. and len(r["densityBuckets"]) == 24 for r in rrows),
  568. "window-standard": all(r.get("ctxWindow") in (200000, 1000000) for r in rrows),
  569. "pct-bounded": all(0 <= r.get("ctxPct", -1) <= 100 for r in rrows),
  570. }
  571. if all(checks.values()):
  572. ok("pick --json --rich emits pick/v2 with per-session display metrics")
  573. else:
  574. no("pick --json --rich emits pick/v2 with per-session display metrics",
  575. f"failed={[k for k, v in checks.items() if not v]} rc={rc}")
  576. finally:
  577. shutil.rmtree(tmp, ignore_errors=True)
  578. def build_widget_sandbox(tmp: Path) -> dict:
  579. """One account, three real sessions + one 0-turn stub, all stale (not
  580. running), transcripts present — the fixture for the widget builder tests."""
  581. home = tmp / "home"
  582. appdata = home / "AppData" / "Roaming"
  583. env = os.environ.copy()
  584. env["HOME"] = str(home)
  585. env["USERPROFILE"] = str(home)
  586. env["APPDATA"] = str(appdata)
  587. env.pop("PYTHONIOENCODING", None)
  588. env.pop("TERM_ASCII", None)
  589. cdir = claude_dir(env)
  590. projects = home / ".claude" / "projects"
  591. now_ms = int(time.time() * 1000)
  592. ws = cdir / "claude-code-sessions" / SRC_UUID / "11111111-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
  593. ws.mkdir(parents=True)
  594. proj = tmp / "proj"
  595. proj.mkdir()
  596. # (sid, cli, title, turns, age_min) — newest first; ages > 10m so none live.
  597. specs = [
  598. ("w0", "cli-w0", "widget alpha", 5, 20),
  599. ("w1", "cli-w1", "widget beta", 3, 30),
  600. ("w2", "cli-w2", "widget gamma", 9, 40),
  601. ("wstub", "cli-wstub", "empty stub", 0, 50), # 0-turn -> dropped by default
  602. ]
  603. for sid, cli, title, turns, age in specs:
  604. (ws / f"local_{sid}.json").write_text(json.dumps({
  605. "sessionId": f"local_{sid}", "cliSessionId": cli, "title": title,
  606. "cwd": str(proj), "lastActivityAt": now_ms - age * 60_000,
  607. "completedTurns": turns, "model": "claude-opus-4-8", "effort": "high",
  608. }), encoding="utf-8")
  609. enc_dir = projects / encode_cwd(str(proj))
  610. enc_dir.mkdir(parents=True)
  611. for _, cli, *_ in specs:
  612. (enc_dir / f"{cli}.jsonl").write_text(
  613. '{"type":"user","message":{"content":"hello there"},"timestamp":"2026-07-01T00:00:00Z"}\n'
  614. '{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}],'
  615. '"usage":{"input_tokens":100,"output_tokens":50}},"timestamp":"2026-07-01T00:05:00Z"}\n',
  616. encoding="utf-8")
  617. # Backdate wrapper mtimes past the 10-minute liveness window (the stub must
  618. # read as NOT running so it is dropped by default).
  619. stale = time.time() - 3600
  620. for f in ws.glob("local_*.json"):
  621. os.utime(f, (stale, stale))
  622. return {"env": env, "ws": ws, "proj": proj, "projects": projects}
  623. def _widget_data_array(html: str):
  624. """Extract + parse the injected <script id="D"> JSON array; also return the
  625. raw block so line-formatting can be asserted."""
  626. start = '<script id="D" type="application/json">'
  627. i = html.find(start)
  628. j = html.find("</script>", i + len(start)) if i >= 0 else -1
  629. if i < 0 or j < 0:
  630. return "", None
  631. block = html[i + len(start):j]
  632. try:
  633. return block, json.loads(block)
  634. except json.JSONDecodeError:
  635. return block, None
  636. def widget_tests() -> None:
  637. """24. summon widget: one-shot finished card-picker HTML.
  638. Asserts the builder replaces the <script id="D"> payload, drops 0-turn
  639. stubs by default (--include-stubs keeps them), honours --limit, keeps only
  640. the whitelisted keys, emits one session object per line, sets the window
  641. <select>, and holds the assembled HTML under the byte budget (capping with
  642. a stderr note when it can't).
  643. """
  644. mod = _load_summon_module()
  645. allowed = set(mod.WIDGET_KEYS)
  646. tmp = Path(tempfile.mkdtemp(prefix="summon-widget-"))
  647. try:
  648. sb = build_widget_sandbox(tmp)
  649. env = sb["env"]
  650. out_html = tmp / "w.html"
  651. # A) default run: placeholder replaced, stub dropped, keys whitelisted,
  652. # one-object-per-line, window set, under budget, mirror written.
  653. rc, out, err = run_mode(env, ["widget", "--out", str(out_html)])
  654. block, arr = _widget_data_array(out)
  655. ids = [r["sessionId"] for r in arr] if arr else []
  656. data_lines = [ln for ln in block.splitlines() if ln.strip().startswith("{")]
  657. checks = {
  658. "rc": rc == 0,
  659. "parses": arr is not None,
  660. "placeholder-gone": "local_0000" not in out,
  661. "payload-present": "widget alpha" in out,
  662. "stub-dropped": "local_wstub" not in ids,
  663. "three-real": ids == ["local_w0", "local_w1", "local_w2"],
  664. "keys-whitelisted": arr is not None and all(set(r).issubset(allowed) for r in arr),
  665. "dead-keys-gone": arr is not None and all(
  666. "cliSessionId" not in r and "transcriptPath" not in r
  667. and "branch" not in r for r in arr),
  668. "one-per-line": arr is not None and len(data_lines) == len(arr),
  669. "window-30d": '<option value="720" selected>' in out,
  670. "under-budget": len(out.encode("utf-8")) <= 28 * 1024,
  671. "mirror-written": out_html.is_file() and "widget alpha" in out_html.read_text(encoding="utf-8"),
  672. "stdout-not-stderr": "widget alpha" not in err,
  673. }
  674. if all(checks.values()):
  675. ok("widget builds finished HTML: stub-drop, whitelist, 1/line, budget")
  676. else:
  677. no("widget builds finished HTML: stub-drop, whitelist, 1/line, budget",
  678. f"failed={[k for k, v in checks.items() if not v]} rc={rc} err-tail={err[-200:]!r}")
  679. # B) --limit caps the session count.
  680. rc, out, _ = run_mode(env, ["widget", "--limit", "2", "--out", str(out_html)])
  681. _, arr = _widget_data_array(out)
  682. if rc == 0 and arr is not None and len(arr) == 2 and [r["sessionId"] for r in arr] == ["local_w0", "local_w1"]:
  683. ok("widget --limit caps to the N most-recently-active sessions")
  684. else:
  685. no("widget --limit caps to the N most-recently-active sessions",
  686. f"rc={rc} n={len(arr) if arr else None}")
  687. # C) --include-stubs keeps the 0-turn session.
  688. rc, out, _ = run_mode(env, ["widget", "--include-stubs", "--out", str(out_html)])
  689. _, arr = _widget_data_array(out)
  690. if rc == 0 and arr is not None and "local_wstub" in [r["sessionId"] for r in arr]:
  691. ok("widget --include-stubs keeps 0-turn sessions")
  692. else:
  693. no("widget --include-stubs keeps 0-turn sessions",
  694. f"rc={rc} ids={[r['sessionId'] for r in arr] if arr else None}")
  695. # D) density downsampled to <=12 by default, full 24 with --full-density.
  696. rc, out, _ = run_mode(env, ["widget", "--out", str(out_html)])
  697. _, arr = _widget_data_array(out)
  698. default_ds = len(arr[0]["densityBuckets"]) if arr else -1
  699. rc2, out2, _ = run_mode(env, ["widget", "--full-density", "--out", str(out_html)])
  700. _, arr2 = _widget_data_array(out2)
  701. full_ds = len(arr2[0]["densityBuckets"]) if arr2 else -1
  702. if rc == 0 and rc2 == 0 and default_ds == 12 and full_ds == 24:
  703. ok("widget downsamples density 24->12 (default); --full-density keeps 24")
  704. else:
  705. no("widget downsamples density 24->12 (default); --full-density keeps 24",
  706. f"default={default_ds} full={full_ds}")
  707. # E) a tight budget forces capping with a stderr note (never spools).
  708. rc, out, err = run_mode(env, ["widget", "--max-kb", "0.001", "--out", str(out_html)])
  709. _, arr = _widget_data_array(out)
  710. if rc == 0 and arr is not None and len(arr) == 1 and "capped" in err:
  711. ok("widget honours --max-kb: caps to fit + notes the cap on stderr")
  712. else:
  713. no("widget honours --max-kb: caps to fit + notes the cap on stderr",
  714. f"rc={rc} n={len(arr) if arr else None} err-tail={err[-200:]!r}")
  715. finally:
  716. shutil.rmtree(tmp, ignore_errors=True)
  717. def asset_tests() -> None:
  718. """In-chat picker asset: present, injectable, and cited from SKILL.md.
  719. The injection marker is the `<script id="D">` JSON block (the original
  720. `>>> INJECT <<<` comment was replaced when the card picker was rebuilt
  721. in 551292e) — keep this assertion in sync with SKILL.md's render step.
  722. """
  723. name = "picker-widget.html asset present + <script id=\"D\"> block + cited from SKILL.md"
  724. asset = HERE.parent / "assets" / "picker-widget.html"
  725. skill = HERE.parent / "SKILL.md"
  726. checks = {}
  727. checks["asset exists"] = asset.is_file()
  728. if checks["asset exists"]:
  729. html = asset.read_text(encoding="utf-8")
  730. checks["script id=D block"] = '<script id="D"' in html
  731. checks["sendPrompt wiring"] = "sendPrompt" in html
  732. checks["cited from SKILL.md"] = (
  733. skill.is_file() and "assets/picker-widget.html" in skill.read_text(encoding="utf-8")
  734. )
  735. if all(checks.values()):
  736. ok(name)
  737. else:
  738. no(name, f"failed={[k for k, v in checks.items() if not v]}")
  739. def main() -> int:
  740. # 1. Piped selection honoured even with --yes (the original bug: --yes
  741. # used to select ALL candidates, ignoring the piped picks).
  742. tmp, env, _, dest_ws = with_sandbox()
  743. try:
  744. rc, out = run_summon(env, ["--yes"], stdin_text="1,3\n")
  745. got = copied_titles(dest_ws)
  746. if rc == 0 and got == {"alpha", "gamma"}:
  747. ok("--yes honours piped selection (copies 2 of 3)")
  748. else:
  749. no("--yes honours piped selection", f"rc={rc} copied={sorted(got)}")
  750. finally:
  751. shutil.rmtree(tmp, ignore_errors=True)
  752. # 2. --select answers the picker without stdin.
  753. tmp, env, _, dest_ws = with_sandbox()
  754. try:
  755. rc, out = run_summon(env, ["--select", "2", "--yes"])
  756. got = copied_titles(dest_ws)
  757. if rc == 0 and got == {"beta"}:
  758. ok("--select 2 copies exactly session #2")
  759. else:
  760. no("--select 2 copies exactly session #2", f"rc={rc} copied={sorted(got)}")
  761. finally:
  762. shutil.rmtree(tmp, ignore_errors=True)
  763. # 3. --select all selects everything.
  764. tmp, env, _, dest_ws = with_sandbox()
  765. try:
  766. rc, out = run_summon(env, ["--select", "all", "--yes"])
  767. got = copied_titles(dest_ws)
  768. if rc == 0 and got == {"alpha", "beta", "gamma"}:
  769. ok("--select all copies all 3")
  770. else:
  771. no("--select all copies all 3", f"rc={rc} copied={sorted(got)}")
  772. finally:
  773. shutil.rmtree(tmp, ignore_errors=True)
  774. # 4. Without --yes, declining the confirmation cancels.
  775. tmp, env, _, dest_ws = with_sandbox()
  776. try:
  777. rc, out = run_summon(env, [], stdin_text="1,2\nn\n")
  778. got = copied_titles(dest_ws)
  779. if rc == 0 and not got and "cancelled" in out:
  780. ok("confirmation 'n' cancels, nothing copied")
  781. else:
  782. no("confirmation 'n' cancels, nothing copied",
  783. f"rc={rc} copied={sorted(got)}")
  784. finally:
  785. shutil.rmtree(tmp, ignore_errors=True)
  786. # 5. Without --yes, confirming 'y' proceeds.
  787. tmp, env, _, dest_ws = with_sandbox()
  788. try:
  789. rc, out = run_summon(env, [], stdin_text="1\ny\n")
  790. got = copied_titles(dest_ws)
  791. if rc == 0 and got == {"alpha"}:
  792. ok("confirmation 'y' proceeds with the pick")
  793. else:
  794. no("confirmation 'y' proceeds with the pick",
  795. f"rc={rc} copied={sorted(got)}")
  796. finally:
  797. shutil.rmtree(tmp, ignore_errors=True)
  798. # 6. --yes with empty stdin cancels — must NOT fall back to select-all.
  799. tmp, env, _, dest_ws = with_sandbox()
  800. try:
  801. rc, out = run_summon(env, ["--yes"], stdin_text="")
  802. got = copied_titles(dest_ws)
  803. if rc == 0 and not got and "cancelled" in out:
  804. ok("--yes with no selection cancels (no auto-all)")
  805. else:
  806. no("--yes with no selection cancels (no auto-all)",
  807. f"rc={rc} copied={sorted(got)}")
  808. finally:
  809. shutil.rmtree(tmp, ignore_errors=True)
  810. # 7. Unicode title on cp1252 stdout must not crash (UnicodeEncodeError
  811. # regression: U+2192 in a title with Windows cp1252 console encoding).
  812. tmp, env, _, dest_ws = with_sandbox(
  813. titles=("Update docs: Dagu → process-compose gallery", "beta", "gamma"))
  814. try:
  815. env_cp = dict(env)
  816. env_cp["PYTHONIOENCODING"] = "cp1252"
  817. rc, out = run_summon(env_cp, ["--select", "all", "--yes", "--dry-run"])
  818. if rc == 0 and "would copy" in out:
  819. ok("cp1252 stdout survives U+2192 in session title")
  820. else:
  821. no("cp1252 stdout survives U+2192 in session title",
  822. f"rc={rc} out-tail={out[-300:]!r}")
  823. finally:
  824. shutil.rmtree(tmp, ignore_errors=True)
  825. # 8-16. Toolbox modes: rebind / recover / pick / doctor
  826. toolbox_tests()
  827. # 17. Extraction (in-process unit tests)
  828. extraction_tests()
  829. # 18-20. Distilled handover: shim claude, cache lifecycle, degrade paths
  830. distill_tests()
  831. # 21-22. pick --json: envelope, clean stdout, worktree split, brokenCwd
  832. pick_json_tests()
  833. # 23. In-chat picker asset: present + injectable + cited from SKILL.md
  834. asset_tests()
  835. # 24. summon widget: one-shot finished card-picker HTML builder
  836. widget_tests()
  837. print(f"\nsummon tests: {PASS} passed, {FAIL} failed")
  838. return 1 if FAIL else 0
  839. if __name__ == "__main__":
  840. sys.exit(main())