deepsieve-cli 0.6.0__tar.gz → 0.8.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.6.0
3
+ Version: 0.8.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
@@ -44,8 +44,11 @@ deepsieve runs retry <id> # a retry that charges shows its t
44
44
  deepsieve runs reruns <id> # a report's re-research waiting for approval
45
45
  deepsieve runs monitoring <id> --off # stop a report's monitoring; asks first
46
46
  deepsieve links remove <entity> <record-id> <linked-entity> <target-id> # asks first
47
+ deepsieve subscription cancel # at period end; nothing new is charged; asks first
48
+ deepsieve subscription resume # undo it while the period runs; asks first
47
49
  deepsieve data catalog # entities + columns (never guess)
48
50
  deepsieve data get companies --receipts # rows with per-cell citations
51
+ deepsieve data history companies headquarters_city --record <id> # one cell's change history
49
52
  deepsieve chat thread <run-id> # changes, with status + warnings
50
53
  deepsieve chat confirm <action-id> # apply a change that waits for confirmation
51
54
  deepsieve chat dismiss <action-id> # decline it; nothing is written
@@ -36,8 +36,11 @@ deepsieve runs retry <id> # a retry that charges shows its t
36
36
  deepsieve runs reruns <id> # a report's re-research waiting for approval
37
37
  deepsieve runs monitoring <id> --off # stop a report's monitoring; asks first
38
38
  deepsieve links remove <entity> <record-id> <linked-entity> <target-id> # asks first
39
+ deepsieve subscription cancel # at period end; nothing new is charged; asks first
40
+ deepsieve subscription resume # undo it while the period runs; asks first
39
41
  deepsieve data catalog # entities + columns (never guess)
40
42
  deepsieve data get companies --receipts # rows with per-cell citations
43
+ deepsieve data history companies headquarters_city --record <id> # one cell's change history
41
44
  deepsieve chat thread <run-id> # changes, with status + warnings
42
45
  deepsieve chat confirm <action-id> # apply a change that waits for confirmation
43
46
  deepsieve chat dismiss <action-id> # decline it; nothing is written
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepsieve-cli"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "DeepSieve CLI — run cited deep research from your terminal"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -198,9 +198,51 @@ def cmd_whoami(args) -> int:
198
198
  print(f"workspace {me.get('workspace_schema')}")
199
199
  print(f"role {me.get('role')}")
200
200
  print(f"scopes {', '.join(me.get('scopes') or []) or '(full)'}")
201
+ trial_line = _trial_line(me.get("subscription"), me.get("app_url"))
202
+ if trial_line:
203
+ print(f"trial {trial_line}")
201
204
  return EXIT_OK
202
205
 
203
206
 
207
+ def _trial_line(subscription: dict | None, app_url: str | None) -> str | None:
208
+ """The free trial in one line (gh #2896), from the dates the server derived: never
209
+ counted here, so the CLI and the billing page cannot disagree about the day. A cancel is
210
+ read from `cancel_at_period_end`, never inferred from a missing amount."""
211
+ sub = subscription or {}
212
+ trial = sub.get("trial") or {}
213
+ cancelled = bool(sub.get("cancel_at_period_end"))
214
+
215
+ def charge() -> str:
216
+ cents = trial.get("first_charge_cents")
217
+ amount = f"{_usd(cents)} " if cents is not None else ""
218
+ return f"first charge {amount}on {trial.get('first_charge_on')}"
219
+
220
+ if trial.get("conversion_failed"):
221
+ if cancelled:
222
+ return "ended; the first charge was declined and the plan is cancelled, so it will not be retried."
223
+ return "ended, and the first charge was declined. Update the card in the app."
224
+ if trial.get("ended_without_charge"):
225
+ return f"cancelled and ended on {trial.get('ends_on')}; nothing was charged."
226
+ if trial.get("conversion_submitted"):
227
+ return f"ended; {charge()} is being processed and cannot be stopped."
228
+ if trial.get("conversion_pending"):
229
+ return f"ended; {charge()} is due."
230
+ if trial.get("active"):
231
+ left = trial.get("days_left")
232
+ days = f"{left} day{'' if left == 1 else 's'} left" if left is not None else "active"
233
+ if cancelled:
234
+ return f"{days}; cancelled, so it will not be charged (access until {trial.get('ends_on')})"
235
+ if trial.get("first_charge_on"):
236
+ return f"{days}; {charge()}"
237
+ return days
238
+ if trial.get("eligible") is True:
239
+ length = trial.get("offer_days")
240
+ what = f"a {length}-day trial" if length else "a trial"
241
+ where = f"{app_url}/pricing" if app_url else "the pricing page"
242
+ return f"{what} is available. Start one from {where} (it takes a card, so it needs a person)."
243
+ return None
244
+
245
+
204
246
  def cmd_profiles(args) -> int:
205
247
  profiles = config.list_profiles()
206
248
  active = config.profile_name(args.profile)
@@ -262,11 +304,28 @@ def cmd_runs_get(args) -> int:
262
304
  print(f"blueprint {bp.get('name')} ({bp.get('id')})")
263
305
  if body.get("error"):
264
306
  print(f"error {body['error'].get('message')}")
307
+ for line in _recheck_lines(body):
308
+ print(f"result {line}")
265
309
  if not body.get("done"):
266
310
  return EXIT_PENDING
267
311
  return EXIT_OK if body.get("status") == "completed" else EXIT_FAIL
268
312
 
269
313
 
314
+ def _recheck_lines(body: dict) -> list[str]:
315
+ """gh #2781 — what a re-check run found (`check_results`, one line per checked value) or what
316
+ a follow-up search found (`update_result`), in the sentences the API wrote for a person."""
317
+ lines = []
318
+ for entry in body.get("check_results") or []:
319
+ line = str(entry.get("message") or "")
320
+ if entry.get("followup_run_id"):
321
+ line += f" (follow-up run {entry['followup_run_id']})"
322
+ lines.append(line)
323
+ update = body.get("update_result") or {}
324
+ if update.get("message"):
325
+ lines.append(str(update["message"]))
326
+ return lines
327
+
328
+
270
329
  def _folded_text(folds: list[dict]) -> str:
271
330
  """gh #2716 — the seeds a request counted once, in one line (the first 4, then a count)."""
272
331
  if not folds:
@@ -387,6 +446,8 @@ def _wait(client: Client, run_id: str, args) -> int:
387
446
  print(f"run {run_id} finished: {body.get('status')}")
388
447
  if body.get("error"):
389
448
  print(f" {body['error'].get('message')}")
449
+ for line in _recheck_lines(body):
450
+ print(f" {line}")
390
451
  return EXIT_OK if body.get("status") == "completed" else EXIT_FAIL
391
452
  if not args.json and _tty():
392
453
  print(f" status: {body.get('status')}…", end="\r", flush=True)
@@ -683,6 +744,19 @@ def _refund_line(refund: dict | None) -> str:
683
744
  # gh #2460: the plan is still running; an earlier billing period's allowance ended.
