deepsieve-cli 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: deepsieve-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: DeepSieve CLI — run cited deep research from your terminal
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -31,6 +31,7 @@ deepsieve whoami # identity, organization, active B
31
31
  deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
32
32
  deepsieve runs create --blueprint <id> --query "..." --wait # real run: spends credits
33
33
  deepsieve runs create --blueprint <id> --seeds "..." --allowed-source "*.gov" # only cite these
34
+ deepsieve runs create --blueprint <id> --query "..." --expected-total 1000 # refuse (charge nothing) if it now costs more than the estimate
34
35
  deepsieve runs list
35
36
  deepsieve runs get <id> # exit 4 while still running
36
37
  deepsieve runs cancel <id>
@@ -38,7 +39,11 @@ deepsieve members list # who is in the organization + sea
38
39
  deepsieve changes list --live # merges, renames, successor links
39
40
  deepsieve changes undo <id> # asks first; --yes in scripts
40
41
  deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
41
- deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser]
42
+ deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser] --preview-digest <digest>
43
+ deepsieve runs retry <id> # a retry that charges shows its total and asks
44
+ deepsieve runs reruns <id> # a report's re-research waiting for approval
45
+ deepsieve runs monitoring <id> --off # stop a report's monitoring; asks first
46
+ deepsieve links remove <entity> <record-id> <linked-entity> <target-id> # asks first
42
47
  deepsieve data catalog # entities + columns (never guess)
43
48
  deepsieve data get companies --receipts # rows with per-cell citations
44
49
  deepsieve chat thread <run-id> # changes, with status + warnings
@@ -23,6 +23,7 @@ deepsieve whoami # identity, organization, active B
23
23
  deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
24
24
  deepsieve runs create --blueprint <id> --query "..." --wait # real run: spends credits
25
25
  deepsieve runs create --blueprint <id> --seeds "..." --allowed-source "*.gov" # only cite these
26
+ deepsieve runs create --blueprint <id> --query "..." --expected-total 1000 # refuse (charge nothing) if it now costs more than the estimate
26
27
  deepsieve runs list
27
28
  deepsieve runs get <id> # exit 4 while still running
28
29
  deepsieve runs cancel <id>
@@ -30,7 +31,11 @@ deepsieve members list # who is in the organization + sea
30
31
  deepsieve changes list --live # merges, renames, successor links
31
32
  deepsieve changes undo <id> # asks first; --yes in scripts
32
33
  deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
33
- deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser]
34
+ deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser] --preview-digest <digest>
35
+ deepsieve runs retry <id> # a retry that charges shows its total and asks
36
+ deepsieve runs reruns <id> # a report's re-research waiting for approval
37
+ deepsieve runs monitoring <id> --off # stop a report's monitoring; asks first
38
+ deepsieve links remove <entity> <record-id> <linked-entity> <target-id> # asks first
34
39
  deepsieve data catalog # entities + columns (never guess)
35
40
  deepsieve data get companies --receipts # rows with per-cell citations
36
41
  deepsieve chat thread <run-id> # changes, with status + warnings
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepsieve-cli"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "DeepSieve CLI — run cited deep research from your terminal"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import argparse
18
18
  import json
19
+ import math
19
20
  import os
20
21
  import re
21
22
  import sys
@@ -266,6 +267,15 @@ def cmd_runs_get(args) -> int:
266
267
  return EXIT_OK if body.get("status") == "completed" else EXIT_FAIL
267
268
 
268
269
 
270
+ def _folded_text(folds: list[dict]) -> str:
271
+ """gh #2716 — the seeds a request counted once, in one line (the first 4, then a count)."""
272
+ if not folds:
273
+ return ""
274
+ shown = ", ".join(f'"{f.get("seed")}" as "{f.get("kept_as")}"' for f in folds[:4])
275
+ more = f" and {len(folds) - 4} more" if len(folds) > 4 else ""
276
+ return f"{len(folds)} counted once: {shown}{more}."
277
+
278
+
269
279
  def cmd_runs_create(args) -> int:
270
280
  client, prof = _require_auth(args)
271
281
  # At most one. NEITHER is a paid broad sweep of the whole Blueprint schema
@@ -291,8 +301,15 @@ def cmd_runs_create(args) -> int:
291
301
  # #1802 — repeatable; the server resolves and refuses, the CLI only carries.
292
302
  # Only blanks given → no list at all, never an empty one (the server 400s on []).
293
303
  body["allowed_sources"] = allowed
304
+ excluded = [s.strip() for s in (getattr(args, "excluded_source", None) or []) if s.strip()]
305
+ if excluded:
306
+ # gh #2641 — repeatable; the server resolves and refuses, the CLI only carries.
307
+ body["excluded_sources"] = excluded
294
308
  if getattr(args, "blueprint", None):
295
309
  body["blueprint_id"] = args.blueprint
310
+ if getattr(args, "expected_total", None) is not None:
311
+ # gh #2710 — bind the run to the estimate: a higher total is refused, never charged.
312
+ body["expected_total_cents"] = args.expected_total
296
313
 
297
314
  # A real run spends money. Confirm interactively unless told not to — and
298
315
  # never silently in a pipe, where nobody is watching.
@@ -317,6 +334,17 @@ def cmd_runs_create(args) -> int:
317
334
  headers={"Idempotency-Key": args.idempotency_key or uuid.uuid4().hex},
318
335
  )
319
336
  except ApiError as exc:
337
+ if exc.code == "run_price_changed":
338
+ # gh #2710 — the run would now cost more than --expected-total. Nothing was charged.
339
+ total = exc.details.get("total_price_cents")
340
+ if isinstance(total, int) and not isinstance(total, bool):
341
+ ask = (f"The price for this run changed since it was estimated: it is now "
342
+ f"${total / 100:,.2f}. Nothing was charged.")
343
+ if args.json:
344
+ print(json.dumps({"error": {"code": exc.code, "message": ask,
345
+ "details": exc.details}}))
346
+ err(f"{ask} Pass --expected-total {total} to start at the new price.")
347
+ return EXIT_USAGE
320
348
  err(str(exc))
321
349
  # A --query is parsed by a model: the org's model allowance can refuse it (gh #2307).
322
350
  return _api_exit(exc)
@@ -326,6 +354,9 @@ def cmd_runs_create(args) -> int:
326
354
  return EXIT_OK
327
355
  run_id = created.get("id")
328
356
  print(f"started run {run_id} ({'dry run' if args.dry_run else 'billable'})")
357
+ folded = _folded_text(created.get("folded_duplicates") or [])
358
+ if folded:
359
+ print(folded)
329
360
  bp = created.get("blueprint") or {}
330
361
  if bp:
331
362
  print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
@@ -472,6 +503,288 @@ def cmd_runs_monitoring(args) -> int:
472
503
  return EXIT_OK
473
504
 
474
505
 
