deepsieve-cli 0.8.0__tar.gz → 0.9.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.
@@ -139,3 +139,6 @@ page-*.yml
139
139
  # same mechanism as the node_modules note above, a second time. Reports belong
140
140
  # in the session scratchpad; a copy in the tree is only ever a transient.
141
141
  .review-*/
142
+
143
+ # gh #3106: the old build scripts/check-cross-version-seam.sh archives (never committed)
144
+ /.cross-version/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: deepsieve-cli
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepsieve-cli"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "DeepSieve CLI — run cited deep research from your terminal"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -343,6 +343,11 @@ def cmd_runs_create(args) -> int:
343
343
  if args.query and args.seeds:
344
344
  err("pass at most one of --query or --seeds")
345
345
  return EXIT_USAGE
346
+ phrases = [s.strip() for s in (getattr(args, "phrase", None) or []) if s.strip()]
347
+ file_types = [s.strip() for s in (getattr(args, "file_type", None) or []) if s.strip()]
348
+ if getattr(args, "no_search_hints", False) and (phrases or file_types):
349
+ err("pass --no-search-hints, or --phrase/--file-type, not both")
350
+ return EXIT_USAGE
346
351
  # gh #1282 B1: a billable run must NAME its Blueprint (the API refuses one that does not);
347
352
  # say so before anything else, with where the id comes from. A dry run is free and may omit it.
348
353
  if not args.dry_run and not getattr(args, "blueprint", None):
@@ -364,6 +369,16 @@ def cmd_runs_create(args) -> int:
364
369
  if excluded:
365
370
  # gh #2641 — repeatable; the server resolves and refuses, the CLI only carries.
366
371
  body["excluded_sources"] = excluded
372
+ # gh #2657 — the exact phrases and file types the researcher also searches for (never a
373
+ # filter). Given, they REPLACE the ones the server reads from --query; --no-search-hints
374
+ # sends {} so a misread one is dropped. Neither: the parsed ones, as before.
375
+ if getattr(args, "no_search_hints", False):
376
+ body["search_hints"] = {}
377
+ elif phrases or file_types:
378
+ body["search_hints"] = {
379
+ **({"exact_phrases": phrases} if phrases else {}),
380
+ **({"file_types": file_types} if file_types else {}),
381
+ }
367
382
  if getattr(args, "blueprint", None):
368
383
  body["blueprint_id"] = args.blueprint
369
384
  if getattr(args, "expected_total", None) is not None:
@@ -738,6 +753,13 @@ def _refund_line(refund: dict | None) -> str:
738
753
  if kind == "kept_credits":
739
754
  used = plural(credits, "run credit", "run credits") if credits is not None else "run credits"
740
755
  return f"Your earlier run used {used}. It finished with no results, and a finished run isn't refunded."
756
+ if kind == "kept_mixed":
757
+ # gh #1858: a finished mixed run kept its credits AND its prepaid part.
758
+ if credits is not None and amount is not None:
759
+ used = f"{plural(credits, 'run credit', 'run credits')} and {_usd(amount)} of your prepaid balance"
760
+ else:
761
+ used = "run credits and your prepaid balance"
762
+ return f"Your earlier run used {used}. It finished with no results, and a finished run isn't refunded."
741
763
  if kind == "refunded_to_ended_plan":
742
764
  return f"Your plan has ended, so the credits for your {run} were returned to it and can't be used."
743
765
  if kind == "refunded_to_ended_period":
@@ -757,6 +779,10 @@ def _refund_line(refund: dict | None) -> str:
757
779
  if kind == "partially_refunded_to_plan":
758
780
  return (f"Part of your {run} returned nothing. The credits for that part were returned to "
759
781
  "the plan they came from.")
782
+ if kind == "not_refunded_account_closed":
783
+ # gh #3045: closure voids prepaid credit, so nothing was sent: never "refunded".
784
+ return (f"Nothing was returned for your {run}, because your account is closed and a "
785
+ "closed account can't hold prepaid credit.")
760
786
  if kind == "refunded_unknown":
761
787
  return f"Your {run} was refunded in full."
762
788
  if kind == "refund_pending":
@@ -781,6 +807,16 @@ def _retry_cost(details: dict) -> str:
781
807
  if funding.get("covered_by") == "plan_credits":
782
808
  n = funding.get("plan_credits_needed")
783
809
  return f"uses {n} plan run credit{'' if n == 1 else 's'} (list price {_usd(total)})"