684
745
  return (f"The credits for your {run} went back to the billing period they came from, "
685
746
  "which has ended, so they can't be used again.")
747
+ if kind == "partially_refunded_to_ended_plan":
748
+ # gh #2462: only the part that returned nothing gave its credits back.
749
+ return (f"Part of your {run} returned nothing. Your plan has ended, so the credits for "
750
+ "that part were returned to it and can't be used.")
751
+ if kind == "partially_refunded_to_ended_period":
752
+ return (f"Part of your {run} returned nothing. The credits for that part went back to the "
753
+ "billing period they came from, which has ended, so they can't be used again.")
754
+ if kind == "refunded_to_plan":
755
+ # gh #2302: whether the credits can be spent cannot be told: never "back", never "ended".
756
+ return f"The credits for your {run} were returned to the plan they came from."
757
+ if kind == "partially_refunded_to_plan":
758
+ return (f"Part of your {run} returned nothing. The credits for that part were returned to "
759
+ "the plan they came from.")
686
760
  if kind == "refunded_unknown":
687
761
  return f"Your {run} was refunded in full."
688
762
  if kind == "refund_pending":
@@ -713,9 +787,24 @@ def _retry_cost(details: dict) -> str:
713
787
  return cost
714
788
 
715
789
 
716
- def _retry_quote_text(details: dict, moved: bool) -> str:
790
+ def _retry_quote_text(details: dict, moved: bool, sent: dict | None = None) -> str:
791
+ # gh #2456 (review, seat-contract 2a): say WHAT moved. `sent` is the confirmation just
792
+ # refused; a rail that moved at the same total is not a price change.
793
+ sent = sent or {}
794
+ rail_moved = bool(sent.get("expected_funding")) and sent.get("expected_funding") != (
795
+ details.get("funding") or {}
796
+ ).get("covered_by")
797
+ total_moved = sent.get("expected_total_cents") != details.get("total_price_cents")
798
+ if not moved:
799
+ what = ""
800
+ elif rail_moved and not total_moved:
801
+ what = "What would pay for this retry changed since the quote; the total did not."
802
+ elif rail_moved:
803
+ what = "The price, and what would pay for this retry, changed since the quote."
804
+ else:
805
+ what = "The price changed since the quote."
717
806
  parts = [
718
- "The price changed since the quote." if moved else "",
807
+ what,
719
808
  _refund_line(details.get("refund")),
720
809
  f"Retrying starts a new run and {_retry_cost(details)}.",
721
810
  ]
@@ -737,15 +826,25 @@ def cmd_runs_retry(args) -> int:
737
826
  (`retry_confirmation_required`) until it is sent with the total it was quoted: here, an
738
827
  explicit question showing the refund and the total, or --expected-total CENTS. In a pipe it
739
828
  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."""
829
+ with the new total; nothing is charged in between.
830
+
831
+ gh #2456: the yes is bound to what PAYS as well as the total. The interactive confirmation
832
+ sends the quote's `funding.covered_by` as `expected_funding`, so a retry agreed as "uses 1
833
+ plan run credit" is never paid from the balance instead; --expected-funding does the same
834
+ in a script."""
741
835
  rid = _uuid_arg(args.run_id, "run_id")
742
836
  if rid is None:
743
837
  return EXIT_USAGE
838
+ if args.expected_funding is not None and args.expected_total is None:
839
+ err("--expected-funding binds the rail of a quoted total: pass --expected-total too")
840
+ return EXIT_USAGE
744
841
  client, _ = _require_auth(args)
745
842
  path = f"/v1/research/runs/{rid}/retry"
746
843
  payload: dict = {}
747
844
  if args.expected_total is not None:
748
845
  payload["expected_total_cents"] = args.expected_total
846
+ if args.expected_funding is not None:
847
+ payload["expected_funding"] = args.expected_funding
749
848
  # At most two refusals: the quote, and one re-quote if the price moved while the person was
750
849
  # deciding. A third would be the API contradicting itself; stop rather than loop, and never ask
751
850
  # a question whose yes would not be sent (seat-2389 L1).
@@ -764,11 +863,15 @@ def cmd_runs_retry(args) -> int:
764
863
  # No quote to state: never prompt "$0.00" (seat-2389 L2).
765
864
  err(f"{exc} (the API sent no total to confirm; nothing was charged)")
766
865
  return EXIT_FAIL
767
- ask = _retry_quote_text(exc.details, moved=exc.code == "retry_price_changed")
866
+ ask = _retry_quote_text(
867
+ exc.details, moved=exc.code == "retry_price_changed", sent=payload
868
+ )
869
+ rail = (exc.details.get("funding") or {}).get("covered_by")
870
+ flags = f"--expected-total {total}" + (f" --expected-funding {rail}" if rail else "")
768
871
  if not _interactive():
769
872
  if args.json:
770
873
  print(json.dumps({"error": {"code": exc.code, "message": ask, "details": exc.details}}))
771
- err(f"{ask} Pass --expected-total {total} to confirm that charge.")
874
+ err(f"{ask} Pass {flags} to confirm that charge.")
772
875
  return EXIT_USAGE
773
876
  if attempt == attempts - 1:
774
877
  break # the last refusal: no request would follow, so ask nothing
@@ -776,6 +879,8 @@ def cmd_runs_retry(args) -> int:
776
879
  print("Cancelled. Nothing was charged.")
777
880
  return EXIT_OK
778
881
  payload = {"expected_total_cents": total}
882
+ if rail:
883
+ payload["expected_funding"] = rail # gh #2456: the yes covers what pays too
779
884
  if body is None:
780
885
  err("the API kept refusing the confirmation; nothing was charged")
781
886
  return EXIT_FAIL
@@ -1222,6 +1327,68 @@ def cmd_links_remove(args) -> int:
1222
1327
  return EXIT_OK
1223
1328
 
1224
1329
 