506
+ def _reruns_summary(rid: str, v: dict) -> str:
507
+ waiting = v.get("awaiting_count") or 0
508
+ lines = [
509
+ f"{waiting} waiting for re-research on {rid}"
510
+ + (f", {_usd(v.get('awaiting_total_cents') or 0)} to approve now" if waiting else "")
511
+ ]
512
+ for it in v.get("items") or []:
513
+ what = it.get("column_label") or it.get("column") or "a fact"
514
+ who = it.get("entity") or "an entity"
515
+ lines.append(f" - {who}" + (f", for {it['child']}" if it.get("child") else "") + f": {what}")
516
+ if waiting > len(v.get("items") or []):
517
+ lines.append(f" … and {waiting - len(v.get('items') or [])} more")
518
+ if v.get("quote_withheld"):
519
+ lines.append(v["quote_withheld"])
520
+ lines.append(
521
+ f"included this period: {v.get('included_used', 0)}/{v.get('included_per_period', 0)}; "
522
+ f"renews {v.get('next_period_start') or 'unknown'}"
523
+ )
524
+ if v.get("auto_approve"):
525
+ lines.append(
526
+ f"auto-approve ON, up to {_usd(v.get('auto_approve_cap_cents') or 0)}/month "
527
+ f"({_usd(v.get('auto_approve_spent_cents') or 0)} spent)"
528
+ )
529
+ return "\n".join(lines)
530
+
531
+
532
+ #: The API's ceiling on a monthly auto-approve cap, in cents (`maximum` in the contract; the
533
+ #: column is a Postgres INTEGER). Checked here so the CLI refuses before it asks.
534
+ _AUTO_APPROVE_CAP_MAX_CENTS = 2_147_483_647
535
+
536
+
537
+ def cmd_runs_reruns(args) -> int:
538
+ """A report's re-research waiting for approval (gh #2351): show it, approve it, or turn
539
+ auto-approve on or off. Approving SPENDS money: it needs --expected-total and --quote-id (both
540
+ from what you were shown), or an interactive yes to what was fetched just now; never a silent
541
+ yes in a pipe. The quote pins the exact rows, so an approval never buys a rerun you were not
542
+ shown. There is no decline: a rerun left waiting costs nothing and is decided again at
543
+ renewal.
544
+
545
+ Turning auto-approve ON (gh #2506) is standing authority to spend on this report's future
546
+ re-research, up to a monthly limit you must give (--cap, in dollars). It is recorded against
547
+ the key or app you are signed in with: it switches off if that key is revoked or expires, or
548
+ you sign out everywhere, and pauses while the key is suspended. Confirmed like approving:
549
+ interactively, or with --yes, never silently in a pipe."""
550
+ rid = _uuid_arg(args.run_id, "run_id")
551
+ if rid is None:
552
+ return EXIT_USAGE
553
+ client, _ = _require_auth(args)
554
+ base = f"/v1/research/runs/{rid}/reruns"
555
+ if not args.auto_approve_on and (args.cap is not None or args.yes):
556
+ # seat-2506b F5: these belong to --auto-approve-on alone; silently ignoring them let a
557
+ # user believe --yes had confirmed something else.
558
+ err("--cap and --yes go with --auto-approve-on")
559
+ return EXIT_USAGE
560
+ try:
561
+ if args.auto_approve_on:
562
+ # seat-2506b F3/F4: a cap that is not a finite number, or that rounds to less than
563
+ # the API's 1 cent, is refused here (it used to traceback, or confirm "$0.00").
564
+ cap_ok = args.cap is not None and math.isfinite(args.cap) and args.cap > 0
565
+ cap_cents = int(round(args.cap * 100)) if cap_ok else 0
566
+ if cap_cents < 1 or cap_cents > _AUTO_APPROVE_CAP_MAX_CENTS:
567
+ err(
568
+ "turning auto-approve on needs --cap USD: the most it may spend a month, "
569
+ f"from $0.01 to {_usd(_AUTO_APPROVE_CAP_MAX_CENTS)}"
570
+ )
571
+ return EXIT_USAGE
572
+ what = (
573
+ f"Turn on auto-approve for {rid}? Its future re-research is approved and charged "
574
+ f"automatically, up to {_usd(cap_cents)} a month. It switches off if the key or "
575
+ "app you are signed in with is revoked."
576
+ )
577
+ if not args.yes:
578
+ if not _interactive():
579
+ err("refusing to turn on auto-approve non-interactively (it spends money); "
580
+ "pass --yes")
581
+ return EXIT_USAGE
582
+ if not _yes(f"{what} [y/N] "):
583
+ print("Cancelled. Auto-approve was not changed.")
584
+ return EXIT_OK
585
+ body = client.request(
586
+ "PATCH",
587
+ f"{base}/auto-approve",
588
+ json_body={"auto_approve": True, "auto_approve_cap_cents": cap_cents},
589
+ )
590
+ out(body, args.json, _reruns_summary(rid, body))
591
+ return EXIT_OK
592
+ if args.auto_approve_off:
593
+ body = client.request("PATCH", f"{base}/auto-approve", json_body={"auto_approve": False})
594
+ out(body, args.json, _reruns_summary(rid, body))
595
+ return EXIT_OK
596
+ if not args.approve:
597
+ body = client.request("GET", base)
598
+ out(body, args.json, _reruns_summary(rid, body))
599
+ return EXIT_OK
600
+ total, quote_id = args.expected_total, args.quote_id
601
+ if total is None or not quote_id:
602
+ if not _interactive():
603
+ err("approving spends money: pass --expected-total CENTS and --quote-id ID "
604
+ "(both from `deepsieve runs reruns RID --json`)")
605
+ return EXIT_USAGE
606
+ state = client.request("GET", base)
607
+ print(_reruns_summary(rid, state))
608
+ total = int(state.get("awaiting_total_cents") or 0)
609
+ quote_id = state.get("quote_id") or ""
610
+ if not state.get("awaiting_count"):
611
+ print("Nothing is waiting.")
612
+ return EXIT_OK
613
+ if not quote_id:
614
+ # gh #2351 delta review: past what the API will list there is no quote, and asking
615
+ # "Approve N?" over rows the list could not show is asking for a blind yes.
616
+ err("these cannot be approved here (the reason is above). Nothing was charged.")
617
+ return EXIT_USAGE
618
+ if not _yes(f"Approve {state['awaiting_count']} for {_usd(total)}? [y/N] "):
619
+ print("Cancelled. Nothing was charged.")
620
+ return EXIT_OK
621
+ body = client.request(
622
+ "POST",
623
+ f"{base}/approve",
624
+ json_body={"expected_total_cents": total, "quote_id": quote_id},
625
+ headers={"Idempotency-Key": uuid.uuid4().hex},
626
+ )
627
+ except ApiError as exc:
628
+ if exc.code == "rerun_price_changed":
629
+ if not (exc.details or {}).get("count"):
630
+ err("nothing is waiting any more (it was approved, or retired). Nothing was charged.")
631
+ else:
632
+ err(
633
+ f"what is waiting changed: {exc.details['count']} now, costing "
634
+ f"{_usd(exc.details.get('total_cents') or 0)}. "
635
+ "Nothing was charged; run it again to see the new list."
636
+ )
637
+ else:
638
+ err(str(exc))
639
+ return _api_exit(exc)
640
+ human = f"approved {body.get('approved', 0)} for {rid}; {body.get('still_waiting', 0)} still waiting"
641
+ if body.get("stopped"):
642
+ human += f"\nstopped part-way: {body['stopped']}"
643
+ out(body, args.json, human)
644
+ return EXIT_OK
645
+
646
+
647
+ def _usd(cents) -> str:
648
+ return f"${cents / 100:,.2f}"
649
+
650
+
651
+ def _refund_line(refund: dict | None) -> str:
652
+ """What became of the earlier run's charge (gh #2389), in the app's words (site
653
+ `refundHeadline`), so the CLI and the Retry dialog never state it differently."""
654
+ refund = refund or {}
655
+ status, kind = refund.get("run_status"), refund.get("kind")
656
+ run = {"failed": "failed run", "cancelled": "stopped run"}.get(status, "earlier run")
657
+ amount, credits, units = refund.get("amount_cents"), refund.get("credits"), refund.get("units")
658
+ plural = lambda n, one, many: f"{n} {one if n == 1 else many}" # noqa: E731
659
+ if kind == "refunded_dollars":
660
+ return (f"Your {run} was refunded. {_usd(amount)} is back on your account."
661
+ if amount is not None else f"Your {run} was refunded in full.")
662
+ if kind == "refunded_free_run":
663
+ return f"Your {run} was refunded. Your free run is back."
664
+ if kind == "refunded_credits":
665
+ return (f"Your {run} was refunded. {plural(credits, 'run credit is', 'run credits are')} back."
666
+ if credits is not None else f"Your {run} was refunded. Its run credits are back.")
667
+ if kind == "allowance_restored":
668
+ return (f"Your {run} didn't fully count. {plural(units, 'included rerun is', 'included reruns are')} back."
669
+ if units is not None else f"Your {run} didn't count. Its included rerun is back.")
670
+ if kind == "not_charged":
671
+ return f"Your {run} wasn't charged."
672
+ if kind == "kept_free_run":
673
+ if status in ("completed", None):
674
+ return ("Your earlier run used your free run. It finished with no results, and a "
675
+ "finished run isn't refunded.")
676
+ return f"Your {run} used your free run. Part of it returned results, so your free run stays used."
677
+ if kind == "kept_credits":
678
+ used = plural(credits, "run credit", "run credits") if credits is not None else "run credits"
679
+ return f"Your earlier run used {used}. It finished with no results, and a finished run isn't refunded."
680
+ if kind == "refunded_to_ended_plan":
681
+ return f"Your plan has ended, so the credits for your {run} were returned to it and can't be used."
682
+ if kind == "refunded_to_ended_period":
683
+ # gh #2460: the plan is still running; an earlier billing period's allowance ended.
684
+ return (f"The credits for your {run} went back to the billing period they came from, "
685
+ "which has ended, so they can't be used again.")
686
+ if kind == "refunded_unknown":
687
+ return f"Your {run} was refunded in full."
688
+ if kind == "refund_pending":
689
+ return f"Your {run} is being refunded."
690
+ if kind == "partially_refunded":
691
+ lead = f"Part of your {run} returned nothing, and that part was refunded."
692
+ if amount is not None:
693
+ return f"{lead} {_usd(amount)} is back on your account."
694
+ if credits is not None:
695
+ return f"{lead} {plural(credits, 'run credit is', 'run credits are')} back."
696
+ return lead
697
+ return "We can't confirm yet whether your earlier run was refunded. We're checking it."
698
+
699
+
700
+ def _retry_cost(details: dict) -> str:
701
+ """What would pay, from the quote's `funding` (seat-2389 M3): `total_price_cents` is the
702
+ LIST price even when the free run or plan credits cover the retry."""
703
+ total = details.get("total_price_cents")
704
+ funding = details.get("funding") or {}
705
+ if funding.get("covered_by") == "free_run":
706
+ return f"uses your free run (list price {_usd(total)})"
707
+ if funding.get("covered_by") == "plan_credits":
708
+ n = funding.get("plan_credits_needed")
709
+ return f"uses {n} plan run credit{'' if n == 1 else 's'} (list price {_usd(total)})"
710
+ cost = f"charges {_usd(total)}"
711
+ if (details.get("shortfall_cents") or 0) > 0:
712
+ cost += f", and your balance is {_usd(details['shortfall_cents'])} short"
713
+ return cost
714
+
715
+
716
+ def _retry_quote_text(details: dict, moved: bool) -> str:
717
+ parts = [
718
+ "The price changed since the quote." if moved else "",
719
+ _refund_line(details.get("refund")),
720
+ f"Retrying starts a new run and {_retry_cost(details)}.",
721
+ ]
722
+ for key, what in (
723
+ ("reuse_predicted", "carried over at no charge"),
724
+ ("skipped_already_held", "already in the report, skipped"),
725
+ ):
726
+ names = details.get(key) or []
727
+ if names:
728
+ parts.append(f"{len(names)} {what}: {', '.join(names[:4])}"
729
+ + (f" and {len(names) - 4} more" if len(names) > 4 else "") + ".")
730
+ return " ".join(p for p in parts if p)
731
+
732
+
733
+ def cmd_runs_retry(args) -> int:
734
+ """Retry a failed, stopped or empty run, as the app's Retry does (gh #2389).
735
+
736
+ A retry that charges nothing starts at once. A retry that will CHARGE is refused by the API
737
+ (`retry_confirmation_required`) until it is sent with the total it was quoted: here, an
738
+ explicit question showing the refund and the total, or --expected-total CENTS. In a pipe it
739
+ never confirms by itself. A price that moved meanwhile (`retry_price_changed`) is asked again
740
+ with the new total; nothing is charged in between."""
741
+ rid = _uuid_arg(args.run_id, "run_id")
742
+ if rid is None:
743
+ return EXIT_USAGE
744
+ client, _ = _require_auth(args)
745
+ path = f"/v1/research/runs/{rid}/retry"
746
+ payload: dict = {}
747
+ if args.expected_total is not None:
748
+ payload["expected_total_cents"] = args.expected_total
749
+ # At most two refusals: the quote, and one re-quote if the price moved while the person was
750
+ # deciding. A third would be the API contradicting itself; stop rather than loop, and never ask
751
+ # a question whose yes would not be sent (seat-2389 L1).
752
+ attempts = 3
753
+ body = None
754
+ for attempt in range(attempts):
755
+ try:
756
+ body = client.request("POST", path, json_body=payload)
757
+ break
758
+ except ApiError as exc:
759
+ if exc.code not in ("retry_confirmation_required", "retry_price_changed"):
760
+ err(str(exc))
761
+ return _api_exit(exc)
762
+ total = exc.details.get("total_price_cents")
763
+ if not isinstance(total, int) or isinstance(total, bool):
764
+ # No quote to state: never prompt "$0.00" (seat-2389 L2).
765
+ err(f"{exc} (the API sent no total to confirm; nothing was charged)")
766
+ return EXIT_FAIL
767
+ ask = _retry_quote_text(exc.details, moved=exc.code == "retry_price_changed")
768
+ if not _interactive():
769
+ if args.json:
770
+ print(json.dumps({"error": {"code": exc.code, "message": ask, "details": exc.details}}))
771
+ err(f"{ask} Pass --expected-total {total} to confirm that charge.")
772
+ return EXIT_USAGE
773
+ if attempt == attempts - 1:
774
+ break # the last refusal: no request would follow, so ask nothing
775
+ if not _yes(f"{ask} Retry at {_usd(total)}? [y/N] "):
776
+ print("Cancelled. Nothing was charged.")
777
+ return EXIT_OK
778
+ payload = {"expected_total_cents": total}
779
+ if body is None:
780
+ err("the API kept refusing the confirmation; nothing was charged")
781
+ return EXIT_FAIL
782
+ retry = body.get("retry") or {}
783
+ how = "resumed" if retry.get("mode") == "resumed" else f"restarted as {body.get('id')}"
784
+ out(body, args.json, f"retry of {rid} {how}")
785
+ return EXIT_OK
786
+
787
+
475
788
  def _uuid_arg(value: str, what: str) -> str | None:
476
789
  """An id interpolated into a URL path must be a UUID, never a path fragment."""
477
790
  import uuid as _uuid # noqa: PLC0415
@@ -577,6 +890,9 @@ def _describe_change(c: dict) -> str:
577
890
  return f"renamed {c.get('old_name')} to {c.get('new_name')}"
578
891
  if kind == "succeeded_by":
579
892
  return f"{c.get('old_name')} succeeded by {c.get('new_name')}"
893
+ if kind == "link_removed":
894
+ link = c.get("link") or {}
895
+ return f"removed {link.get('target_name') or link.get('target_id')} from {link.get('record_name')}"
580
896
  return str(kind)
581
897
 
582
898
 
@@ -785,10 +1101,15 @@ def _print_merge(body: dict) -> None:
785
1101
  if ties:
786
1102
  print(f"\nopen ties ({len(ties)}): settle each with --choice KEY=survivor|loser, "
787
1103
  "or pass --accept-survivor-on-ties")
1104
+ # #2409: the apply confirms THIS merge by its digest; a merge that changed since is refused.
1105
+ if body.get("preview_digest") and not body.get("applied"):
1106
+ print(f"\nto apply exactly this merge: --preview-digest {body['preview_digest']}")
788
1107
 
789
1108
 
790
1109
  #: Merge refusals fixed by changing the command, not by retrying: exit 2, not 1.
791
- _MERGE_USAGE_CODES = frozenset({"ties_unresolved", "merge_across_parents"})
1110
+ _MERGE_USAGE_CODES = frozenset(
1111
+ {"ties_unresolved", "merge_across_parents", "merge_changed", "preview_required"}
1112
+ )
792
1113
 
793
1114
 
794
1115
  def cmd_merge_preview(args) -> int:
@@ -817,6 +1138,15 @@ def cmd_merge_apply(args) -> int:
817
1138
  if body is None:
818
1139
  return EXIT_USAGE
819
1140
  body["accept_survivor_on_ties"] = bool(args.accept_survivor_on_ties)
1141
+ # #2409: confirm the merge you previewed, or say you are merging blind. Neither is refused by
1142
+ # the API (preview_required); both is a contradiction, refused here before any call.
1143
+ if args.preview_digest and args.without_preview:
1144
+ err("pass --preview-digest or --without-preview, not both")
1145
+ return EXIT_USAGE
1146
+ if args.preview_digest:
1147
+ body["preview_digest"] = args.preview_digest
1148
+ if args.without_preview:
1149
+ body["apply_without_preview"] = True
820
1150
  if not args.yes:
821
1151
  if not _interactive():
822
1152
  err("refusing to change data non-interactively; pass --yes")
@@ -830,6 +1160,14 @@ def cmd_merge_apply(args) -> int:
830
1160
  out_ = client.request("POST", "/v1/merges", json_body=body, headers=headers)
831
1161
  except ApiError as exc:
832
1162
  err(str(exc))
1163
+ if exc.code == "merge_changed" and exc.details.get("preview"):
1164
+ # #2409: what the merge would do NOW, so the person sees what moved. Under --json it
1165
+ # stays JSON on stdout (seat-2409 5c): a script parses it, never human text.
1166
+ if args.json:
1167
+ out(exc.details["preview"], True)
1168
+ else:
1169
+ print("\nthe merge as it stands now:")
1170
+ _print_merge(exc.details["preview"])
833
1171
  # An open tie is fixed by changing the command (--choice, or
834
1172
  # --accept-survivor-on-ties), so it exits as a usage error: a script can tell it
835
1173
  # apart from a failure without --json. So is a merge across parents (#2254): no retry
@@ -843,6 +1181,47 @@ def cmd_merge_apply(args) -> int:
843
1181
  return EXIT_OK
844
1182
 
845
1183
 