810
+ if funding.get("covered_by") == "mixed":
811
+ # gh #1858: plan credits for the interactions they cover, the balance for the rest.
812
+ prepaid = _usd(funding.get("prepaid_cents"))
813
+ cost = (
814
+ f"uses your plan run credits and {prepaid} from your balance "
815
+ f"(list price {_usd(total)})"
816
+ )
817
+ if (details.get("shortfall_cents") or 0) > 0:
818
+ cost += f", and your balance is {_usd(details['shortfall_cents'])} short"
819
+ return cost
784
820
  cost = f"charges {_usd(total)}"
785
821
  if (details.get("shortfall_cents") or 0) > 0:
786
822
  cost += f", and your balance is {_usd(details['shortfall_cents'])} short"
@@ -838,6 +874,10 @@ def cmd_runs_retry(args) -> int:
838
874
  if args.expected_funding is not None and args.expected_total is None:
839
875
  err("--expected-funding binds the rail of a quoted total: pass --expected-total too")
840
876
  return EXIT_USAGE
877
+ # gh #1858: a mixed rail binds its split, so it needs the prepaid part; nothing else takes one.
878
+ if (args.expected_funding == "mixed") != (args.expected_prepaid is not None):
879
+ err("--expected-funding mixed and --expected-prepaid CENTS go together (funding.prepaid_cents)")
880
+ return EXIT_USAGE
841
881
  client, _ = _require_auth(args)
842
882
  path = f"/v1/research/runs/{rid}/retry"
843
883
  payload: dict = {}
@@ -845,6 +885,8 @@ def cmd_runs_retry(args) -> int:
845
885
  payload["expected_total_cents"] = args.expected_total
846
886
  if args.expected_funding is not None:
847
887
  payload["expected_funding"] = args.expected_funding
888
+ if args.expected_prepaid is not None:
889
+ payload["expected_prepaid_cents"] = args.expected_prepaid
848
890
  # At most two refusals: the quote, and one re-quote if the price moved while the person was
849
891
  # deciding. A third would be the API contradicting itself; stop rather than loop, and never ask
850
892
  # a question whose yes would not be sent (seat-2389 L1).
@@ -863,11 +905,24 @@ def cmd_runs_retry(args) -> int:
863
905
  # No quote to state: never prompt "$0.00" (seat-2389 L2).
864
906
  err(f"{exc} (the API sent no total to confirm; nothing was charged)")
865
907
  return EXIT_FAIL
908
+ funding = exc.details.get("funding") or {}
909
+ rail = funding.get("covered_by")
910
+ prepaid = funding.get("prepaid_cents")
911
+ if rail == "mixed" and (not isinstance(prepaid, int) or isinstance(prepaid, bool)):
912
+ # gh #1858 review: a mixed quote without its prepaid part cannot be confirmed
913
+ # (the API refuses `mixed` without it), so say so, never `--expected-prepaid
914
+ # None`. Checked before the quote text, which prices the split.
915
+ err(
916
+ f"{exc} (the API's quote names a split without its prepaid part; "
917
+ "nothing was charged)"
918
+ )
919
+ return EXIT_FAIL
866
920
  ask = _retry_quote_text(
867
921
  exc.details, moved=exc.code == "retry_price_changed", sent=payload
868
922
  )
869
- rail = (exc.details.get("funding") or {}).get("covered_by")
870
923
  flags = f"--expected-total {total}" + (f" --expected-funding {rail}" if rail else "")
924
+ if rail == "mixed":
925
+ flags += f" --expected-prepaid {prepaid}"
871
926
  if not _interactive():
872
927
  if args.json:
873
928
  print(json.dumps({"error": {"code": exc.code, "message": ask, "details": exc.details}}))
@@ -881,6 +936,8 @@ def cmd_runs_retry(args) -> int:
881
936
  payload = {"expected_total_cents": total}
882
937
  if rail:
883
938
  payload["expected_funding"] = rail # gh #2456: the yes covers what pays too
939
+ if rail == "mixed":
940
+ payload["expected_prepaid_cents"] = prepaid # gh #1858: and the split
884
941
  if body is None:
885
942
  err("the API kept refusing the confirmation; nothing was charged")
886
943
  return EXIT_FAIL
@@ -998,6 +1055,12 @@ def _describe_change(c: dict) -> str:
998
1055
  if kind == "link_removed":