1330
+ def _subscription_summary(sub: dict, app_url: str | None) -> str:
1331
+ """The subscription after a cancel or resume, from the server's own fields (gh #2903)."""
1332
+ lines = [f"status {sub.get('status')}"]
1333
+ if sub.get("cancel_at_period_end"):
1334
+ trial_ = sub.get("trial") or {}
1335
+ tail = (
1336
+ "the first charge already in flight still goes ahead"
1337
+ if trial_.get("conversion_submitted")
1338
+ else "nothing new is charged"
1339
+ )
1340
+ lines.append(f"cancelled access until {sub.get('access_through')}; {tail}")
1341
+ elif sub.get("access_through"):
1342
+ lines.append(f"renews {sub.get('access_through')}")
1343
+ trial_line = _trial_line(sub, app_url)
1344
+ if trial_line:
1345
+ lines.append(f"trial {trial_line}")
1346
+ if sub.get("retry"):
1347
+ lines.append(f"retry {sub['retry']}")
1348
+ return "\n".join(lines)
1349
+
1350
+
1351
+ def _subscription_change(args, verb: str, prompt: str) -> int:
1352
+ client, _ = _require_auth(args)
1353
+ if not args.yes:
1354
+ if not _interactive():
1355
+ err(f"refusing to {verb} the subscription non-interactively; pass --yes")
1356
+ return EXIT_USAGE
1357
+ if input(prompt).strip().lower() not in ("y", "yes"):
1358
+ print("Nothing changed.")
1359
+ return EXIT_OK
1360
+ headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
1361
+ try:
1362
+ sub_ = client.request("POST", f"/v1/subscription/{verb}", headers=headers)
1363
+ except ApiError as exc:
1364
+ err(str(exc))
1365
+ return _api_exit(exc)
1366
+ out(sub_, args.json, _subscription_summary(sub_, None))
1367
+ return EXIT_OK
1368
+
1369
+
1370
+ def cmd_subscription_cancel(args) -> int:
1371
+ """Cancel the subscription at the end of the period (gh #2903). Confirmed interactively,
1372
+ or with --yes, never silently in a pipe. Undone by `deepsieve subscription resume`."""
1373
+ return _subscription_change(
1374
+ args,
1375
+ "cancel",
1376
+ "Cancel the subscription at the end of the period? Nothing new is charged after it (a "
1377
+ "first charge already in flight still goes ahead). [y/N] ",
1378
+ )
1379
+
1380
+
1381
+ def cmd_subscription_resume(args) -> int:
1382
+ """Undo a cancellation while the period runs (gh #2903). A past-due account gets one
1383
+ immediate charge attempt, so this is confirmed like a cancel."""
1384
+ return _subscription_change(
1385
+ args,
1386
+ "resume",
1387
+ "Resume the subscription? Its renewal is charged as before, and a past-due account is "
1388
+ "charged once now. [y/N] ",
1389
+ )
1390
+
1391
+
1225
1392
  def _blueprint_id(client, args) -> str | None:
1226
1393
  """The Blueprint these commands address: ``--blueprint``, else the active one.
1227
1394
 
@@ -1304,6 +1471,148 @@ def cmd_data_get(args) -> int:
1304
1471
  return EXIT_OK
1305
1472
 
1306
1473
 
1474
+ #: Width a value may take in a history row. The tag after it ("QA corrected") is never cut.
1475
+ _HISTORY_VALUE_WIDTH = 30
1476
+
1477
+ #: An entity or column key. Both go into the URL path; anything else is refused here.
1478
+ _KEY = re.compile(r"[A-Za-z0-9_-]{1,128}")
1479
+
1480
+
1481
+ def _hv(value: Any) -> str:
1482
+ text = "(empty)" if value is None else str(value)
1483
+ w = _HISTORY_VALUE_WIDTH
1484
+ return text if len(text) <= w else text[: w - 1] + "…"
1485
+
1486
+
1487
+ def _describe_history(e: dict) -> str:
1488
+ """One change log entry in the source popover's words (`LineageBlock`, #2129).
1489
+
1490
+ A `change` this CLI does not know prints the bare value, as `unknown` does, with no tag: the
1491
+ #2129 changelog entry tells clients to read an unrecognised `change` as `unknown`."""
1492
+ kind, value = e.get("change"), _hv(e.get("value"))
1493
+ if kind == "changed":
1494
+ return f"{_hv(e.get('previous_value'))} → {value}"
1495
+ if kind == "amended":
1496
+ pre = e.get("pre_amend")
1497
+ if pre == "recorded":
1498
+ return f"{_hv(e.get('pre_amend_value'))} → {value} QA corrected"
1499
+ if pre == "not_recorded":
1500
+ return f"{value} QA corrected · earlier value not recorded"
1501
+ # A missing or unknown `pre_amend` reads as `none`: say only what is known.
1502
+ return f"{value} QA corrected"
1503
+ if kind == "unchanged":
1504
+ return "re-verified"
1505
+ if kind == "observed":
1506
+ return f"seen, not applied: {value}"
1507
+ if kind == "not_applied":
1508
+ return f"found, not applied: {value}"
1509
+ return value
1510
+
1511
+
1512
+ def _history_when(captured_at: Any) -> str:
1513
+ """`captured_at` in UTC, to the second, saying so (a bare slice dropped the offset)."""
1514
+ from datetime import datetime, timezone # noqa: PLC0415
1515
+
1516
+ if not captured_at:
1517
+ return "date unknown"
1518
+ try:
1519
+ at = datetime.fromisoformat(str(captured_at))
1520
+ except ValueError:
1521
+ return str(captured_at)
1522
+ if at.tzinfo is None:
1523
+ return str(captured_at)
1524
+ return at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1525
+
1526
+
1527
+ def cmd_data_history(args) -> int:
1528
+ """A cell's change history (#2960): a column's log across records, one record's cell
1529
+ (`--record`), or one cell by its id (`--cell`: a `--json` entry's `cell_id`, or `holds list`)."""
1530
+ by_cell = args.cell is not None
1531
+ if by_cell and (args.entity or args.column):
1532
+ err("give either <entity> <column>, or --cell <cell id>, not both")
1533
+ return EXIT_USAGE
1534
+ if not by_cell and not (args.entity and args.column):
1535
+ err("give <entity> <column> (see `deepsieve data catalog`), or --cell <cell id>")
1536
+ return EXIT_USAGE
1537
+ if by_cell and (args.record is not None or args.blueprint is not None):
1538
+ err("--record and --blueprint go with <entity> <column>; a cell id names its own")
1539
+ return EXIT_USAGE
1540
+ if args.rechecks and not by_cell:
1541
+ err("--rechecks needs --cell (re-checks are listed per cell)")
1542
+ return EXIT_USAGE
1543
+ if by_cell and not _CELL_ID.fullmatch(args.cell):
1544
+ # The id goes into the URL path; anything else is refused before a request.
1545
+ err("cell id must be a `cell_id` from `--json` output or `deepsieve holds list` (cel_…)")
1546
+ return EXIT_USAGE
1547
+ if not by_cell and not (_KEY.fullmatch(args.entity) and _KEY.fullmatch(args.column)):
1548
+ err("entity and column must be keys from `deepsieve data catalog`")
1549
+ return EXIT_USAGE
1550
+ if args.blueprint is not None:
1551
+ # It goes into the URL path too; a mangled one read another endpoint (review, LOW-2).
1552
+ try:
1553
+ args.blueprint = str(uuid.UUID(args.blueprint))
1554
+ except ValueError:
1555
+ err("--blueprint must be a Blueprint id (a UUID)")
1556
+ return EXIT_USAGE
1557
+ params: dict[str, Any] = {"limit": args.limit}
1558
+ if args.record is not None:
1559
+ try:
1560
+ params["record_id"] = str(uuid.UUID(args.record))
1561
+ except ValueError:
1562
+ err("--record must be a record id (a UUID, from `deepsieve data get`)")
1563
+ return EXIT_USAGE
1564
+ for flag in ("cursor", "since"):
1565
+ if getattr(args, flag):
1566
+ params[flag] = getattr(args, flag)
1567
+ if args.rechecks:
1568
+ params["include"] = "rechecks"
1569
+ client, _ = _require_auth(args)
1570
+ if by_cell:
1571
+ path = f"/v1/cells/{args.cell}/changes"
1572
+ else:
1573
+ bp_id = _blueprint_id(client, args)
1574
+ if bp_id is None:
1575
+ return EXIT_FAIL
1576
+ path = f"/v1/blueprints/{bp_id}/entities/{args.entity}/columns/{args.column}/changes"
1577
+ try:
1578
+ body = client.request("GET", path, params=params)
1579
+ except ApiError as exc:
1580
+ err(str(exc))
1581
+ return _api_exit(exc)
1582
+ if not isinstance(body, dict) or not isinstance(body.get("data"), list):
1583
+ # "Found nothing" must never read like "could not look": a 200 that is not a change log
1584
+ # (an HTML page from the wrong origin) is a failure, not an empty history.
1585
+ err(f"unexpected response from {path}: not a change log (is the profile's origin the API?)")
1586
+ return EXIT_FAIL
1587
+ if args.json:
1588
+ out(body, True)
1589
+ return EXIT_OK
1590
+ bp = body.get("blueprint") or {}
1591
+ if bp:
1592
+ print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
1593
+ cell = body.get("cell") or {}
1594
+ if cell:
1595
+ print(f"Cell: {cell.get('record_name') or cell.get('record_id')}.{cell.get('column')}")
1596
+ rows = []
1597
+ for e in body["data"]:
1598
+ row = [_history_when(e.get("captured_at"))]
1599
+ if not by_cell:
1600
+ row.append(str(e.get("record_name") or e.get("record_id") or "")[:30])
1601
+ row += [_describe_history(e), e.get("run_id") or "-"]
1602
+ rows.append(row)
1603
+ headers = ["when (utc)", "change", "run"]
1604
+ if not by_cell:
1605
+ headers.insert(1, "record")
1606
+ print(_table(rows, headers) if rows else "No history found.")
1607
+ if body.get("has_more") and body.get("next_cursor"):
1608
+ print(f"\nMore: --cursor {body.get('next_cursor')}")
1609
+ if body.get("truncated"):
1610
+ # A clipped log must never read as a complete one (the same rule as `data get`).
1611
+ reason = body.get("truncated_reason") or "this is a preview, not the full log."
1612
+ print(f"\nnote: {reason}", file=sys.stderr)
1613
+ return EXIT_OK
1614
+
1615
+
1307
1616
  def _remote_server_name(prof) -> str:
1308
1617
  """The MCP server name this deployment tells agents to register.
1309
1618
 
@@ -1571,6 +1880,12 @@ def build_parser() -> argparse.ArgumentParser:
1571
1880
  metavar="CENTS",
1572
1881
  help="confirm a charging retry at the total you were quoted, in cents",
1573
1882
  )
1883
+ rr.add_argument(
1884
+ "--expected-funding",
1885
+ choices=["free_run", "plan_credits", "balance"],
1886
+ default=None,
1887
+ help="also bind what pays, as quoted (funding.covered_by); needs --expected-total",
1888
+ )
1574
1889
  rr.set_defaults(func=cmd_runs_retry)
1575
1890
 
1576
1891
  rm = runs.add_parser(
@@ -1672,6 +1987,16 @@ def build_parser() -> argparse.ArgumentParser:
1672
1987
  lr.add_argument("--idempotency-key", help="make a retry safe")
1673
1988
  lr.set_defaults(func=cmd_links_remove)
1674
1989
 
1990
+ subs = sub.add_parser("subscription", help="cancel or resume the subscription", parents=[common]).add_subparsers(dest="sub", required=True)
1991
+ sc = subs.add_parser("cancel", help="cancel at the end of the period; nothing new is charged (needs billing:cancel)", parents=[common])
1992
+ sc.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1993
+ sc.add_argument("--idempotency-key", help="make a retry safe")
1994
+ sc.set_defaults(func=cmd_subscription_cancel)
1995
+ sr = subs.add_parser("resume", help="undo a cancellation while the period runs (needs billing:write)", parents=[common])
1996
+ sr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1997
+ sr.add_argument("--idempotency-key", help="make a retry safe")
1998
+ sr.set_defaults(func=cmd_subscription_resume)
1999
+
1675
2000
  holds = sub.add_parser("holds", help="cells you set on purpose, and newer research", parents=[common]).add_subparsers(dest="sub", required=True)
1676
2001
  hl = holds.add_parser("list", help="newest first; --pending for those with newer research", parents=[common])
1677
2002
  hl.add_argument("--entity", help="only this entity's cells (an entity key)")
@@ -1735,6 +2060,23 @@ def build_parser() -> argparse.ArgumentParser:
1735
2060
  dg.add_argument("--blueprint", help="Blueprint id (default: the active one)")
1736
2061
  dg.set_defaults(func=cmd_data_get)
1737
2062
 
2063
+ dh = data.add_parser(
2064
+ "history",
2065
+ help="a cell's change history: <entity> <column> [--record ID], or --cell ID",
2066
+ parents=[common],
2067
+ )
2068
+ dh.add_argument("entity", nargs="?", help="entity key (see `deepsieve data catalog`)")
2069
+ dh.add_argument("column", nargs="?", help="column key")
2070
+ dh.add_argument("--record", help="only this record's cell (a record id)")
2071
+ dh.add_argument("--cell", help="one cell, by its cell_id (in --json output, or from `holds list`)")
2072
+ dh.add_argument("--rechecks", action="store_true",
2073
+ help="with --cell: also re-checks that found a change and did not apply it")
2074
+ dh.add_argument("--since", help="only changes after this ISO timestamp")
2075
+ dh.add_argument("--limit", type=int, default=25)
2076
+ dh.add_argument("--cursor")
2077
+ dh.add_argument("--blueprint", help="Blueprint id (default: the active one)")
2078
+ dh.set_defaults(func=cmd_data_history)
2079
+
1738
2080
  setup = sub.add_parser("setup", help="connect other tools", parents=[common]).add_subparsers(
1739
2081
  dest="sub", required=True
1740
2082
  )
@@ -820,6 +820,61 @@ def test_runs_get_and_whoami_name_the_blueprint(monkeypatch, capsys):
820
820
  assert "blueprint Widgets (bp-a)" in printed and "workspace ws_x" in printed
821
821
 
822
822
 
823
+ # ── a re-check's result (#2781) ─────────────────────────────────────────────
824
+
825
+ _RECHECK = {
826
+ "id": "rc_1", "status": "completed", "done": True, "run_type": "reverify",
827
+ "check_results": [
828
+ {"evidence_id": "e1", "outcome": "confirmed",
829
+ "message": "Checked: the value is still correct.", "followup_run_id": None},
830
+ {"evidence_id": "e2", "outcome": "looking",
831
+ "message": "Checked: the value looks out of date. Looking for the current one.",
832
+ "followup_run_id": "fu_1"},
833
+ ],
834
+ "update_result": None,
835
+ }
836
+ _FOLLOWUP = {
837
+ "id": "fu_1", "status": "completed", "done": True, "run_type": "research_update",
838
+ "check_results": None,
839
+ "update_result": {"outcome": "updated",
840
+ "message": "Found the current value and updated the cell."},
841
+ }
842
+
843
+
844
+ @pytest.mark.parametrize("command", [["runs", "get"], ["runs", "wait", "--interval", "0"]],
845
+ ids=["get", "wait"])
846
+ def test_a_recheck_says_what_it_found_and_names_the_followup(monkeypatch, capsys, command):
847
+ """The human view printed only id/status/error, so a person could not see what a re-check
848
+ found without --json. The follow-up id it prints is the one the next command takes."""
849
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
850
+ runs = {"rc_1": _RECHECK, "fu_1": _FOLLOWUP}
851
+
852
+ def handler(request: httpx.Request) -> httpx.Response:
853
+ return httpx.Response(200, json=runs[request.url.path.rsplit("/", 1)[-1]])
854
+
855
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
856
+ assert main([*command, "rc_1"]) == 0
857
+ out = capsys.readouterr().out
858
+ print(out)
859
+ assert "Checked: the value is still correct." in out
860
+ assert "Looking for the current one. (follow-up run fu_1)" in out
861
+ assert out.count("follow-up run") == 1, "only the entry that started a search names one"
862
+
863
+ assert main([*command, "fu_1"]) == 0
864
+ out = capsys.readouterr().out
865
+ print(out)
866
+ assert "Found the current value and updated the cell." in out
867
+
868
+
869
+ def test_a_run_with_no_recheck_result_prints_no_extra_lines(monkeypatch, capsys):
870
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
871
+ body = {"id": "r", "status": "completed", "done": True,
872
+ "check_results": None, "update_result": None}
873
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(200, json=body)))
874
+ assert main(["runs", "get", "r"]) == 0
875
+ assert "result" not in capsys.readouterr().out
876
+
877
+
823
878
  # ── chat changes (#2187) ────────────────────────────────────────────────────
824
879
 
825
880
 
@@ -1621,9 +1676,20 @@ def test_a_quote_without_a_total_is_never_asked_as_zero(monkeypatch, capsys):
1621
1676
  "Your plan has ended, so the credits for your stopped run were returned to it"),
1622
1677
  ({"kind": "refunded_to_ended_period", "run_status": "failed"},
1623
1678
  "The credits for your failed run went back to the billing period they came from"),
1679
+ # gh #2462: a PARTIAL refund to an ended plan or period is said of the part.
1680
+ ({"kind": "partially_refunded_to_ended_plan", "run_status": "failed"},
1681
+ "Part of your failed run returned nothing. Your plan has ended, so the credits for that part"),
1682
+ ({"kind": "partially_refunded_to_ended_period", "run_status": "failed"},
1683
+ "Part of your failed run returned nothing. The credits for that part went back"),
1684
+ # gh #2302: whether the credits can be spent cannot be told.
1685
+ ({"kind": "refunded_to_plan", "run_status": "failed"},
1686
+ "The credits for your failed run were returned to the plan they came from."),
1687
+ ({"kind": "partially_refunded_to_plan", "run_status": "failed"},
1688
+ "Part of your failed run returned nothing. The credits for that part were returned"),
1624
1689
  ({"kind": "unconfirmed"}, "We can't confirm yet whether your earlier run was refunded."),
1625
1690
  ],
1626
- ids=["kept-credits", "pending", "ended-plan", "ended-period", "unconfirmed"],
1691
+ ids=["kept-credits", "pending", "ended-plan", "ended-period", "partial-ended-plan",
1692
+ "partial-ended-period", "returned-to-plan", "partial-returned-to-plan", "unconfirmed"],
1627
1693
  )
1628
1694
  def test_every_refund_kind_reads_in_words_never_its_code(monkeypatch, capsys, refund, words):
1629
1695
  """seat-2389 L3: the app's sentences (site `refundHeadline`), never a raw enum name."""