1184
+ def cmd_links_remove(args) -> int:
1185
+ """Remove one many-to-many link from a record (gh #2514). Research does not add it back.
1186
+ It changes the dataset, so it is confirmed like a merge: interactively, or with --yes,
1187
+ never silently in a pipe. Undo it with `deepsieve changes undo <change id>`."""
1188
+ client, _ = _require_auth(args)
1189
+ for value, name in ((args.record_id, "record id"), (args.target_id, "target id")):
1190
+ try:
1191
+ uuid.UUID(value)
1192
+ except ValueError:
1193
+ err(f"{name} must be a UUID")
1194
+ return EXIT_USAGE
1195
+ body = {
1196
+ "entity": args.entity,
1197
+ "record_id": args.record_id,
1198
+ "to_entity": args.to_entity,
1199
+ "target_id": args.target_id,
1200
+ }
1201
+ if args.blueprint:
1202
+ body["blueprint_id"] = args.blueprint
1203
+ if not args.yes:
1204
+ if not _interactive():
1205
+ err("refusing to change data non-interactively; pass --yes")
1206
+ return EXIT_USAGE
1207
+ prompt = (
1208
+ f"Remove the {args.to_entity} link {args.target_id} from {args.entity} "
1209
+ f"{args.record_id}? Research will not add it back. [y/N] "
1210
+ )
1211
+ if input(prompt).strip().lower() not in ("y", "yes"):
1212
+ print("Cancelled.")
1213
+ return EXIT_OK
1214
+ headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
1215
+ try:
1216
+ out_ = client.request("POST", "/v1/links/remove", json_body=body, headers=headers)
1217
+ except ApiError as exc:
1218
+ err(str(exc))
1219
+ return _api_exit(exc)
1220
+ human = f"{_describe_change(out_)}. Undo: deepsieve changes undo {out_.get('change_id')}"
1221
+ out(out_, args.json, human)
1222
+ return EXIT_OK
1223
+
1224
+
846
1225
  def _blueprint_id(client, args) -> str | None:
847
1226
  """The Blueprint these commands address: ``--blueprint``, else the active one.
848
1227
 
@@ -1146,6 +1525,9 @@ def build_parser() -> argparse.ArgumentParser:
1146
1525
  help="restrict evidence to this source (repeatable): a host, *.gov, "
1147
1526
  "https://host/path/*, or blueprint:primary; a cell cited only "
1148
1527
  "elsewhere is not written")
1528
+ rc.add_argument("--excluded-source", action="append", metavar="PATTERN",
1529
+ help="never cite this source (repeatable): a host, *.tld or "
1530
+ "https://host/path/*; a citation on it is not written")
1149
1531
  rc.add_argument("--blueprint", help="Blueprint id to research; REQUIRED unless --dry-run "
1150
1532
  "(`deepsieve whoami` shows your active Blueprint's id)")
1151
1533
  rc.add_argument("--depth", default="standard", choices=["standard", "max"])
@@ -1157,6 +1539,13 @@ def build_parser() -> argparse.ArgumentParser:
1157
1539
  rc.add_argument("--timeout", type=int, default=3600)
1158
1540
  rc.add_argument("--idempotency-key")
1159
1541
  rc.add_argument("-y", "--yes", action="store_true", help="skip the billable-run confirmation")
1542
+ rc.add_argument(
1543
+ "--expected-total",
1544
+ type=int,
1545
+ default=None,
1546
+ metavar="CENTS",
1547
+ help="the estimate's run_price_cents: refuse (and charge nothing) if the run now costs more",
1548
+ )
1160
1549
  rc.set_defaults(func=cmd_runs_create)
1161
1550
 
1162
1551
  rw = runs.add_parser("wait", help="poll an existing run to completion", parents=[common])
@@ -1169,6 +1558,21 @@ def build_parser() -> argparse.ArgumentParser:
1169
1558
  rx.add_argument("run_id")
1170
1559
  rx.set_defaults(func=cmd_runs_cancel)
1171
1560
 
1561
+ rr = runs.add_parser(
1562
+ "retry",
1563
+ help="retry a failed, stopped or empty run (a retry that charges asks first)",
1564
+ parents=[common],
1565
+ )
1566
+ rr.add_argument("run_id")
1567
+ rr.add_argument(
1568
+ "--expected-total",
1569
+ type=int,
1570
+ default=None,
1571
+ metavar="CENTS",
1572
+ help="confirm a charging retry at the total you were quoted, in cents",
1573
+ )
1574
+ rr.set_defaults(func=cmd_runs_retry)
1575
+
1172
1576
  rm = runs.add_parser(
1173
1577
  "monitoring",
1174
1578
  help="turn a report's monitoring on or off (ON bills daily past the free 30 days)",
@@ -1191,6 +1595,37 @@ def build_parser() -> argparse.ArgumentParser:
1191
1595
  )
1192
1596
  rm.set_defaults(func=cmd_runs_monitoring)
1193
1597
 
1598
+ rq = runs.add_parser(
1599
+ "reruns",
1600
+ help="a report's re-research waiting for approval: show, --approve (spends), or "
1601
+ "--auto-approve-on/--auto-approve-off",
1602
+ parents=[common],
1603
+ )
1604
+ rq.add_argument("run_id")
1605
+ act = rq.add_mutually_exclusive_group()
1606
+ act.add_argument("--approve", action="store_true", help="approve everything waiting (spends money)")
1607
+ rq.add_argument(
1608
+ "--expected-total",
1609
+ type=int,
1610
+ default=None,
1611
+ metavar="CENTS",
1612
+ help="with --approve: the total you were shown, in cents; refused if the price moved. "
1613
+ "Without --quote-id it is ignored: in a terminal the list and total are fetched and you "
1614
+ "confirm those",
1615
+ )
1616
+ rq.add_argument("--quote-id", default="", metavar="ID",
1617
+ help="with --approve: the quote_id you were shown; refused if what is waiting changed")
1618
+ act.add_argument("--auto-approve-off", action="store_true",
1619
+ help="stop approving this report's future re-research automatically")
1620
+ act.add_argument("--auto-approve-on", action="store_true",
1621
+ help="approve this report's future re-research automatically, up to --cap a "
1622
+ "month (spends money; it switches off if your key is revoked)")
1623
+ rq.add_argument("--cap", type=float, default=None, metavar="USD",
1624
+ help="with --auto-approve-on: the most it may spend a month, in dollars (required)")
1625
+ rq.add_argument("--yes", action="store_true",
1626
+ help="with --auto-approve-on: do not ask for confirmation (required in a pipe)")
1627
+ rq.set_defaults(func=cmd_runs_reruns)
1628
+
1194
1629
  chat = sub.add_parser("chat", help="a run's chat and the changes it proposed", parents=[common]).add_subparsers(dest="sub", required=True)
1195
1630
  ct = chat.add_parser("thread", help="the thread, with each change's status and warnings", parents=[common])
1196
1631
  ct.add_argument("run_id")
@@ -1208,10 +1643,10 @@ def build_parser() -> argparse.ArgumentParser:
1208
1643
  ml = members.add_parser("list", help="who is in the organization, and seat usage", parents=[common])
1209
1644
  ml.set_defaults(func=cmd_members_list)
1210
1645
 
1211
- changes = sub.add_parser("changes", help="merges, renames and successor links", parents=[common]).add_subparsers(dest="sub", required=True)
1646
+ changes = sub.add_parser("changes", help="merges, renames, successor links and removed links", parents=[common]).add_subparsers(dest="sub", required=True)
1212
1647
  cl = changes.add_parser("list", help="newest first; undone ones included unless --live", parents=[common])
1213
1648
  cl.add_argument("--entity", help="only this entity's records (an entity key)")
1214
- cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by"])
1649
+ cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by", "link_removed"])
1215
1650
  cl.add_argument("--by", choices=["user", "automatic", "operator"], help="who made the change")
1216
1651
  cl.add_argument("--live", action="store_true", help="leave out undone changes")
1217
1652
  cl.add_argument("--limit", type=int, default=25)
@@ -1226,6 +1661,17 @@ def build_parser() -> argparse.ArgumentParser:
1226
1661
  help="undo a merge even though cells it filled changed since")
1227
1662
  cu.set_defaults(func=cmd_changes_undo)
1228
1663
 
1664
+ links = sub.add_parser("links", help="a record's many-to-many links", parents=[common]).add_subparsers(dest="sub", required=True)
1665
+ lr = links.add_parser("remove", help="remove one link; research will not add it back (needs data:write)", parents=[common])
1666
+ lr.add_argument("entity", help="the entity whose record lists the link (an entity key)")
1667
+ lr.add_argument("record_id")
1668
+ lr.add_argument("to_entity", help="the linked entity")
1669
+ lr.add_argument("target_id", help="the linked record to remove")
1670
+ lr.add_argument("--blueprint", help="Blueprint id (default: the active one)")
1671
+ lr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1672
+ lr.add_argument("--idempotency-key", help="make a retry safe")
1673
+ lr.set_defaults(func=cmd_links_remove)
1674
+
1229
1675
  holds = sub.add_parser("holds", help="cells you set on purpose, and newer research", parents=[common]).add_subparsers(dest="sub", required=True)
1230
1676
  hl = holds.add_parser("list", help="newest first; --pending for those with newer research", parents=[common])
1231
1677
  hl.add_argument("--entity", help="only this entity's cells (an entity key)")
@@ -1268,6 +1714,10 @@ def build_parser() -> argparse.ArgumentParser:
1268
1714
  help="keep the survivor's value on every tie you did not settle")
1269
1715
  mp.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1270
1716
  mp.add_argument("--idempotency-key", help="make a retry safe")
1717
+ mp.add_argument("--preview-digest", metavar="DIGEST",
1718
+ help="apply exactly the merge `merge preview` showed (its digest)")
1719
+ mp.add_argument("--without-preview", action="store_true",
1720
+ help="merge without confirming a preview (a blind merge, on purpose)")
1271
1721
  mp.set_defaults(func=func)
1272
1722
 
1273
1723
  data = sub.add_parser("data", help="your cited dataset", parents=[common]).add_subparsers(dest="sub", required=True)
@@ -253,6 +253,61 @@ def test_run_create_with_neither_sends_an_empty_broad_sweep(monkeypatch):
253
253
  assert seen["body"]["dry_run"] is True
254
254
 
255
255
 
256
+ def test_run_create_sends_the_expected_total_only_when_given(monkeypatch):
257
+ """gh #2710: --expected-total binds the create to the estimate; without it, no field."""
258
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
259
+ bodies = []
260
+
261
+ def handler(request: httpx.Request) -> httpx.Response:
262
+ bodies.append(json.loads(request.content))
263
+ return httpx.Response(202, json={"id": "run_1", "status": "queued"})
264
+
265
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
266
+ base = ["runs", "create", "--seeds", "Acme", "--blueprint", "bp-a", "--yes"]
267
+ assert main(base) == 0
268
+ assert main([*base, "--expected-total", "1000"]) == 0
269
+ assert "expected_total_cents" not in bodies[0]
270
+ assert bodies[1]["expected_total_cents"] == 1000
271
+
272
+
273
+ def test_run_create_that_now_costs_more_says_the_new_total_and_charges_nothing(
274
+ monkeypatch, capsys
275
+ ):
276
+ """gh #2710: a 409 run_price_changed prints the new total and the flag to resend with, and
277
+ exits as a usage refusal (the human decides), never as a started run."""
278
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
279
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
280
+ refusal = {
281
+ "error": {
282
+ "type": "conflict_error",
283
+ "code": "run_price_changed",
284
+ "message": "The price for this run changed since it was estimated.",
285
+ "param": "expected_total_cents",
286
+ "retriable": False,
287
+ "details": {
288
+ "expected_total_cents": 1000,
289
+ "total_price_cents": 2000,
290
+ "interactions": 2,
291
+ "unit_price_cents": 1000,
292
+ },
293
+ }
294
+ }
295
+
296
+ def handler(request: httpx.Request) -> httpx.Response:
297
+ return httpx.Response(409, json=refusal)
298
+
299
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
300
+ code = main(
301
+ ["runs", "create", "--seeds", "Acme", "--blueprint", "bp-a", "--yes",
302
+ "--expected-total", "1000"]
303
+ )
304
+ err = capsys.readouterr().err
305
+ print("exit", code, "err", err)
306
+ assert code == 2
307
+ assert "it is now $20.00. Nothing was charged." in err
308
+ assert "Pass --expected-total 2000 to start at the new price." in err
309
+
310
+
256
311
  # A workspace with one Blueprint, active. `data` commands resolve through this