999
1056
  link = c.get("link") or {}
1000
1057
  return f"removed {link.get('target_name') or link.get('target_id')} from {link.get('record_name')}"
1058
+ if kind == "choice":
1059
+ ch = c.get("choice") or {}
1060
+ return (
1061
+ f"chose {ch.get('chosen_value')!r} over {ch.get('prior_value')!r} for "
1062
+ f"{ch.get('record_name')}.{ch.get('column')}"
1063
+ )
1001
1064
  return str(kind)
1002
1065
 
1003
1066
 
@@ -1172,6 +1235,90 @@ def cmd_holds_keep(args) -> int:
1172
1235
  def cmd_holds_release(args) -> int:
1173
1236
  return _hold_verb(args, "release")
1174
1237
 
1238
+
1239
+ def cmd_holds_conflicts(args) -> int:
1240
+ """Cells waiting for you to choose among the values one run stated (gh #2370)."""
1241
+ client, _ = _require_auth(args)
1242
+ params: dict[str, Any] = {"limit": args.limit}
1243
+ for flag, key in (("entity", "entity"), ("cursor", "cursor"), ("blueprint", "blueprint_id")):
1244
+ if getattr(args, flag, None):
1245
+ params[key] = getattr(args, flag)
1246
+ try:
1247
+ body = client.request("GET", "/v1/conflicts", params=params)
1248
+ except ApiError as exc:
1249
+ err(str(exc))
1250
+ return EXIT_FAIL
1251
+ if args.json:
1252
+ out(body, True)
1253
+ return EXIT_OK
1254
+ bp = body.get("blueprint") or {}
1255
+ if bp:
1256
+ print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
1257
+ rows = []
1258
+ for c in body.get("data", []):
1259
+ where = f"{c.get('record_name') or c.get('record_id')}.{c.get('column')}"[:40]
1260
+ # The value shown first, then each value it can be chosen from, with the evidence id
1261
+ # `holds choose` takes and `--expected` the shown value's.
1262
+ rows.append([c.get("cell_id", ""), where, str(c.get("value"))[:30], "shown", c.get("value_evidence_id") or "-"])
1263
+ for cand in c.get("candidates") or []:
1264
+ tag = cand.get("verdict") or ""
1265
+ if cand.get("refused_by"):
1266
+ tag += " · kept out by your knowledge"
1267
+ rows.append(["", "", str(cand.get("value"))[:30], tag[:40], cand.get("evidence_id") or "-"])
1268
+ print(
1269
+ _table(rows, ["cell", "cell of", "value", "verdict", "evidence"])
1270
+ if rows
1271
+ else "No cells waiting for you to choose."
1272
+ )
1273
+ if body.get("truncated"):
1274
+ print(
1275
+ f"\nnote: a PREVIEW, not every cell (preview_row_cap={body.get('preview_row_cap')}).",
1276
+ file=sys.stderr,
1277
+ )
1278
+ if body.get("has_more"):
1279
+ print(f"\nMore: --cursor {body.get('next_cursor')}")
1280
+ return EXIT_OK
1281
+
1282
+
1283
+ def cmd_holds_choose(args) -> int:
1284
+ """Make one of a cell's values the one it shows (gh #2370). It changes the dataset, so it is
1285
+ confirmed like the other hold verbs: interactively, or with --yes, never silently in a pipe."""
1286
+ client, _ = _require_auth(args)
1287
+ if not _CELL_ID.fullmatch(args.cell_id or ""):
1288
+ err("cell id must be one `deepsieve holds conflicts` printed (it starts with cel_)")
1289
+ return EXIT_USAGE
1290
+ body: dict[str, Any] = {}
1291
+ for name, raw in (("evidence_id", args.evidence_id), ("expected_value_evidence_id", args.expected)):
1292
+ if raw is None:
1293
+ continue
1294
+ try:
1295
+ body[name] = str(uuid.UUID(raw))
1296
+ except ValueError:
1297
+ err(f"{'evidence id' if name == 'evidence_id' else '--expected'} must be an evidence id (a UUID)")
1298
+ return EXIT_USAGE
1299
+ if not args.yes:
1300
+ if not _interactive():
1301
+ err("refusing to change data non-interactively; pass --yes")
1302
+ return EXIT_USAGE
1303
+ if input("Show this value in the cell, and protect it from later research? [y/N] ").strip().lower() not in ("y", "yes"):
1304
+ print("Cancelled.")
1305
+ return EXIT_OK
1306
+ headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
1307
+ try:
1308
+ result = client.request(
1309
+ "POST", f"/v1/cells/{args.cell_id}/choose", json_body=body, headers=headers
1310
+ )
1311
+ except ApiError as exc:
1312
+ err(str(exc))
1313
+ return EXIT_FAIL
1314
+ out(
1315
+ result,
1316
+ args.json,
1317
+ f"chosen; the cell stays protected: {result.get('column')} = {result.get('value')!r}"
1318
+ f" (undo: deepsieve changes undo {result.get('change_id')})",
1319
+ )
1320
+ return EXIT_OK
1321
+
1175
1322
  def _merge_body(args) -> dict[str, Any] | None:
1176
1323
  choices: dict[str, str] = {}
1177
1324
  for c in args.choice or []:
@@ -1462,6 +1609,19 @@ def cmd_data_get(args) -> int:
1462
1609
  err(str(exc))
1463
1610
  return EXIT_FAIL
1464
1611
  out(body, True) # rows are structured either way; --json only stills the notes
1612
+ conflicting = sum(
1613
+ 1
1614
+ for row in body.get("data") or []
1615
+ for cite in (row.get("citations") or {}).values()
1616
+ if ((cite or {}).get("selection") or {}).get("state") == "conflicting"
1617
+ )
1618
+ if conflicting and not args.json:
1619
+ # gh #2370: a cell stated two ways shows the better-sourced value until someone chooses.
1620
+ print(
1621
+ f"\nnote: {conflicting} cell(s) here were stated more than one way and wait for you "
1622
+ "to choose: `deepsieve holds conflicts`.",
1623
+ file=sys.stderr,
1624
+ )
1465
1625
  if body.get("truncated") and not args.json:
1466
1626
  print(
1467
1627
  "\nnote: this response is a PREVIEW, not the full dataset "
@@ -1837,6 +1997,16 @@ def build_parser() -> argparse.ArgumentParser:
1837
1997
  rc.add_argument("--excluded-source", action="append", metavar="PATTERN",
1838
1998
  help="never cite this source (repeatable): a host, *.tld or "
1839
1999
  "https://host/path/*; a citation on it is not written")
2000
+ rc.add_argument("--phrase", action="append", metavar="TEXT",
2001
+ help="also search for pages with this exact wording (repeatable; never a "
2002
+ "filter). With --file-type, replaces EVERY exact phrase and file type "
2003
+ "read from --query")
2004
+ rc.add_argument("--file-type", action="append", metavar="EXT",
2005
+ help="also look for this kind of document, e.g. pdf or xlsx (repeatable; "
2006
+ "never a filter). With --phrase, replaces EVERY exact phrase and file "
2007
+ "type read from --query")
2008
+ rc.add_argument("--no-search-hints", action="store_true",
2009
+ help="drop the exact phrases and file types read from --query")
1840
2010
  rc.add_argument("--blueprint", help="Blueprint id to research; REQUIRED unless --dry-run "
1841
2011
  "(`deepsieve whoami` shows your active Blueprint's id)")
1842
2012
  rc.add_argument("--depth", default="standard", choices=["standard", "max"])
@@ -1882,10 +2052,17 @@ def build_parser() -> argparse.ArgumentParser:
1882
2052
  )
1883
2053
  rr.add_argument(
1884
2054
  "--expected-funding",
1885
- choices=["free_run", "plan_credits", "balance"],
2055
+ choices=["free_run", "plan_credits", "balance", "mixed"],
1886
2056
  default=None,
1887
2057
  help="also bind what pays, as quoted (funding.covered_by); needs --expected-total",
1888
2058
  )
2059
+ rr.add_argument(
2060
+ "--expected-prepaid",
2061
+ type=int,
2062
+ default=None,
2063
+ metavar="CENTS",
2064
+ help="with --expected-funding mixed: the balance's part, as quoted (funding.prepaid_cents)",
2065
+ )
1889
2066
  rr.set_defaults(func=cmd_runs_retry)
1890
2067
 