@@ -1970,3 +2036,425 @@ def test_run_create_says_which_seeds_were_counted_once(monkeypatch, capsys):
1970
2036
  reply.pop("folded_duplicates")
1971
2037
  assert main(["runs", "create", "--seeds", "Fund X", "--dry-run"]) == 0
1972
2038
  assert "counted once" not in capsys.readouterr().out
2039
+
2040
+
2041
+ # ── the free trial on whoami (gh #2896) ─────────────────────────────────────
2042
+
2043
+
2044
+ @pytest.mark.parametrize(
2045
+ "subscription, line",
2046
+ [
2047
+ ({"cancel_at_period_end": False,
2048
+ "trial": {"active": True, "days_left": 12, "first_charge_on": "2026-11-13",
2049
+ "first_charge_cents": 9900, "ends_on": "2026-11-13"}},
2050
+ "trial 12 days left; first charge $99.00 on 2026-11-13"),
2051
+ ({"cancel_at_period_end": True,
2052
+ "trial": {"active": True, "days_left": 1, "first_charge_on": None,
2053
+ "first_charge_cents": None, "ends_on": "2026-11-13"}},
2054
+ "trial 1 day left; cancelled, so it will not be charged (access until 2026-11-13)"),
2055
+ # Not cancelled, amount unknown (a tier with no self-serve price): never "cancelled".
2056
+ ({"cancel_at_period_end": False,
2057
+ "trial": {"active": True, "days_left": 10, "first_charge_on": "2026-11-13",
2058
+ "first_charge_cents": None, "ends_on": "2026-11-13"}},
2059
+ "trial 10 days left; first charge on 2026-11-13"),
2060
+ ({"trial": {"conversion_pending": True, "first_charge_on": "2026-11-13",
2061
+ "first_charge_cents": 9900}},
2062
+ "trial ended; first charge $99.00 on 2026-11-13 is due."),
2063
+ ({"cancel_at_period_end": True,
2064
+ "trial": {"conversion_submitted": True, "first_charge_on": "2026-11-13",
2065
+ "first_charge_cents": 9900}},
2066
+ "trial ended; first charge $99.00 on 2026-11-13 is being processed and cannot "
2067
+ "be stopped."),
2068
+ ({"trial": {"active": False, "conversion_failed": True}},
2069
+ "trial ended, and the first charge was declined. Update the card in the app."),
2070
+ ({"cancel_at_period_end": True, "trial": {"active": False, "conversion_failed": True}},
2071
+ "trial ended; the first charge was declined and the plan is cancelled, so it "
2072
+ "will not be retried."),
2073
+ ({"cancel_at_period_end": True,
2074
+ "trial": {"active": False, "ended_without_charge": True, "ends_on": "2026-11-13"}},
2075
+ "trial cancelled and ended on 2026-11-13; nothing was charged."),
2076
+ ({"trial": {"active": False, "eligible": True, "offer_days": 30}},
2077
+ "trial a 30-day trial is available. Start one from https://app.x.test/pricing "
2078
+ "(it takes a card, so it needs a person)."),
2079
+ ],
2080
+ ids=["charging", "cancelled", "amount-unknown", "pending", "submitted", "declined",
2081
+ "declined-then-cancelled", "ended-without-charge", "eligible"],
2082
+ )
2083
+ def test_whoami_states_the_trial_from_the_servers_dates(monkeypatch, capsys, subscription, line):
2084
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
2085
+ seen = []
2086
+
2087
+ def handler(request: httpx.Request) -> httpx.Response:
2088
+ seen.append(request.url.path)
2089
+ return httpx.Response(200, json={
2090
+ "org_id": "o", "role": "owner", "app_url": "https://app.x.test",
2091
+ "subscription": {"status": "trialing", **subscription},
2092
+ })
2093
+
2094
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
2095
+ assert main(["whoami"]) == 0
2096
+ assert seen == ["/v1/me"]
2097
+ assert line in capsys.readouterr().out.splitlines()
2098
+
2099
+
2100
+ @pytest.mark.parametrize(
2101
+ "subscription",
2102
+ [None, {"status": "active", "trial": None},
2103
+ {"status": "none", "trial": {"active": False, "eligible": None}}],
2104
+ ids=["unknown", "no-trial-block", "eligibility-unknown"],
2105
+ )
2106
+ def test_whoami_prints_no_trial_line_when_there_is_nothing_to_say(
2107
+ monkeypatch, capsys, subscription
2108
+ ):
2109
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
2110
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(
2111
+ 200, json={"org_id": "o", "role": "owner", "subscription": subscription})))
2112
+ assert main(["whoami"]) == 0
2113
+ out = capsys.readouterr().out
2114
+ assert "role owner" in out, "control: whoami printed its identity"
2115
+ assert "trial" not in out
2116
+
2117
+
2118
+ # ── subscription cancel / resume (gh #2903) ─────────────────────────────────
2119
+
2120
+
2121
+ def test_subscription_cancel_refuses_in_a_pipe_without_yes(monkeypatch, capsys):
2122
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
2123
+ seen = []
2124
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: seen.append(r) or httpx.Response(200)))
2125
+ assert main(["subscription", "cancel"]) == 2
2126
+ assert seen == [], "nothing reached the API"
2127
+ assert "pass --yes" in capsys.readouterr().err
2128
+
2129
+
2130
+ @pytest.mark.parametrize("verb", ["cancel", "resume"])
2131
+ def test_subscription_change_posts_and_prints_what_the_server_says(monkeypatch, capsys, verb):
2132
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
2133
+ seen = []
2134
+
2135
+ def handler(request: httpx.Request) -> httpx.Response:
2136
+ seen.append((request.method, request.url.path, request.headers.get("Idempotency-Key")))
2137
+ return httpx.Response(200, json={
2138
+ "object": "subscription", "status": "trialing",
2139
+ "cancel_at_period_end": verb == "cancel", "access_through": "2026-11-13",
2140
+ "retry": None,
2141
+ "trial": {"active": True, "days_left": 12, "ends_on": "2026-11-13",
2142
+ "first_charge_on": None if verb == "cancel" else "2026-11-13",
2143
+ "first_charge_cents": None if verb == "cancel" else 9900},
2144
+ })
2145
+
2146
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
2147
+ assert main(["subscription", verb, "--yes", "--idempotency-key", "k-1"]) == 0
2148
+ assert seen == [("POST", f"/v1/subscription/{verb}", "k-1")]
2149
+ printed = capsys.readouterr().out.splitlines()
2150
+ if verb == "cancel":
2151
+ assert "cancelled access until 2026-11-13; nothing new is charged" in printed
2152
+ else:
2153
+ assert "trial 12 days left; first charge $99.00 on 2026-11-13" in printed
2154
+
2155
+
2156
+ def test_subscription_resume_after_the_end_exits_non_zero_with_the_servers_words(
2157
+ monkeypatch, capsys
2158
+ ):
2159
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
2160
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json={
2161
+ "error": {"type": "conflict_error", "code": "subscription_ended",
2162
+ "message": "This subscription has ended, so it can't be resumed.",
2163
+ "retriable": False, "request_id": "r"}})))
2164
+ assert main(["subscription", "resume", "--yes"]) != 0
2165
+ assert "can't be resumed" in capsys.readouterr().err
2166
+
2167
+
2168
+ # ── data history: a cell's change log (#2960) ────────────────────────────────
2169
+
2170
+ BP = "0b5e4f1a-1111-4222-8333-444455556666"
2171
+ REC = "9a8b7c6d-0000-4000-8000-000000000001"
2172
+
2173
+
2174
+ def _entry(change, **over):
2175
+ base = {
2176
+ "cell_id": CELL, "entity_key": "books", "record_id": REC, "record_name": "Emma",
2177
+ "column": "author", "evidence_id": "e-1", "value": "J. Austen",
2178
+ "captured_at": "2026-10-15T09:30:00+00:00", "run_id": "run-1", "source_stage": "extract",
2179
+ "source_urls": [], "confidence_label": "high", "qa_notes": None,
2180
+ "previous_evidence_id": None, "previous_value": None, "lineage": "none",
2181
+ "change": change, "pre_amend": "none", "pre_amend_value": None,
2182
+ }
2183
+ return base | over
2184
+
2185
+
2186
+ def _history(*entries, **over):
2187
+ return {"object": "list", "data": list(entries), "has_more": False, "next_cursor": None,
2188
+ "blueprint": {"id": BP, "name": "Library"}} | over
2189
+
2190
+
2191
+ def _serve(monkeypatch, body, status=200):
2192
+ seen: list[httpx.Request] = []
2193
+
2194
+ def handler(request: httpx.Request) -> httpx.Response:
2195
+ seen.append(request)
2196
+ return httpx.Response(status, json=body)
2197
+
2198
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
2199
+ return seen
2200
+
2201
+
2202
+ #: Every `change` the API publishes, plus one it may add later, with the line each must print.
2203
+ #: The wording is the source popover's (`source-popover.tsx` `LineageBlock`).
2204
+ _KINDS = [
2205
+ (_entry("changed", previous_value="Austen", lineage="recorded"), "Austen → J. Austen"),
2206
+ (_entry("changed", previous_value=None, lineage="recorded"), "(empty) → J. Austen"),
2207
+ (_entry("amended", pre_amend="recorded", pre_amend_value="Jane Austin"),
2208
+ "Jane Austin → J. Austen QA corrected"),
2209
+ (_entry("amended", pre_amend="not_recorded"),
2210
+ "J. Austen QA corrected · earlier value not recorded"),
2211
+ (_entry("amended"), "J. Austen QA corrected"),
2212
+ (_entry("unchanged"), "re-verified"),
2213
+ (_entry("observed", value="Austen, J."), "seen, not applied: Austen, J."),
2214
+ (_entry("not_applied", value="Jane"), "found, not applied: Jane"),
2215
+ (_entry("initial"), "J. Austen"),
2216
+ (_entry("unknown", value=None), "(empty)"),
2217
+ (_entry("rewritten_by_a_future_writer"), "J. Austen"),
2218
+ ]
2219
+
2220
+
2221
+ @pytest.mark.parametrize("entry,line", _KINDS, ids=[
2222
+ f"{i}-{e['change']}-{e['pre_amend']}" for i, (e, _) in enumerate(_KINDS)])
2223
+ def test_history_prints_every_change_kind_in_the_popovers_words(monkeypatch, capsys, entry, line):
2224
+ _signed_in(monkeypatch)
2225
+ _serve(monkeypatch, _history(entry))
2226
+ assert main(["data", "history", "--cell", CELL]) == 0
2227
+ out_lines = capsys.readouterr().out.splitlines()
2228
+ header = next(r for r in out_lines if r.startswith("WHEN"))
2229
+ row = out_lines[out_lines.index(header) + 1]
2230
+ # Slice the CHANGE column out of the row by the header's offsets and compare it EXACTLY: a
2231
+ # substring check let a tag or a prefix on a bare-value kind through (review, MEDIUM-1).
2232
+ start, end = header.index("CHANGE"), header.index("RUN")
2233
+ assert row[start:end].rstrip() == line
2234
+
2235
+
2236
+ def test_history_truncates_a_long_value_and_never_the_qa_tag(monkeypatch, capsys):
2237
+ _signed_in(monkeypatch)
2238
+ long_old, long_new = "A" * 200, "B" * 200
2239
+ _serve(monkeypatch, _history(
2240
+ _entry("amended", pre_amend="recorded", pre_amend_value=long_old, value=long_new),
2241
+ _entry("amended", pre_amend="not_recorded", value=long_new),
2242
+ ))
2243
+ assert main(["data", "history", "--cell", CELL]) == 0
2244
+ text = capsys.readouterr().out
2245
+ assert long_old not in text and long_new not in text
2246
+ assert "A" * 29 + "… → " + "B" * 29 + "… QA corrected" in text
2247
+ assert "B" * 29 + "… QA corrected · earlier value not recorded" in text
2248
+
2249
+
2250
+ def test_history_json_is_the_api_envelope_reprinted(monkeypatch, capsys):
2251
+ _signed_in(monkeypatch)
2252
+ body = _history(
2253
+ _entry("amended", pre_amend="recorded", pre_amend_value="Jane Austin"),
2254
+ _entry("amended", pre_amend="not_recorded"),
2255
+ cell={"cell_id": CELL, "entity_key": "books", "record_id": REC, "record_name": "Emma",
2256
+ "column": "author"},
2257
+ )
2258
+ _serve(monkeypatch, body)
2259
+ assert main(["data", "history", "--cell", CELL, "--json"]) == 0
2260
+ printed = capsys.readouterr().out
2261
+ assert printed == json.dumps(body, indent=2) + "\n"
2262
+ assert json.loads(printed)["data"][0]["pre_amend_value"] == "Jane Austin"
2263
+
2264
+
2265
+ def test_history_by_cell_reads_the_cell_endpoint_with_its_flags(monkeypatch, capsys):
2266
+ _signed_in(monkeypatch)
2267
+ seen = _serve(monkeypatch, _history(_entry("initial"), has_more=True, next_cursor="nx",
2268
+ cell={"record_name": "Emma", "column": "author"}))
2269
+ rc = main(["data", "history", "--cell", CELL, "--rechecks", "--since",
2270
+ "2026-10-01T00:00:00Z", "--cursor", "cc", "--limit", "5"])
2271
+ assert rc == 0
2272
+ assert len(seen) == 1 # a cell id names its Blueprint: no /v1/blueprints lookup
2273
+ assert seen[0].url.path == f"/v1/cells/{CELL}/changes"
2274
+ assert dict(seen[0].url.params) == {"limit": "5", "cursor": "cc",
2275
+ "since": "2026-10-01T00:00:00Z", "include": "rechecks"}
2276
+ text = capsys.readouterr().out
2277
+ assert "Cell: Emma.author" in text and "--cursor nx" in text
2278
+ assert "RECORD" not in text # one cell: the record is in the Cell line, not a column
2279
+
2280
+
2281
+ def test_history_by_column_resolves_the_active_blueprint_and_passes_the_record(monkeypatch, capsys):
2282
+ _signed_in(monkeypatch)
2283
+ seen: list[httpx.Request] = []
2284
+
2285
+ def handler(request: httpx.Request) -> httpx.Response:
2286
+ seen.append(request)
2287
+ if request.url.path == "/v1/blueprints":
2288
+ return httpx.Response(200, json={"data": [{"id": BP, "is_active": True}]})
2289
+ return httpx.Response(200, json=_history(_entry("changed", previous_value="Austen")))
2290
+
2291
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
2292
+ assert main(["data", "history", "books", "author", "--record", REC]) == 0
2293
+ assert [r.url.path for r in seen] == [
2294
+ "/v1/blueprints", f"/v1/blueprints/{BP}/entities/books/columns/author/changes"]
2295
+ assert dict(seen[1].url.params) == {"limit": "25", "record_id": REC}
2296
+ text = capsys.readouterr().out
2297
+ assert "RECORD" in text and "Emma" in text and "Austen → J. Austen" in text
2298
+
2299
+
2300
+ def test_history_with_blueprint_skips_the_lookup(monkeypatch):
2301
+ _signed_in(monkeypatch)
2302
+ seen = _serve(monkeypatch, _history())
2303
+ assert main(["data", "history", "books", "author", "--blueprint", BP]) == 0
2304
+ assert [r.url.path for r in seen] == [f"/v1/blueprints/{BP}/entities/books/columns/author/changes"]
2305
+
2306
+
2307
+ def test_history_empty_page_says_so(monkeypatch, capsys):
2308
+ _signed_in(monkeypatch)
2309
+ _serve(monkeypatch, _history())
2310
+ assert main(["data", "history", "--cell", CELL]) == 0
2311
+ assert "No history found." in capsys.readouterr().out
2312
+
2313
+
2314
+ @pytest.mark.parametrize("argv", [
2315
+ ["data", "history"],
2316
+ ["data", "history", "books"],
2317
+ ["data", "history", "books", "author", "--cell", CELL],
2318
+ ["data", "history", "--cell", CELL, "--record", REC],
2319
+ ["data", "history", "--cell", CELL, "--blueprint", BP],
2320
+ ["data", "history", "books", "author", "--rechecks"],
2321
+ ["data", "history", "--cell", "../../v1/holds"],
2322
+ ["data", "history", "books", "author", "--record", "not-a-uuid"],
2323
+ ["data", "history", "books", "../holds", "--blueprint", BP],
2324
+ ["data", "history", "books", "author", "--record", ""],
2325
+ ["data", "history", "books", "author", "--blueprint", "../../v1/holds#"],
2326
+ ["data", "history", "--cell", CELL, "--record", ""],
2327
+ ], ids=["neither", "entity-only", "both-forms", "cell-with-record", "cell-with-blueprint",
2328
+ "rechecks-without-cell", "bad-cell-id", "bad-record", "bad-column", "empty-record",
2329
+ "bad-blueprint", "cell-with-empty-record"])
2330
+ def test_history_usage_errors_exit_2_and_send_nothing(monkeypatch, capsys, argv):
2331
+ _signed_in(monkeypatch)
2332
+ seen = _serve(monkeypatch, _history())
2333
+ assert main(argv) == 2
2334
+ assert capsys.readouterr().err.startswith("error: ")
2335
+ assert seen == []
2336
+
2337
+
2338
+ def test_history_outside_the_preview_surfaces_the_refusal(monkeypatch, capsys):
2339
+ _signed_in(monkeypatch)
2340
+ _serve(monkeypatch, {"error": {"type": "payment_error", "code": "payment_required",
2341
+ "message": "This row is outside your free preview.",
2342
+ "retriable": False, "request_id": "r"}}, status=402)
2343
+ assert main(["data", "history", "--cell", CELL]) == 1
2344
+ assert "outside your free preview" in capsys.readouterr().err
2345
+
2346
+
2347
+ def test_history_a_preview_page_says_it_is_clipped_on_stderr(monkeypatch, capsys):
2348
+ _signed_in(monkeypatch)
2349
+ _serve(monkeypatch, _history(_entry("initial"), truncated=True,
2350
+ truncated_reason="Free preview: only changes on the 5 preview rows."))
2351
+ assert main(["data", "history", "books", "author", "--blueprint", BP]) == 0
2352
+ captured = capsys.readouterr()
2353
+ assert "Free preview" in captured.err and "Free preview" not in captured.out
2354
+
2355
+
2356
+ @pytest.mark.parametrize("body", [{"raw": "<html>not the API</html>"}, {"data": None}],
2357
+ ids=["html-page", "no-list"])
2358
+ def test_history_a_response_that_is_not_a_change_log_is_a_failure_not_empty(monkeypatch, capsys,
2359
+ body):
2360
+ """Found nothing and could not look must not print the same line (review, LOW-3)."""
2361
+ _signed_in(monkeypatch)
2362
+ _serve(monkeypatch, body)
2363
+ assert main(["data", "history", "--cell", CELL]) == 1
2364
+ captured = capsys.readouterr()
2365
+ assert "No history found." not in captured.out
2366
+ assert "not a change log" in captured.err
2367
+
2368
+
2369
+ def test_history_times_are_shown_in_utc(monkeypatch, capsys):
2370
+ _signed_in(monkeypatch)
2371
+ _serve(monkeypatch, _history(_entry("initial", captured_at="2026-10-15T11:30:00+02:00"),
2372
+ _entry("initial", captured_at=None)))
2373
+ assert main(["data", "history", "--cell", CELL]) == 0
2374
+ text = capsys.readouterr().out
2375
+ assert "WHEN (UTC)" in text and "2026-10-15T09:30:00Z" in text and "date unknown" in text
2376
+
2377
+
2378
+ # gh #2456: the yes binds what pays as well as the total.
2379
+
2380
+ _RAIL_QUOTE = {
2381
+ **_RETRY_QUOTE,
2382
+ "funding": {"covered_by": "plan_credits", "plan_credits_needed": 2,
2383
+ "plan_credits_available": 5, "balance_cents": 0},
2384
+ }
2385
+
2386
+
2387
+ def _rail_handler(seen: list):
2388
+ def handler(request: httpx.Request) -> httpx.Response:
2389
+ body = json.loads(request.content) if request.content else {}
2390
+ seen.append(body)
2391
+ if body.get("expected_funding") != "plan_credits" or body.get("expected_total_cents") != 1200:
2392
+ return httpx.Response(409, json={"error": {
2393
+ "code": "retry_confirmation_required", "message": "confirm",
2394
+ "param": "expected_total_cents", "retriable": False, "details": _RAIL_QUOTE,
2395
+ }})
2396
+ return httpx.Response(202, json={
2397
+ "object": "run", "id": "new-run", "status": "queued",
2398
+ "retry": {"source_run_id": RUN, "mode": "restarted"},
2399
+ })
2400
+
2401
+ return handler
2402
+
2403
+
2404
+ def test_interactively_the_yes_binds_the_rail_shown(monkeypatch, capsys):
2405
+ _signed_in(monkeypatch)
2406
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
2407
+ prompts: list = []
2408
+ monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
2409
+ seen: list = []
2410
+ monkeypatch.setattr(httpx, "Client", _mock(_rail_handler(seen)))
2411
+ assert main(["runs", "retry", RUN]) == 0
2412
+ assert seen == [{}, {"expected_total_cents": 1200, "expected_funding": "plan_credits"}]
2413
+ assert "uses 2 plan run credits" in prompts[0]
2414
+
2415
+
2416
+ def test_in_a_pipe_the_hint_names_the_rail_flag(monkeypatch, capsys):
2417
+ _signed_in(monkeypatch)
2418
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
2419
+ seen: list = []
2420
+ monkeypatch.setattr(httpx, "Client", _mock(_rail_handler(seen)))
2421
+ assert main(["runs", "retry", RUN]) == 2
2422
+ assert "--expected-total 1200 --expected-funding plan_credits" in capsys.readouterr().err
2423
+
2424
+
2425
+ def test_expected_funding_is_sent_beside_the_total(monkeypatch, capsys):
2426
+ _signed_in(monkeypatch)
2427
+ seen: list = []
2428
+ monkeypatch.setattr(httpx, "Client", _mock(_rail_handler(seen)))
2429
+ argv = ["runs", "retry", RUN, "--expected-total", "1200", "--expected-funding", "plan_credits"]
2430
+ assert main(argv) == 0
2431
+ assert seen == [{"expected_total_cents": 1200, "expected_funding": "plan_credits"}]
2432
+
2433
+
2434
+ def test_expected_funding_without_its_total_sends_nothing(monkeypatch, capsys):
2435
+ _signed_in(monkeypatch)
2436
+ seen: list = []
2437
+ monkeypatch.setattr(httpx, "Client", _mock(_rail_handler(seen)))
2438
+ assert main(["runs", "retry", RUN, "--expected-funding", "plan_credits"]) == 2
2439
+ assert seen == []
2440
+
2441
+
2442
+ def test_a_moved_rail_at_the_same_total_is_not_called_a_price_change(monkeypatch, capsys):
2443
+ """gh #2456 review (seat-contract 2a): the person is told what moved."""
2444
+ _signed_in(monkeypatch)
2445
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
2446
+ prompts: list = []
2447
+ monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "n")
2448
+ balance_quote = {**_RAIL_QUOTE, "funding": {**_RAIL_QUOTE["funding"], "covered_by": "balance"}}
2449
+
2450
+ def handler(request: httpx.Request) -> httpx.Response:
2451
+ return httpx.Response(409, json={"error": {
2452
+ "code": "retry_price_changed", "message": "moved", "param": "expected_funding",
2453
+ "retriable": False, "details": balance_quote,
2454
+ }})
2455
+
2456
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
2457
+ argv = ["runs", "retry", RUN, "--expected-total", "1200", "--expected-funding", "plan_credits"]
2458
+ assert main(argv) == 0
2459
+ assert "What would pay for this retry changed" in prompts[0]
2460
+ assert "price changed" not in prompts[0]
File without changes