257
312
  # listing since #1282 removed the ambient /v1/data — so a handler that does not
258
313
  # serve it is a handler the data commands cannot get past.
@@ -360,6 +415,12 @@ def test_unfinished_run_exits_pending(monkeypatch):
360
415
  ["merge", "preview", "books", "--survivor", "s", "--loser", "l", "--json"],
361
416
  ["merge", "apply", "books", "--survivor", "s", "--loser", "l", "--loser", "m", "--yes"],
362
417
  ["changes", "undo", "00000000-0000-0000-0000-000000000000", "--yes"],
418
+ # #2615: the re-research and link-removal lines cli.md now shows.
419
+ ["runs", "reruns", "r"],
420
+ ["runs", "reruns", "r", "--approve", "--expected-total", "900", "--quote-id", "q"],
421
+ ["runs", "reruns", "r", "--auto-approve-on", "--cap", "50"],
422
+ ["runs", "reruns", "r", "--auto-approve-off"],
423
+ ["links", "remove", "funds", "rid", "individuals", "tid"],
363
424
  ],
364
425
  )
365
426
  def test_documented_command_lines_parse(argv):
@@ -696,6 +757,24 @@ def test_run_create_forwards_each_allowed_source(monkeypatch):
696
757
  assert "allowed_sources" not in seen["body"]
697
758
 
698
759
 
760
+ def test_run_create_forwards_each_excluded_source(monkeypatch):
761
+ """gh #2641 — `--excluded-source` mirrors `--allowed-source`: repeatable, a list, absent
762
+ when not given (agent parity: every surface that takes one takes the other)."""
763
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
764
+ seen = {}
765
+
766
+ def handler(request: httpx.Request) -> httpx.Response:
767
+ seen["body"] = json.loads(request.content)
768
+ return httpx.Response(202, json={"id": "run_1", "status": "queued"})
769
+
770
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
771
+ assert main(["runs", "create", "--seeds", "Acme", "--dry-run",
772
+ "--excluded-source", "wikipedia.org", "--excluded-source", " *.example "]) == 0
773
+ assert seen["body"]["excluded_sources"] == ["wikipedia.org", "*.example"]
774
+ assert main(["runs", "create", "--seeds", "Acme", "--dry-run"]) == 0
775
+ assert "excluded_sources" not in seen["body"]
776
+
777
+
699
778
  def test_run_create_names_the_blueprint_it_sends_and_the_one_it_got(monkeypatch, capsys):
700
779
  """gh #1282: `--blueprint` reaches the body, and the Blueprint the run is on is
701
780
  printed, so a run on the wrong one is visible at once."""
@@ -1094,7 +1173,8 @@ def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch
1094
1173
 
1095
1174
  def test_a_billable_run_without_a_blueprint_is_a_usage_error_before_any_request(monkeypatch, capsys):