1891
2068
  rm = runs.add_parser(
@@ -1958,10 +2135,10 @@ def build_parser() -> argparse.ArgumentParser:
1958
2135
  ml = members.add_parser("list", help="who is in the organization, and seat usage", parents=[common])
1959
2136
  ml.set_defaults(func=cmd_members_list)
1960
2137
 
1961
- changes = sub.add_parser("changes", help="merges, renames, successor links and removed links", parents=[common]).add_subparsers(dest="sub", required=True)
2138
+ changes = sub.add_parser("changes", help="merges, renames, successor links, removed links and value choices", parents=[common]).add_subparsers(dest="sub", required=True)
1962
2139
  cl = changes.add_parser("list", help="newest first; undone ones included unless --live", parents=[common])
1963
2140
  cl.add_argument("--entity", help="only this entity's records (an entity key)")
1964
- cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by", "link_removed"])
2141
+ cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by", "link_removed", "choice"])
1965
2142
  cl.add_argument("--by", choices=["user", "automatic", "operator"], help="who made the change")
1966
2143
  cl.add_argument("--live", action="store_true", help="leave out undone changes")
1967
2144
  cl.add_argument("--limit", type=int, default=25)
@@ -2022,6 +2199,19 @@ def build_parser() -> argparse.ArgumentParser:
2022
2199
  hr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
2023
2200
  hr.add_argument("--idempotency-key", help="make a retry safe")
2024
2201
  hr.set_defaults(func=cmd_holds_release)
2202
+ hc = holds.add_parser("conflicts", help="cells waiting for you to choose among one run's values", parents=[common])
2203
+ hc.add_argument("--entity", help="only this entity's cells (an entity key)")
2204
+ hc.add_argument("--limit", type=int, default=25)
2205
+ hc.add_argument("--cursor")
2206
+ hc.add_argument("--blueprint", help="Blueprint id (default: the active one)")
2207
+ hc.set_defaults(func=cmd_holds_conflicts)
2208
+ hch = holds.add_parser("choose", help="show one of a cell's values (needs data:write)", parents=[common])
2209
+ hch.add_argument("cell_id")
2210
+ hch.add_argument("evidence_id", help="the `evidence` id `holds conflicts` printed for the value to show")
2211
+ hch.add_argument("--expected", help="the `evidence` id of the value the cell showed when you looked (a change since is refused)")
2212
+ hch.add_argument("--yes", action="store_true", help="do not ask for confirmation")
2213
+ hch.add_argument("--idempotency-key", help="make a retry safe")
2214
+ hch.set_defaults(func=cmd_holds_choose)
2025
2215
 
2026
2216
  merge = sub.add_parser("merge", help="mark rows as duplicates of one another", parents=[common]).add_subparsers(dest="sub", required=True)
2027
2217
  for name, helptext, func in (
@@ -0,0 +1,48 @@
1
+ {
2
+ "data": [
3
+ {
4
+ "object": "conflict",
5
+ "cell_id": "cel_YmxhaGJsYWhibGFoYmxhaA",
6
+ "entity": "books",
7
+ "record_id": "00000000-0000-0000-0000-000000000001",
8
+ "record_name": "Emma",
9
+ "column": "author",
10
+ "value": "Jane Austen",
11
+ "value_evidence_id": "6f1c2d3e-0000-4000-8000-00000000a001",
12
+ "selection": {
13
+ "state": "conflicting",
14
+ "reason": "the quality check supported 2 different values; the better-sourced one is shown until someone chooses"
15
+ },
16
+ "candidates": [
17
+ {
18
+ "kind": "also_stated",
19
+ "evidence_id": "6f1c2d3e-0000-4000-8000-00000000a002",
20
+ "verdict": "supported",
21
+ "source_hosts": [
22
+ "trade.test"
23
+ ],
24
+ "captured_at": "2026-10-19T10:00:00+00:00",
25
+ "reason": null,
26
+ "choosable": true,
27
+ "refused_by": [
28
+ "k_cautionb"
29
+ ],
30
+ "knowledge_decided": [],
31
+ "knowledge_cited": [],
32
+ "value": "J. Austen",
33
+ "note": "[ALSO STATED] this run also stated this value; the cell shows another"
34
+ }
35
+ ],
36
+ "blueprint": {
37
+ "id": "bp_1",
38
+ "name": "Library"
39
+ }
40
+ }
41
+ ],
42
+ "has_more": false,
43
+ "next_cursor": null,
44
+ "blueprint": {
45
+ "id": "bp_1",
46
+ "name": "Library"
47
+ }
48
+ }
@@ -775,6 +775,35 @@ def test_run_create_forwards_each_excluded_source(monkeypatch):
775
775
  assert "excluded_sources" not in seen["body"]
776
776
 
777
777
 
778
+ def test_run_create_sends_search_hints_and_can_drop_them(monkeypatch, capsys):
779
+ """gh #2657 — `--phrase` / `--file-type` send `search_hints` (replacing the parsed ones);
780
+ `--no-search-hints` sends `{}`, which drops every parsed hint; neither sends nothing, which
781
+ keeps the parsed ones. Both kinds of flag at once is a usage error, never a guess."""
782
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
783
+ seen = {}
784
+
785
+ def handler(request: httpx.Request) -> httpx.Response:
786
+ seen["body"] = json.loads(request.content)
787
+ return httpx.Response(202, json={"id": "run_1", "status": "queued"})
788
+
789
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
790
+ q = ["runs", "create", "--query", "Acme, PDFs only", "--dry-run"]
791
+ assert main([*q, "--phrase", " Request for Information ", "--file-type", "pdf"]) == 0
792
+ assert seen["body"]["search_hints"] == {
793
+ "exact_phrases": ["Request for Information"],
794
+ "file_types": ["pdf"],
795
+ }
796
+ assert main([*q, "--file-type", "xlsx"]) == 0
797
+ assert seen["body"]["search_hints"] == {"file_types": ["xlsx"]}
798
+ assert main([*q, "--no-search-hints"]) == 0
799
+ assert seen["body"]["search_hints"] == {}
800
+ assert main(q) == 0
801
+ assert "search_hints" not in seen["body"]
802
+ seen.clear()
803
+ assert main([*q, "--no-search-hints", "--phrase", "x y"]) == 2
804
+ assert seen == {}, "a usage error sends nothing"
805
+
806
+
778
807
  def test_run_create_names_the_blueprint_it_sends_and_the_one_it_got(monkeypatch, capsys):
779
808
  """gh #1282: `--blueprint` reaches the body, and the Blueprint the run is on is
780
809
  printed, so a run on the wrong one is visible at once."""
@@ -1226,6 +1255,68 @@ def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch
1226
1255
  assert calls == []
1227
1256
 
1228
1257
 
1258
+ # ---------------------------------------------------------------------------
1259
+ # gh #2370 B2: holds conflicts | choose
1260
+ # ---------------------------------------------------------------------------
1261
+
1262
+ #: The shape `GET /v1/conflicts` serves; `api/tests/test_v1_choose.py` asserts the API's keys
1263
+ #: equal this file's, so the CLI is never tested against a shape the API stopped sending.
1264
+ _CONFLICTS = json.loads((Path(__file__).parent / "fixtures" / "conflict_page.json").read_text())
1265
+
1266
+
1267
+ def test_holds_conflicts_lists_the_value_shown_and_each_value_to_choose(monkeypatch, capsys):
1268
+ _signed_in(monkeypatch)
1269
+ seen = {}
1270
+
1271
+ def handler(request: httpx.Request) -> httpx.Response:
1272
+ seen["path"], seen["params"] = request.url.path, dict(request.url.params)
1273
+ return httpx.Response(200, json=_CONFLICTS)
1274
+
1275
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1276
+ assert main(["holds", "conflicts", "--entity", "books"]) == 0
1277
+ assert seen["path"] == "/v1/conflicts" and seen["params"]["entity"] == "books"
1278
+ text = capsys.readouterr().out
1279
+ item = _CONFLICTS["data"][0]
1280
+ # Both values, and both evidence ids, so `holds choose` can name one and `--expected` the other.
1281
+ assert "Jane Austen" in text and "J. Austen" in text
1282
+ assert item["value_evidence_id"] in text and item["candidates"][0]["evidence_id"] in text
1283
+ assert "kept out by your knowledge" in text, "a refused value says so"
1284
+
1285
+
1286
+ def test_holds_choose_posts_the_value_and_the_value_seen(monkeypatch, capsys):
1287
+ _signed_in(monkeypatch)
1288
+ seen = {}
1289
+ item = _CONFLICTS["data"][0]
1290
+
1291
+ def handler(request: httpx.Request) -> httpx.Response:
1292
+ seen["path"], seen["key"] = request.url.path, request.headers.get("idempotency-key")
1293
+ seen["body"] = json.loads(request.content or b"{}")
1294
+ return httpx.Response(200, json={"object": "cell", "column": "author", "value": "J. Austen",
1295
+ "held": True, "change_id": CID})
1296
+
1297
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1298
+ cand = item["candidates"][0]["evidence_id"]
1299
+ rc = main(["holds", "choose", CELL, cand, "--expected", item["value_evidence_id"], "--yes",
1300
+ "--idempotency-key", "k-4"])
1301
+ assert rc == 0
1302
+ assert (seen["path"], seen["key"]) == (f"/v1/cells/{CELL}/choose", "k-4")
1303
+ assert seen["body"] == {"evidence_id": cand, "expected_value_evidence_id": item["value_evidence_id"]}
1304
+ out_ = capsys.readouterr().out
1305
+ assert "stays protected" in out_ and f"changes undo {CID}" in out_
1306
+
1307
+
1308
+ def test_holds_choose_asks_in_a_pipe_and_refuses_a_malformed_id(monkeypatch, capsys):
1309
+ _signed_in(monkeypatch)
1310
+ calls = []
1311
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
1312
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
1313
+ assert main(["holds", "choose", CELL, CID]) == 2
1314
+ assert "pass --yes" in capsys.readouterr().err
1315
+ assert main(["holds", "choose", CELL, "not-a-uuid", "--yes"]) == 2
1316
+ assert main(["holds", "choose", "../x#", CID, "--yes"]) == 2
1317
+ assert calls == [], "nothing sent"
1318
+
1319
+
1229
1320
  def test_a_billable_run_without_a_blueprint_is_a_usage_error_before_any_request(monkeypatch, capsys):
1230
1321
  """gh #1282 B1: the API now requires `blueprint_id` on run create; the CLI says so first, with
1231
1322
  where the id comes from, and sends nothing. A dry run may omit it (the API's own exception).
@@ -1686,10 +1777,18 @@ def test_a_quote_without_a_total_is_never_asked_as_zero(monkeypatch, capsys):
1686
1777
  "The credits for your failed run were returned to the plan they came from."),
1687
1778
  ({"kind": "partially_refunded_to_plan", "run_status": "failed"},
1688
1779
  "Part of your failed run returned nothing. The credits for that part were returned"),
1780
+ # gh #3045: a refund to a closed account sends nothing: never "refunded".
1781
+ ({"kind": "not_refunded_account_closed", "run_status": "failed"},
1782
+ "Nothing was returned for your failed run, because your account is closed"),
1783
+ # gh #1858: a finished mixed run kept both halves.
1784
+ ({"kind": "kept_mixed", "credits": 2, "amount_cents": 1000, "run_status": "completed"},
1785
+ "Your earlier run used 2 run credits and $10.00 of your prepaid balance. It finished"),
1689
1786
  ({"kind": "unconfirmed"}, "We can't confirm yet whether your earlier run was refunded."),
1690
1787
  ],
1691
1788
  ids=["kept-credits", "pending", "ended-plan", "ended-period", "partial-ended-plan",
1692
- "partial-ended-period", "returned-to-plan", "partial-returned-to-plan", "unconfirmed"],
1789
+ "partial-ended-period", "returned-to-plan", "partial-returned-to-plan", "closed-account",
1790
+ "kept-mixed",
1791
+ "unconfirmed"],
1693
1792
  )
1694
1793
  def test_every_refund_kind_reads_in_words_never_its_code(monkeypatch, capsys, refund, words):
1695
1794
  """seat-2389 L3: the app's sentences (site `refundHeadline`), never a raw enum name."""
@@ -2431,6 +2530,96 @@ def test_expected_funding_is_sent_beside_the_total(monkeypatch, capsys):
2431
2530
  assert seen == [{"expected_total_cents": 1200, "expected_funding": "plan_credits"}]
2432
2531
 
2433
2532
 
2533
+ # gh #1858: a mixed quote's yes binds its split (`funding.prepaid_cents`) as well as its rail.
2534
+
2535
+ _MIXED_QUOTE = {
2536
+ **_RETRY_QUOTE,
2537
+ "total_price_cents": 3000,
2538
+ "funding": {"covered_by": "mixed", "plan_credits_needed": 3, "plan_credits_available": 2,
2539
+ "balance_cents": 5000, "prepaid_cents": 1000},
2540
+ }
2541
+
2542
+
2543
+ def _mixed_handler(seen: list):
2544
+ def handler(request: httpx.Request) -> httpx.Response:
2545
+ body = json.loads(request.content) if request.content else {}
2546
+ seen.append(body)
2547
+ if body.get("expected_prepaid_cents") != 1000 or body.get("expected_funding") != "mixed":
2548
+ return httpx.Response(409, json={"error": {
2549
+ "code": "retry_confirmation_required", "message": "confirm",
2550
+ "param": "expected_total_cents", "retriable": False, "details": _MIXED_QUOTE,
2551
+ }})
2552
+ return httpx.Response(202, json={
2553
+ "object": "run", "id": "new-run", "status": "queued",
2554
+ "retry": {"source_run_id": RUN, "mode": "restarted"},
2555
+ })
2556
+
2557
+ return handler
2558
+
2559
+
2560
+ def test_interactively_the_yes_binds_a_mixed_split(monkeypatch, capsys):
2561
+ _signed_in(monkeypatch)
2562
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
2563
+ prompts: list = []
2564
+ monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
2565
+ seen: list = []
2566
+ monkeypatch.setattr(httpx, "Client", _mock(_mixed_handler(seen)))
2567
+ assert main(["runs", "retry", RUN]) == 0
2568
+ assert seen == [
2569
+ {},
2570
+ {"expected_total_cents": 3000, "expected_funding": "mixed", "expected_prepaid_cents": 1000},
2571
+ ]
2572
+ assert "uses your plan run credits and $10.00 from your balance" in prompts[0]
2573
+
2574
+
2575
+ def test_in_a_pipe_the_hint_names_the_prepaid_flag_for_a_mixed_quote(monkeypatch, capsys):
2576
+ _signed_in(monkeypatch)
2577
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
2578
+ seen: list = []
2579
+ monkeypatch.setattr(httpx, "Client", _mock(_mixed_handler(seen)))
2580
+ assert main(["runs", "retry", RUN]) == 2
2581
+ err = capsys.readouterr().err
2582
+ assert "--expected-total 3000 --expected-funding mixed --expected-prepaid 1000" in err
2583
+
2584
+
2585
+ @pytest.mark.parametrize("interactive", [True, False], ids=["prompt", "pipe"])
2586
+ def test_a_mixed_quote_without_its_prepaid_part_is_never_confirmed(monkeypatch, capsys, interactive):
2587
+ """gh #1858 review: a quote naming `mixed` with no `prepaid_cents` printed
2588
+ `--expected-prepaid None` in a pipe, and at a prompt sent a body the API refuses. It is
2589
+ refused here, nothing is sent after the first request, and the message says why."""
2590
+ _signed_in(monkeypatch)
2591
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: interactive)
2592
+ monkeypatch.setattr("builtins.input", lambda prompt="": "y")
2593
+ broken = {**_MIXED_QUOTE, "funding": {**_MIXED_QUOTE["funding"], "prepaid_cents": None}}
2594
+ seen: list = []
2595
+
2596
+ def handler(request: httpx.Request) -> httpx.Response:
2597
+ seen.append(json.loads(request.content) if request.content else {})
2598
+ return httpx.Response(409, json={"error": {
2599
+ "code": "retry_confirmation_required", "message": "confirm",
2600
+ "param": "expected_total_cents", "retriable": False, "details": broken,
2601
+ }})
2602
+
2603
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
2604
+ assert main(["runs", "retry", RUN]) == 1
2605
+ err = capsys.readouterr().err
2606
+ assert "without its prepaid part" in err
2607
+ assert "None" not in err
2608
+ assert seen == [{}]
2609
+
2610
+
2611
+ def test_a_mixed_rail_and_its_prepaid_part_go_together(monkeypatch, capsys):
2612
+ _signed_in(monkeypatch)
2613
+ seen: list = []
2614
+ monkeypatch.setattr(httpx, "Client", _mock(_mixed_handler(seen)))
2615
+ alone = ["runs", "retry", RUN, "--expected-total", "3000", "--expected-funding", "mixed"]
2616
+ assert main(alone) == 2
2617
+ stray = ["runs", "retry", RUN, "--expected-total", "3000", "--expected-funding", "balance",
2618
+ "--expected-prepaid", "1000"]
2619
+ assert main(stray) == 2
2620
+ assert seen == []
2621
+
2622
+
2434
2623
  def test_expected_funding_without_its_total_sends_nothing(monkeypatch, capsys):
2435
2624
  _signed_in(monkeypatch)
2436
2625
  seen: list = []
File without changes