1096
1175
  """gh #1282 B1: the API now requires `blueprint_id` on run create; the CLI says so first, with
1097
- where the id comes from, and sends nothing. A dry run may omit it (the API's own exception)."""
1176
+ where the id comes from, and sends nothing. A dry run may omit it (the API's own exception).
1177
+ """
1098
1178
  cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
1099
1179
  monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
1100
1180
  sent: list[str] = []
@@ -1280,7 +1360,8 @@ def test_declining_the_lock_sends_nothing_more(monkeypatch, capsys):
1280
1360
 
1281
1361
  def test_a_piped_yes_from_a_terminal_never_commits_30_days(monkeypatch, capsys):
1282
1362
  """gh #2345 review, the seat's exact case: stdout is a terminal, stdin is `yes |`. The gate
1283
- looked at stdout only, so both questions were answered from the pipe and 30 days committed."""
1363
+ looked at stdout only, so both questions were answered from the pipe and 30 days committed.
1364
+ """
1284
1365
  import io
1285
1366
 
1286
1367
  _signed_in(monkeypatch)
@@ -1358,3 +1439,534 @@ def test_end_of_input_at_the_lock_question_is_a_no_not_a_traceback(monkeypatch,
1358
1439
  monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
1359
1440
  assert main(["runs", "monitoring", RUN, "--on"]) == 0
1360
1441
  assert seen == [{"active": True}]
1442
+
1443
+
1444
+ # ── runs retry: a charging retry is confirmed by its total (gh #2389) ────────
1445
+
1446
+ _RETRY_QUOTE = {
1447
+ "total_price_cents": 1200,
1448
+ "interactions": 2,
1449
+ "unit_price_cents": 600,
1450
+ "shortfall_cents": 0,
1451
+ "entities": ["Acme Corp", "Initech"],
1452
+ "refund": {"kind": "refunded_dollars", "amount_cents": 1200},
1453
+ "max_depth": False,
1454
+ "reuse_predicted": ["Held Co"],
1455
+ "skipped_already_held": [],
1456
+ }
1457
+
1458
+
1459
+ def _retry_handler(seen: list, *, price: int = 1200):
1460
+ def handler(request: httpx.Request) -> httpx.Response:
1461
+ body = json.loads(request.content) if request.content else {}
1462
+ seen.append(body)
1463
+ if body.get("expected_total_cents") != price:
1464
+ code = "retry_price_changed" if "expected_total_cents" in body else (
1465
+ "retry_confirmation_required"
1466
+ )
1467
+ return httpx.Response(409, json={"error": {
1468
+ "code": code, "message": "confirm the total", "param": "expected_total_cents",
1469
+ "retriable": False, "details": {**_RETRY_QUOTE, "total_price_cents": price},
1470
+ }})
1471
+ return httpx.Response(202, json={
1472
+ "object": "run", "id": "new-run", "status": "queued",
1473
+ "retry": {"source_run_id": RUN, "mode": "restarted"},
1474
+ })
1475
+
1476
+ return handler
1477
+
1478
+
1479
+ def test_a_charging_retry_in_a_pipe_names_the_total_and_charges_nothing(monkeypatch, capsys):
1480
+ _signed_in(monkeypatch)
1481
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
1482
+ seen: list = []
1483
+ monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
1484
+ assert main(["runs", "retry", RUN]) == 2
1485
+ err_text = capsys.readouterr().err
1486
+ print("refusal:", err_text)
1487
+ assert "$12.00 is back on your account" in err_text and "charges $12.00" in err_text
1488
+ assert "1 carried over at no charge: Held Co" in err_text
1489
+ assert "--expected-total 1200" in err_text
1490
+ assert seen == [{}], "only the unconfirmed call, which charges nothing"
1491
+
1492
+
1493
+ def test_expected_total_confirms_in_one_call(monkeypatch, capsys):
1494
+ _signed_in(monkeypatch)
1495
+ seen: list = []
1496
+ monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
1497
+ assert main(["runs", "retry", RUN, "--expected-total", "1200"]) == 0
1498
+ assert seen == [{"expected_total_cents": 1200}]
1499
+ assert "restarted as new-run" in capsys.readouterr().out
1500
+
1501
+
1502
+ def test_interactively_the_yes_is_bound_to_the_total_shown(monkeypatch, capsys):
1503
+ _signed_in(monkeypatch)
1504
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1505
+ prompts: list = []
1506
+ monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
1507
+ seen: list = []
1508
+ monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
1509
+ assert main(["runs", "retry", RUN]) == 0
1510
+ assert seen == [{}, {"expected_total_cents": 1200}]
1511
+ assert "Retry at $12.00?" in prompts[0]
1512
+
1513
+
1514
+ def test_declining_a_charging_retry_sends_nothing_more(monkeypatch, capsys):
1515
+ _signed_in(monkeypatch)
1516
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1517
+ monkeypatch.setattr("builtins.input", lambda prompt="": "n")
1518
+ seen: list = []
1519
+ monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
1520
+ assert main(["runs", "retry", RUN]) == 0
1521
+ assert seen == [{}]
1522
+ assert "Nothing was charged" in capsys.readouterr().out
1523
+
1524
+
1525
+ def test_a_moved_price_is_asked_again_with_the_new_total(monkeypatch, capsys):
1526
+ """Quoted at one price, confirmed at an old one: the API answers `retry_price_changed` with
1527
+ the new total; the person is asked again and the new yes is bound to the new total."""
1528
+ _signed_in(monkeypatch)
1529
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1530
+ prompts: list = []
1531
+ monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
1532
+ seen: list = []
1533
+ monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen, price=1500)))
1534
+ assert main(["runs", "retry", RUN, "--expected-total", "1200"]) == 0
1535
+ assert seen == [{"expected_total_cents": 1200}, {"expected_total_cents": 1500}]
1536
+ assert "price changed" in prompts[0] and "$15.00" in prompts[0]
1537
+
1538
+
1539
+ def test_a_retry_refusal_under_json_is_machine_readable(monkeypatch, capsys):
1540
+ _signed_in(monkeypatch)
1541
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
1542
+ seen: list = []
1543
+ monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
1544
+ assert main(["runs", "retry", RUN, "--json"]) == 2
1545
+ out_json = json.loads(capsys.readouterr().out)
1546
+ assert out_json["error"]["code"] == "retry_confirmation_required"
1547
+ assert out_json["error"]["details"]["total_price_cents"] == 1200
1548
+
1549
+
1550
+ def test_a_retry_that_charges_nothing_starts_without_asking(monkeypatch, capsys):
1551
+ _signed_in(monkeypatch)
1552
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
1553
+ seen: list = []
1554
+
1555
+ def handler(request):
1556
+ seen.append(json.loads(request.content) if request.content else {})
1557
+ return httpx.Response(202, json={"object": "run", "id": RUN, "status": "running",
1558
+ "retry": {"source_run_id": RUN, "mode": "resumed"}})
1559
+
1560
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1561
+ assert main(["runs", "retry", RUN]) == 0
1562
+ assert seen == [{}]
1563
+ assert f"retry of {RUN} resumed" in capsys.readouterr().out
1564
+
1565
+
1566
+ def test_retry_refuses_a_run_id_that_is_not_a_uuid(monkeypatch, capsys):
1567
+ _signed_in(monkeypatch)
1568
+ seen: list = []
1569
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: seen.append(r) or httpx.Response(500)))
1570
+ assert main(["runs", "retry", "../billing"]) == 2
1571
+ assert seen == []
1572
+
1573
+
1574
+ def test_a_price_that_keeps_moving_is_asked_twice_and_never_a_third_time(monkeypatch, capsys):
1575
+ """seat-2389 L1: the third refusal ends the loop without a question whose yes is not sent."""
1576
+ _signed_in(monkeypatch)
1577
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1578
+ prompts: list = []
1579
+ monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
1580
+ seen: list = []
1581
+ price = iter([1200, 1300, 1400])
1582
+
1583
+ def handler(request):
1584
+ body = json.loads(request.content) if request.content else {}
1585
+ seen.append(body)
1586
+ now = next(price)
1587
+ return httpx.Response(409, json={"error": {
1588
+ "code": "retry_price_changed" if body else "retry_confirmation_required",
1589
+ "message": "moved", "param": "expected_total_cents", "retriable": False,
1590
+ "details": {**_RETRY_QUOTE, "total_price_cents": now},
1591
+ }})
1592
+
1593
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1594
+ assert main(["runs", "retry", RUN]) == 1
1595
+ print("sent:", seen, "prompts:", len(prompts))
1596
+ assert seen == [{}, {"expected_total_cents": 1200}, {"expected_total_cents": 1300}]
1597
+ assert len(prompts) == 2
1598
+ assert "kept refusing" in capsys.readouterr().err
1599
+
1600
+
1601
+ def test_a_quote_without_a_total_is_never_asked_as_zero(monkeypatch, capsys):
1602
+ """seat-2389 L2: no `total_price_cents`, no prompt; never "$0.00"."""
1603
+ _signed_in(monkeypatch)
1604
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1605
+ prompts: list = []
1606
+ monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
1607
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json={"error": {
1608
+ "code": "retry_confirmation_required", "message": "confirm", "retriable": False}})))
1609
+ assert main(["runs", "retry", RUN]) == 1
1610
+ assert prompts == []
1611
+ assert "$0.00" not in capsys.readouterr().err
1612
+
1613
+
1614
+ @pytest.mark.parametrize(
1615
+ "refund, words",
1616
+ [
1617
+ ({"kind": "kept_credits", "credits": 2, "run_status": "completed"},
1618
+ "Your earlier run used 2 run credits. It finished with no results"),
1619
+ ({"kind": "refund_pending", "run_status": "failed"}, "Your failed run is being refunded."),
1620
+ ({"kind": "refunded_to_ended_plan", "run_status": "cancelled"},
1621
+ "Your plan has ended, so the credits for your stopped run were returned to it"),
1622
+ ({"kind": "refunded_to_ended_period", "run_status": "failed"},
1623
+ "The credits for your failed run went back to the billing period they came from"),
1624
+ ({"kind": "unconfirmed"}, "We can't confirm yet whether your earlier run was refunded."),
1625
+ ],
1626
+ ids=["kept-credits", "pending", "ended-plan", "ended-period", "unconfirmed"],
1627
+ )
1628
+ def test_every_refund_kind_reads_in_words_never_its_code(monkeypatch, capsys, refund, words):
1629
+ """seat-2389 L3: the app's sentences (site `refundHeadline`), never a raw enum name."""
1630
+ _signed_in(monkeypatch)
1631
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
1632
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json={"error": {
1633
+ "code": "retry_confirmation_required", "message": "confirm", "retriable": False,
1634
+ "details": {**_RETRY_QUOTE, "refund": refund}}})))
1635
+ main(["runs", "retry", RUN])
1636
+ err_text = capsys.readouterr().err
1637
+ assert words in err_text
1638
+ assert refund["kind"] not in err_text
1639
+
1640
+
1641
+ @pytest.mark.parametrize(
1642
+ "funding, cost",
1643
+ [
1644
+ ({"covered_by": "plan_credits", "plan_credits_needed": 2}, "uses 2 plan run credits (list price $12.00)"),
1645
+ ({"covered_by": "free_run"}, "uses your free run (list price $12.00)"),
1646
+ ({"covered_by": "balance"}, "charges $12.00"),
1647
+ ],
1648
+ ids=["plan-credits", "free-run", "balance"],
1649
+ )
1650
+ def test_the_quote_says_what_pays_not_only_the_list_price(monkeypatch, capsys, funding, cost):
1651
+ """seat-2389 M3: `total_price_cents` is the list price even when credits or the free run
1652
+ cover the retry; the human is told what will actually be spent."""
1653
+ _signed_in(monkeypatch)
1654
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
1655
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json={"error": {
1656
+ "code": "retry_confirmation_required", "message": "confirm", "retriable": False,
1657
+ "details": {**_RETRY_QUOTE, "funding": funding}}})))
1658
+ main(["runs", "retry", RUN])
1659
+ assert f"Retrying starts a new run and {cost}." in capsys.readouterr().err
1660
+
1661
+
1662
+
1663
+ # --- gh #2351: runs reruns ---------------------------------------------------------------------
1664
+
1665
+ _RERUNS = {
1666
+ "object": "reruns",
1667
+ "run_id": RUN,
1668
+ "awaiting_count": 2,
1669
+ "awaiting_total_cents": 2000,
1670
+ "items": [{"entity": "Acme", "column_label": "HQ city"}, {"entity": "Beta", "column": "ceo"}],
1671
+ "included_per_period": 1,
1672
+ "included_used": 1,
1673
+ "next_period_start": "2026-10-06",
1674
+ "auto_approve": False,
1675
+ "quote_id": "rq_abc",
1676
+ }
1677
+
1678
+
1679
+ def test_runs_reruns_shows_what_is_waiting(monkeypatch, capsys):
1680
+ _signed_in(monkeypatch)
1681
+ seen = []
1682
+
1683
+ def handler(request: httpx.Request) -> httpx.Response:
1684
+ seen.append((request.method, request.url.path))
1685
+ return httpx.Response(200, json=_RERUNS)
1686
+
1687
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1688
+ assert main(["runs", "reruns", RUN]) == 0
1689
+ assert seen == [("GET", f"/v1/research/runs/{RUN}/reruns")]
1690
+ out = capsys.readouterr().out
1691
+ assert "2 waiting" in out and "$20.00" in out and "Acme" in out
1692
+
1693
+
1694
+ def test_runs_reruns_approve_sends_the_total_and_a_key(monkeypatch, capsys):
1695
+ _signed_in(monkeypatch)
1696
+ seen = {}
1697
+
1698
+ def handler(request: httpx.Request) -> httpx.Response:
1699
+ seen["method"], seen["path"] = request.method, request.url.path
1700
+ seen["body"] = json.loads(request.content)
1701
+ seen["key"] = request.headers.get("Idempotency-Key")
1702
+ return httpx.Response(200, json={"approved": 1, "still_waiting": 0, "run_ids": []})
1703
+
1704
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1705
+ assert main(["runs", "reruns", RUN, "--approve", "--expected-total", "2000", "--quote-id", "rq_abc"]) == 0
1706
+ assert (seen["method"], seen["path"]) == ("POST", f"/v1/research/runs/{RUN}/reruns/approve")
1707
+ assert seen["body"] == {"expected_total_cents": 2000, "quote_id": "rq_abc"}
1708
+ assert seen["key"], "approving spends money: an Idempotency-Key must be sent"
1709
+
1710
+
1711
+ def test_runs_reruns_approve_in_a_pipe_needs_the_total_and_sends_nothing(monkeypatch, capsys):
1712
+ _signed_in(monkeypatch)
1713
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
1714
+ calls = []
1715
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
1716
+ assert main(["runs", "reruns", RUN, "--approve"]) == 2
1717
+ assert main(["runs", "reruns", RUN, "--approve", "--expected-total", "2000"]) == 2 # no quote
1718
+ assert "--quote-id" in capsys.readouterr().err
1719
+ assert calls == []
1720
+
1721
+
1722
+ def test_runs_reruns_turns_auto_approve_on_only_with_a_cap_and_a_yes(monkeypatch, capsys):
1723
+ """gh #2506: an agent may turn auto-approve ON (it lapses when its key is revoked). It is
1724
+ standing authority to spend, so the monthly cap is REQUIRED, and it is confirmed like an
1725
+ approval: interactively or with --yes, never silently in a pipe."""
1726
+ _signed_in(monkeypatch)
1727
+ calls = []
1728
+
1729
+ def handler(request: httpx.Request) -> httpx.Response:
1730
+ calls.append((request.method, json.loads(request.content)))
1731
+ return httpx.Response(200, json=_RERUNS)
1732
+
1733
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1734
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
1735
+ with pytest.raises(SystemExit):
1736
+ main(["runs", "reruns", RUN, "--approve", "--auto-approve-on"]) # one action at a time
1737
+ assert main(["runs", "reruns", RUN, "--auto-approve-on", "--yes"]) == 2 # no cap
1738
+ assert "--cap" in capsys.readouterr().err
1739
+ assert main(["runs", "reruns", RUN, "--auto-approve-on", "--cap", "50"]) == 2 # a pipe, no yes
1740
+ assert "--yes" in capsys.readouterr().err
1741
+ assert calls == [], "nothing may be sent without a cap and a yes"
1742
+ assert main(["runs", "reruns", RUN, "--auto-approve-on", "--cap", "50", "--yes"]) == 0
1743
+ assert calls == [("PATCH", {"auto_approve": True, "auto_approve_cap_cents": 5000})]
1744
+ assert main(["runs", "reruns", RUN, "--auto-approve-off"]) == 0
1745
+ assert calls[-1] == ("PATCH", {"auto_approve": False})
1746
+
1747
+
1748
+ @pytest.mark.parametrize("cap", ["nan", "inf", "1e400", "0.004", "-1", "21474836.48"])
1749
+ def test_runs_reruns_refuses_a_cap_the_api_cannot_take_before_asking(monkeypatch, capsys, cap):
1750
+ """seat-2506b F3/F4: a cap that is not a finite number used to traceback, and one that
1751
+ rounds under a cent was confirmed as "$0.00 a month" and then refused by the API. The CLI
1752
+ applies the API's own rule (1 cent to the column's ceiling) before it asks or sends."""
1753
+ _signed_in(monkeypatch)
1754
+ calls, asked = [], []
1755
+ monkeypatch.setattr(
1756
+ httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200, json=_RERUNS))
1757
+ )
1758
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1759
+ monkeypatch.setattr("deepsieve_cli.main._yes", lambda prompt: asked.append(prompt) or True)
1760
+ assert main(["runs", "reruns", RUN, "--auto-approve-on", "--cap", cap]) == 2
1761
+ assert calls == [] and asked == [], "it asked or sent for a cap the API refuses"
1762
+ assert "--cap" in capsys.readouterr().err
1763
+
1764
+
1765
+ def test_runs_reruns_refuses_cap_or_yes_without_auto_approve_on(monkeypatch, capsys):
1766
+ """seat-2506b F5: --cap and --yes belong to --auto-approve-on; silently ignoring them let a
1767
+ user believe --yes had confirmed an approval."""
1768
+ _signed_in(monkeypatch)
1769
+ calls = []
1770
+ monkeypatch.setattr(
1771
+ httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200, json=_RERUNS))
1772
+ )
1773
+ assert main(["runs", "reruns", RUN, "--cap", "50"]) == 2
1774
+ assert main(["runs", "reruns", RUN, "--approve", "--yes"]) == 2
1775
+ assert calls == []
1776
+ assert "--auto-approve-on" in capsys.readouterr().err
1777
+
1778
+
1779
+ def test_runs_reruns_asks_before_turning_auto_approve_on(monkeypatch, capsys):
1780
+ _signed_in(monkeypatch)
1781
+ calls = []
1782
+
1783
+ def handler(request: httpx.Request) -> httpx.Response:
1784
+ calls.append((request.method, json.loads(request.content)))
1785
+ return httpx.Response(200, json=_RERUNS)
1786
+
1787
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1788
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1789
+ asked = []
1790
+ monkeypatch.setattr("deepsieve_cli.main._yes", lambda prompt: asked.append(prompt) or False)
1791
+ assert main(["runs", "reruns", RUN, "--auto-approve-on", "--cap", "12.5"]) == 0
1792
+ assert calls == [], "a declined prompt sent something"
1793
+ assert "$12.50 a month" in asked[0] and "revoked" in asked[0]
1794
+
1795
+
1796
+ def test_runs_reruns_a_moved_price_says_nothing_was_charged(monkeypatch, capsys):
1797
+ _signed_in(monkeypatch)
1798
+
1799
+ def handler(request: httpx.Request) -> httpx.Response:
1800
+ return httpx.Response(
1801
+ 409,
1802
+ json={
1803
+ "error": {
1804
+ "type": "conflict_error",
1805
+ "code": "rerun_price_changed",
1806
+ "message": "moved",
1807
+ "details": {"total_cents": 3000, "count": 3},
1808
+ }
1809
+ },
1810
+ )
1811
+
1812
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1813
+ assert main(["runs", "reruns", RUN, "--approve", "--expected-total", "2000", "--quote-id", "rq_abc"]) != 0
1814
+ err = capsys.readouterr().err
1815
+ assert "$30.00" in err and "Nothing was charged" in err
1816
+
1817
+
1818
+ def test_runs_reruns_past_the_ceiling_refuses_before_asking(monkeypatch, capsys):
1819
+ """gh #2351 delta review: past what the API lists there is no quote. The CLI must say why
1820
+ and stop, never ask "Approve 501?" over rows it could not show (and never POST)."""
1821
+ _signed_in(monkeypatch)
1822
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1823
+ asked: list = []
1824
+ monkeypatch.setattr("deepsieve_cli.main._yes", lambda prompt: asked.append(prompt) or True)
1825
+ withheld = "501 are waiting; more than 500 can't be approved at once through the API."
1826
+ calls = []
1827
+
1828
+ def handler(request: httpx.Request) -> httpx.Response:
1829
+ calls.append(request.method)
1830
+ return httpx.Response(
1831
+ 200,
1832
+ json={**_RERUNS, "awaiting_count": 501, "quote_id": None, "quote_withheld": withheld},
1833
+ )
1834
+
1835
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1836
+ assert main(["runs", "reruns", RUN, "--approve"]) == 2
1837
+ captured = capsys.readouterr()
1838
+ assert withheld in captured.out and "Nothing was charged" in captured.err
1839
+ assert asked == [] and calls == ["GET"]
1840
+
1841
+
1842
+ def test_runs_reruns_nothing_waiting_any_more_is_said_plainly(monkeypatch, capsys):
1843
+ _signed_in(monkeypatch)
1844
+ err_body = {"error": {"code": "rerun_price_changed", "message": "m", "param": "expected_total_cents",
1845
+ "retriable": False, "details": {"total_cents": 0, "count": 0}}}
1846
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=err_body)))
1847
+ assert main(["runs", "reruns", RUN, "--approve", "--expected-total", "2000", "--quote-id", "rq_abc"]) != 0
1848
+ err = capsys.readouterr().err
1849
+ assert "nothing is waiting any more" in err and "$0.00" not in err
1850
+
1851
+
1852
+ _LINK = {
1853
+ "object": "change",
1854
+ "id": "11111111-1111-4111-8111-111111111111",
1855
+ "change_id": "11111111-1111-4111-8111-111111111111",
1856
+ "kind": "link_removed",
1857
+ "link": {"record_name": "Growth Fund", "target_name": "Ann", "to_entity": "individuals"},
1858
+ }
1859
+
1860
+
1861
+ def test_links_remove_confirms_sends_the_link_and_prints_the_undo(monkeypatch, capsys):
1862
+ """gh #2514: it changes data, so it is confirmed like a merge; never silent in a pipe."""
1863
+ _signed_in(monkeypatch)
1864
+ seen = []
1865
+
1866
+ def handler(request: httpx.Request) -> httpx.Response:
1867
+ seen.append((request.method, request.url.path, json.loads(request.content)))
1868
+ return httpx.Response(200, json=_LINK)
1869
+
1870
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1871
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
1872
+ args = ["links", "remove", "funds", RUN, "individuals", RUN]
1873
+ assert main(args) == 2 and seen == [], "a pipe without --yes must not change data"
1874
+ assert main([*args, "--yes"]) == 0
1875
+ assert seen == [("POST", "/v1/links/remove", {
1876
+ "entity": "funds", "record_id": RUN, "to_entity": "individuals", "target_id": RUN})]
1877
+ out = capsys.readouterr().out
1878
+ assert "removed Ann from Growth Fund" in out and "deepsieve changes undo 1111" in out
1879
+
1880
+
1881
+ def test_links_remove_refuses_a_bad_id_before_any_request(monkeypatch):
1882
+ _signed_in(monkeypatch)
1883
+ calls = []
1884
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
1885
+ assert main(["links", "remove", "funds", "nope", "individuals", RUN, "--yes"]) == 2
1886
+ assert calls == []
1887
+
1888
+
1889
+ # ── merge: what you previewed is what you get (#2409) ─────────────────────────
1890
+
1891
+
1892
+ def test_merge_preview_prints_the_digest_to_apply_exactly_that_merge(monkeypatch, capsys):
1893
+ _signed_in(monkeypatch)
1894
+ monkeypatch.setattr(
1895
+ httpx, "Client", _mock(lambda r: httpx.Response(200, json=_merge_result(preview_digest="d1")))
1896
+ )
1897
+ assert main(["merge", "preview", "books", "--survivor", _S, "--loser", _L]) == 0
1898
+ assert "--preview-digest d1" in capsys.readouterr().out
1899
+
1900
+
1901
+ def test_merge_apply_sends_the_digest_or_the_blind_flag_never_both(monkeypatch, capsys):
1902
+ _signed_in(monkeypatch)
1903
+ sent = []
1904
+
1905
+ def handler(request: httpx.Request) -> httpx.Response:
1906
+ sent.append(json.loads(request.content))
1907
+ return httpx.Response(201, json=_merge_result(applied=True, change_id="c-1", unresolved_ties=[]))
1908
+
1909
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1910
+ base = ["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes"]
1911
+ assert main([*base, "--preview-digest", "d1"]) == 0
1912
+ assert sent[-1]["preview_digest"] == "d1" and "apply_without_preview" not in sent[-1]
1913
+ assert main([*base, "--without-preview"]) == 0
1914
+ assert sent[-1]["apply_without_preview"] is True and "preview_digest" not in sent[-1]
1915
+ n = len(sent)
1916
+ assert main([*base, "--preview-digest", "d1", "--without-preview"]) == 2
1917
+ assert len(sent) == n, "a contradiction is refused before any request"
1918
+ assert "not both" in capsys.readouterr().err
1919
+
1920
+
1921
+ def test_a_changed_merge_shows_what_it_would_do_now_and_exits_as_usage(monkeypatch, capsys):
1922
+ """The person sees what moved: the refusal's current preview, printed, and exit 2 (fixed by
1923
+ re-checking and re-sending, not by retrying unchanged)."""
1924
+ _signed_in(monkeypatch)
1925
+ now = _merge_result(preview_digest="d2")
1926
+ now["conflicts"][0] |= {"survivor_value": "Cat"}
1927
+ body = {"error": {"type": "conflict_error", "code": "merge_changed", "retriable": False,
1928
+ "message": "This merge changed since its preview.", "param": "preview_digest",
1929
+ "details": {"preview": now}}}
1930
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
1931
+ argv = ["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes", "--preview-digest", "d1"]
1932
+ assert main(argv) == 2
1933
+ captured = capsys.readouterr()
1934
+ assert "changed since its preview" in captured.err
1935
+ assert "survivor='Cat'" in captured.out and "--preview-digest d2" in captured.out
1936
+
1937
+
1938
+ def test_a_changed_merge_under_json_prints_the_current_merge_as_json(monkeypatch, capsys):
1939
+ """seat-2409 5c: --json keeps stdout parseable on merge_changed."""
1940
+ _signed_in(monkeypatch)
1941
+ now = _merge_result(preview_digest="d2")
1942
+ body = {"error": {"type": "conflict_error", "code": "merge_changed", "retriable": False,
1943
+ "message": "This merge changed since its preview.", "param": "preview_digest",
1944
+ "details": {"preview": now}}}
1945
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
1946
+ argv = ["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes",
1947
+ "--preview-digest", "d1", "--json"]
1948
+ assert main(argv) == 2
1949
+ printed = json.loads(capsys.readouterr().out)
1950
+ assert printed["preview_digest"] == "d2"
1951
+
1952
+
1953
+ def test_run_create_says_which_seeds_were_counted_once(monkeypatch, capsys):
1954
+ """gh #2716 — the 202's `folded_duplicates` is printed, so a CLI user sees why fewer seeds
1955
+ were researched than were sent; nothing is printed when nothing folded."""
1956
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
1957
+ folds = [
1958
+ {"seed": "Fund X, L.P.", "kept_as": "Fund X", "reason": "legal_form"},
1959
+ {"seed": "acme", "kept_as": "Acme", "reason": "case"},
1960
+ ]
1961
+ reply = {"id": "run_1", "status": "queued", "folded_duplicates": folds}
1962
+
1963
+ def handler(request: httpx.Request) -> httpx.Response:
1964
+ return httpx.Response(202, json=reply)
1965
+
1966
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1967
+ assert main(["runs", "create", "--seeds", "Fund X,Fund X, L.P.,Acme,acme", "--dry-run"]) == 0
1968
+ printed = capsys.readouterr().out
1969
+ assert '2 counted once: "Fund X, L.P." as "Fund X", "acme" as "Acme".' in printed
1970
+ reply.pop("folded_duplicates")
1971
+ assert main(["runs", "create", "--seeds", "Fund X", "--dry-run"]) == 0
1972
+ assert "counted once" not in capsys.readouterr().out
File without changes