patchling 0.9.0__tar.gz → 0.9.1__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.
Files changed (37) hide show
  1. {patchling-0.9.0 → patchling-0.9.1}/PKG-INFO +1 -1
  2. {patchling-0.9.0 → patchling-0.9.1}/patchling/__init__.py +1 -1
  3. {patchling-0.9.0 → patchling-0.9.1}/patchling/core.py +46 -2
  4. {patchling-0.9.0 → patchling-0.9.1}/patchling/relevance.py +92 -28
  5. {patchling-0.9.0 → patchling-0.9.1}/patchling.egg-info/PKG-INFO +1 -1
  6. {patchling-0.9.0 → patchling-0.9.1}/tests/test_relevance.py +234 -2
  7. {patchling-0.9.0 → patchling-0.9.1}/LICENSE.txt +0 -0
  8. {patchling-0.9.0 → patchling-0.9.1}/README.md +0 -0
  9. {patchling-0.9.0 → patchling-0.9.1}/patchling/applydiff.py +0 -0
  10. {patchling-0.9.0 → patchling-0.9.1}/patchling/env.py +0 -0
  11. {patchling-0.9.0 → patchling-0.9.1}/patchling/gptpatch.py +0 -0
  12. {patchling-0.9.0 → patchling-0.9.1}/patchling/py.typed +0 -0
  13. {patchling-0.9.0 → patchling-0.9.1}/patchling.egg-info/SOURCES.txt +0 -0
  14. {patchling-0.9.0 → patchling-0.9.1}/patchling.egg-info/dependency_links.txt +0 -0
  15. {patchling-0.9.0 → patchling-0.9.1}/patchling.egg-info/entry_points.txt +0 -0
  16. {patchling-0.9.0 → patchling-0.9.1}/patchling.egg-info/requires.txt +0 -0
  17. {patchling-0.9.0 → patchling-0.9.1}/patchling.egg-info/top_level.txt +0 -0
  18. {patchling-0.9.0 → patchling-0.9.1}/pyproject.toml +0 -0
  19. {patchling-0.9.0 → patchling-0.9.1}/setup.cfg +0 -0
  20. {patchling-0.9.0 → patchling-0.9.1}/setup.py +0 -0
  21. {patchling-0.9.0 → patchling-0.9.1}/tests/test_applydiff.py +0 -0
  22. {patchling-0.9.0 → patchling-0.9.1}/tests/test_applydiff_edgecases.py +0 -0
  23. {patchling-0.9.0 → patchling-0.9.1}/tests/test_argparse_interleaving.py +0 -0
  24. {patchling-0.9.0 → patchling-0.9.1}/tests/test_diff_parse.py +0 -0
  25. {patchling-0.9.0 → patchling-0.9.1}/tests/test_extract_diff_blocks.py +0 -0
  26. {patchling-0.9.0 → patchling-0.9.1}/tests/test_failing_case.py +0 -0
  27. {patchling-0.9.0 → patchling-0.9.1}/tests/test_live_llm.py +0 -0
  28. {patchling-0.9.0 → patchling-0.9.1}/tests/test_multidiff.py +0 -0
  29. {patchling-0.9.0 → patchling-0.9.1}/tests/test_packaging.py +0 -0
  30. {patchling-0.9.0 → patchling-0.9.1}/tests/test_parse_diff_per_file.py +0 -0
  31. {patchling-0.9.0 → patchling-0.9.1}/tests/test_public_api.py +0 -0
  32. {patchling-0.9.0 → patchling-0.9.1}/tests/test_relevance_bench.py +0 -0
  33. {patchling-0.9.0 → patchling-0.9.1}/tests/test_relevance_live.py +0 -0
  34. {patchling-0.9.0 → patchling-0.9.1}/tests/test_smartapply.py +0 -0
  35. {patchling-0.9.0 → patchling-0.9.1}/tests/test_smartapply_bugs.py +0 -0
  36. {patchling-0.9.0 → patchling-0.9.1}/tests/test_strip_bad_ouput.py +0 -0
  37. {patchling-0.9.0 → patchling-0.9.1}/tests/test_swallow_reasoning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchling
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Natural-language code transformation as a library: generate unified diffs with LLMs and apply them resiliently with smartapply. Formerly gptdiff.
5
5
  Author: 255labs
6
6
  License: MIT
@@ -29,7 +29,7 @@ from .relevance import (
29
29
  filter_relevant,
30
30
  )
31
31
 
32
- __version__ = "0.9.0"
32
+ __version__ = "0.9.1"
33
33
 
34
34
  __all__ = [
35
35
  "__version__",
@@ -21,6 +21,7 @@ from threading import Lock
21
21
  import shutil
22
22
  import base64
23
23
  import mimetypes
24
+ import weakref
24
25
 
25
26
  import openai
26
27
  from openai import OpenAI
@@ -377,7 +378,7 @@ def call_llm(
377
378
  if reasoning_effort:
378
379
  request["reasoning_effort"] = reasoning_effort
379
380
  try:
380
- return client.chat.completions.create(**request)
381
+ response = client.chat.completions.create(**request)
381
382
  except TypeError as exc:
382
383
  # Retry once, without the parameter, only when this call sent it
383
384
  # and the error says the parameter is unsupported. A generic
@@ -385,8 +386,51 @@ def call_llm(
385
386
  # reasoning_effort is made exactly once.
386
387
  if "reasoning_effort" in request and _provider_rejected_reasoning_effort(exc):
387
388
  request.pop("reasoning_effort", None)
388
- return client.chat.completions.create(**request)
389
+ response = client.chat.completions.create(**request)
390
+ # The caller asked for an effort this SDK refused. Record that
391
+ # nothing was sent so a relevance decision is not cached under
392
+ # the requested effort.
393
+ note_effort_sent(response, "")
394
+ return response
389
395
  raise
396
+ if "reasoning_effort" in request:
397
+ note_effort_sent(response, request["reasoning_effort"])
398
+ return response
399
+
400
+
401
+ # Responses are not always able to grow an attribute (some SDK models are
402
+ # slotted). The weak map covers ordinary objects; the attribute covers the rest.
403
+ _effort_sent_for_response: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
404
+
405
+
406
+ def note_effort_sent(response: Any, effort: str | None) -> None:
407
+ """Record the ``reasoning_effort`` value actually sent for ``response``.
408
+
409
+ ``""`` means the parameter was omitted after the client rejected it.
410
+ """
411
+ value = "" if not effort else str(effort)
412
+ try:
413
+ _effort_sent_for_response[response] = value
414
+ except TypeError:
415
+ pass
416
+ try:
417
+ object.__setattr__(response, "_patchling_effort_sent", value)
418
+ except (AttributeError, TypeError):
419
+ pass
420
+
421
+
422
+ def effort_sent_for(response: Any):
423
+ """Effort recorded by :func:`note_effort_sent`, or ``None`` if unrecorded."""
424
+ if response is None:
425
+ return None
426
+ recorded = None
427
+ try:
428
+ recorded = _effort_sent_for_response.get(response)
429
+ except TypeError:
430
+ recorded = None
431
+ if recorded is None:
432
+ recorded = getattr(response, "_patchling_effort_sent", None)
433
+ return recorded
390
434
 
391
435
 
392
436
  def _provider_rejected_reasoning_effort(exc: BaseException) -> bool:
@@ -7,14 +7,18 @@ to the task. Only ``yes`` files are returned for ``generate_diff`` and
7
7
 
8
8
  Prompt version 3. Each request uses 1024 output tokens and
9
9
  ``reasoning_effort="low"`` (one retry without that parameter on a 4xx that
10
- names it). The cache key is the SHA-256 of length-prefixed fields: prompt
11
- version, model, token limit, the effort actually sent (``low``, or empty when
12
- the retry omitted it), task, path, mode (``full``, ``truncated``, or
13
- ``binary``), the preview note, and the exact body sent. A decision is a
14
- leading standalone yes/no after the supported wrappers. Anything else is
15
- malformed: the file is included, ``failed_open`` is set, and the decision is
16
- not cached. A cache read error is a miss and the model is called; a cache
17
- write error is logged and does not abort the run.
10
+ names it, or when the OpenAI client rejects the keyword). The cache key is
11
+ the SHA-256 of length-prefixed fields: prompt version, model, token limit,
12
+ the effort actually sent (``low``, or empty when the retry omitted it), task,
13
+ path, mode (``full``, ``truncated``, or ``binary``), the preview note, and
14
+ the exact body sent. A decision is a leading standalone yes/no after the
15
+ supported wrappers, including a quoted decision followed by a reason.
16
+ Anything else is malformed: the file is included, ``failed_open`` is set, and
17
+ the decision is not cached. Non-string message content is that same failure.
18
+ A cache read error is a miss and the model is called; a cache write error is
19
+ logged and does not abort the run. Invalid UTF-8 anywhere in a byte payload
20
+ is binary. ``on_binary="include"`` returns those original bytes unchanged.
21
+ A leading UTF-8 BOM (U+FEFF) stays in decoded text.
18
22
  """
19
23
 
20
24
  from __future__ import annotations
@@ -625,24 +629,45 @@ def _utf8_text_or_none(sample: bytes) -> Optional[str]:
625
629
 
626
630
 
627
631
  def is_binary_content(content) -> bool:
628
- """True when the first 8192 bytes are not text.
632
+ """True when the payload is not text.
629
633
 
630
- A multibyte character cut by that window is still text. A NUL or any
631
- genuinely invalid byte is binary. A non-empty buffer that decodes to
632
- empty text (only an incomplete UTF-8 sequence) is binary.
634
+ The first 8192 bytes are decoded as UTF-8. An incomplete sequence of at
635
+ most 3 bytes at that probe boundary is still text. A NUL or any other
636
+ invalid byte inside the window is binary, and so is a non-empty window
637
+ that decodes to empty text. Bytes past the window are checked too: any
638
+ invalid UTF-8 there is binary. A leading BOM is text.
633
639
  """
634
640
  if isinstance(content, str):
635
- sample = content.encode("utf-8")[:8192]
641
+ if "\x00" in content:
642
+ return True
643
+ raw = content.encode("utf-8")
636
644
  elif isinstance(content, (bytes, bytearray)):
637
- sample = bytes(content[:8192])
645
+ raw = bytes(content)
638
646
  else:
639
647
  return False
640
- text = _utf8_text_or_none(sample)
648
+ return _bytes_are_binary(raw)
649
+
650
+
651
+ def _bytes_are_binary(raw: bytes) -> bool:
652
+ if b"\x00" in raw:
653
+ return True
654
+ window = raw[:8192]
655
+ text = _utf8_text_or_none(window)
641
656
  if text is None:
642
657
  return True
643
- if sample and text == "":
658
+ if window and text == "":
659
+ return True
660
+ if _text_looks_binary(text):
644
661
  return True
645
- return _text_looks_binary(text)
662
+ if len(raw) <= 8192:
663
+ return False
664
+ # The probe may end inside a character. The rest of the file has to be
665
+ # well-formed UTF-8; an invalid byte after the window is not dropped.
666
+ try:
667
+ raw.decode("utf-8")
668
+ except UnicodeDecodeError:
669
+ return True
670
+ return False
646
671
 
647
672
 
648
673
  def _text_looks_binary(sample: str) -> bool:
@@ -666,23 +691,39 @@ def _coerce_text(content) -> str:
666
691
  raise TypeError(f"file content must be str or bytes, got {type(content).__name__}")
667
692
 
668
693
 
694
+ def _decode_utf8_text(raw: bytes) -> Optional[str]:
695
+ """Decode UTF-8, keeping a leading BOM (U+FEFF).
696
+
697
+ ``utf-8-sig`` is not used. A buffer of at most 8192 bytes may end on an
698
+ incomplete sequence; those trailing bytes are dropped. A longer buffer
699
+ must be entirely well-formed, so a bad byte after the probe is not cut off.
700
+ """
701
+ try:
702
+ return raw.decode("utf-8")
703
+ except UnicodeDecodeError:
704
+ if len(raw) > 8192:
705
+ return None
706
+ return _utf8_text_or_none(raw)
707
+
708
+
669
709
  def _prepare_content(content) -> tuple[bool, str]:
670
710
  """Return ``(is_binary, text)``.
671
711
 
672
- Valid UTF-8 bytes are decoded to text and are never replaced with an
673
- empty string. Invalid UTF-8 or a NUL byte takes the binary path.
712
+ Valid UTF-8 bytes are decoded to text, including a leading U+FEFF, and
713
+ are never replaced with an empty string. Invalid UTF-8 anywhere, or a
714
+ NUL byte, takes the binary path. Callers that include a binary file pass
715
+ the original bytes through instead of this text.
674
716
  """
675
717
  if isinstance(content, str):
676
718
  return is_binary_content(content), content
677
719
  if isinstance(content, (bytes, bytearray)):
678
720
  raw = bytes(content)
679
- if b"\x00" in raw:
721
+ if is_binary_content(raw):
680
722
  return True, ""
681
- try:
682
- text = raw.decode("utf-8")
683
- except UnicodeDecodeError:
723
+ text = _decode_utf8_text(raw)
724
+ if text is None or (raw and text == ""):
684
725
  return True, ""
685
- return _text_looks_binary(text), text
726
+ return False, text
686
727
  raise TypeError(f"file content must be str or bytes, got {type(content).__name__}")
687
728
 
688
729
 
@@ -709,6 +750,11 @@ def _usage_triplet(usage) -> tuple[int, int, int]:
709
750
 
710
751
 
711
752
  def _content_text(content):
753
+ """Message content must already be a string.
754
+
755
+ A list such as ``['no']`` is not joined or passed through ``str``. That
756
+ would become the cacheable word ``no``. Non-string content fails open.
757
+ """
712
758
  if content is None:
713
759
  return ""
714
760
  if not isinstance(content, str):
@@ -878,6 +924,13 @@ def _ask(
878
924
  else:
879
925
  raise
880
926
  raw, prompt_tokens, completion_tokens, total_tokens = _usage_and_text(response)
927
+ # call_llm swallows a legacy SDK TypeError and retries with no effort.
928
+ # That response records effort "" so the decision is not stored as "low".
929
+ from .core import effort_sent_for
930
+
931
+ recorded = effort_sent_for(response)
932
+ if recorded is not None:
933
+ sent_effort = recorded or None
881
934
  return raw, prompt_tokens, completion_tokens, total_tokens, sent_effort
882
935
 
883
936
 
@@ -918,11 +971,15 @@ def filter_relevant(
918
971
  the empty effort. Empty answers, malformed answers, and errors are not
919
972
  cached. A cache read error is a miss and the model is called; a cache
920
973
  write error is logged and ignored. A response with missing choices, an
921
- empty choice list, a missing message, or a non-JSON body fails that file
922
- open and is not cached.
974
+ empty choice list, a missing message, non-string message content, or a
975
+ non-JSON body fails that file open and is not cached. When the default
976
+ client retries without ``reasoning_effort``, the decision is stored under
977
+ the empty effort.
923
978
 
924
979
  Binary files and files over ``max_bytes`` / ``max_tokens`` are not sent in
925
- full. ``on_binary`` and ``on_oversize`` are ``include`` (keep, no model
980
+ full. Invalid UTF-8 after the first 8192 bytes is still binary.
981
+ ``on_binary="include"`` keeps the original bytes. ``on_binary`` and
982
+ ``on_oversize`` are ``include`` (keep, no model
926
983
  call), ``exclude`` (drop, no model call), or ``truncate`` (ask the model
927
984
  about a truncated or redacted preview). Pass ``None`` for ``max_bytes`` or
928
985
  ``max_tokens`` to disable that limit. ``skip_patterns`` defaults to
@@ -960,6 +1017,9 @@ def filter_relevant(
960
1017
 
961
1018
  by_path: dict[str, RelevanceDecision] = {}
962
1019
  text_by_path: dict[str, str] = {}
1020
+ # Binary includes keep the caller's object. Decoding would replace or drop
1021
+ # the invalid byte and the bytes that follow it.
1022
+ binary_original: dict = {}
963
1023
  # Group files that share a cache key so each distinct body is sent once.
964
1024
  groups: dict[str, list] = {}
965
1025
  group_order: list[str] = []
@@ -976,6 +1036,8 @@ def filter_relevant(
976
1036
  binary, text = _prepare_content(content)
977
1037
  if not binary:
978
1038
  text_by_path[path] = text
1039
+ else:
1040
+ binary_original[path] = content
979
1041
  if binary:
980
1042
  if on_binary == "truncate":
981
1043
  note = (
@@ -1215,7 +1277,9 @@ def filter_relevant(
1215
1277
  for path, content in files.items():
1216
1278
  decision = by_path[path]
1217
1279
  if decision.decision == "yes":
1218
- if path in text_by_path:
1280
+ if path in binary_original:
1281
+ included[path] = binary_original[path]
1282
+ elif path in text_by_path:
1219
1283
  included[path] = text_by_path[path]
1220
1284
  elif isinstance(content, str):
1221
1285
  included[path] = content
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchling
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Natural-language code transformation as a library: generate unified diffs with LLMs and apply them resiliently with smartapply. Formerly gptdiff.
5
5
  Author: 255labs
6
6
  License: MIT
@@ -1217,6 +1217,13 @@ def test_parser_wrappers_and_terminators():
1217
1217
  assert parse_relevance_answer("**yes**, because it is")[0] == "yes"
1218
1218
 
1219
1219
 
1220
+ def test_parser_quoted_decision_followed_by_reason():
1221
+ assert parse_relevance_answer("**no** unrelated")[0] == "no"
1222
+ assert parse_relevance_answer('"no" unrelated')[0] == "no"
1223
+ assert parse_relevance_answer("\u201cno\u201d — unrelated")[0] == "no"
1224
+ assert parse_relevance_answer("**yes** the helper is here")[0] == "yes"
1225
+
1226
+
1220
1227
  def test_full_and_truncated_abc_do_not_share_a_key():
1221
1228
  full = RelevanceCache.make_key(
1222
1229
  model="m", task="task", content="abc", path="a.py", mode="full", note=""
@@ -1314,6 +1321,217 @@ def test_valid_utf8_bytes_are_decoded_and_do_not_share_a_path_key():
1314
1321
  assert len(bodies) == 2
1315
1322
 
1316
1323
 
1324
+ def test_invalid_utf8_after_probe_window_is_binary_and_include_keeps_bytes():
1325
+ from patchling.relevance import _prepare_content, is_binary_content
1326
+
1327
+ payload = b"a" * 8192 + b"\xff" + b"TAIL"
1328
+ assert is_binary_content(payload) is True
1329
+ assert _prepare_content(payload) == (True, "")
1330
+
1331
+ # A character split by the 8192 window is still text when the whole file is valid.
1332
+ split = b"a" * 8191 + "é".encode("utf-8") + b"TAIL"
1333
+ assert len(split) > 8192
1334
+ binary, text = _prepare_content(split)
1335
+ assert binary is False
1336
+ assert text.endswith("éTAIL")
1337
+ assert "\ufffd" not in text
1338
+
1339
+ calls = {"n": 0}
1340
+
1341
+ def fake_call_llm(api_key, base_url, model, messages, max_tokens, temperature, budget_tokens=None):
1342
+ calls["n"] += 1
1343
+ user = messages[-1]["content"]
1344
+ assert "TAIL" not in user
1345
+ return _Response("yes")
1346
+
1347
+ excluded = filter_relevant(
1348
+ {"a.bin": payload},
1349
+ "task",
1350
+ model="m",
1351
+ cache=False,
1352
+ api_key="k",
1353
+ on_binary="exclude",
1354
+ call_llm=fake_call_llm,
1355
+ )
1356
+ assert calls["n"] == 0
1357
+ assert excluded.files == {}
1358
+ assert excluded.decisions[0].decision == "no"
1359
+ assert excluded.decisions[0].skipped_reason == "binary"
1360
+
1361
+ included = filter_relevant(
1362
+ {"a.bin": payload},
1363
+ "task",
1364
+ model="m",
1365
+ cache=False,
1366
+ api_key="k",
1367
+ on_binary="include",
1368
+ call_llm=fake_call_llm,
1369
+ )
1370
+ assert calls["n"] == 0
1371
+ assert included.decisions[0].decision == "yes"
1372
+ assert included.files["a.bin"] is payload
1373
+ assert included.files["a.bin"].endswith(b"\xffTAIL")
1374
+
1375
+
1376
+ def test_leading_bom_is_preserved_in_decoded_text():
1377
+ from patchling.relevance import PROMPT_VERSION, _prepare_content, _truncate_text
1378
+
1379
+ raw = b"\xef\xbb\xbfhello"
1380
+ binary, text = _prepare_content(raw)
1381
+ assert binary is False
1382
+ assert text == "\ufeffhello"
1383
+ assert text.startswith("\ufeff")
1384
+ assert _truncate_text(text, None, 1, None).startswith("\ufeff")
1385
+
1386
+ with_bom = RelevanceCache.make_key(
1387
+ model="m",
1388
+ task="task",
1389
+ content=text,
1390
+ path="a.py",
1391
+ max_output_tokens=1024,
1392
+ prompt_version=PROMPT_VERSION,
1393
+ effort="low",
1394
+ mode="full",
1395
+ note="",
1396
+ )
1397
+ without_bom = RelevanceCache.make_key(
1398
+ model="m",
1399
+ task="task",
1400
+ content="hello",
1401
+ path="a.py",
1402
+ max_output_tokens=1024,
1403
+ prompt_version=PROMPT_VERSION,
1404
+ effort="low",
1405
+ mode="full",
1406
+ note="",
1407
+ )
1408
+ assert with_bom != without_bom
1409
+
1410
+ def fake_call_llm(api_key, base_url, model, messages, max_tokens, temperature, budget_tokens=None):
1411
+ assert "\ufeffhello" in messages[-1]["content"]
1412
+ return _Response("yes")
1413
+
1414
+ result = filter_relevant(
1415
+ {"a.py": raw},
1416
+ "task",
1417
+ model="m",
1418
+ cache=False,
1419
+ api_key="k",
1420
+ call_llm=fake_call_llm,
1421
+ )
1422
+ assert result.files["a.py"] == "\ufeffhello"
1423
+
1424
+
1425
+ def test_non_string_message_content_fails_open_uncached():
1426
+ class Message:
1427
+ content = ["no"]
1428
+
1429
+ class Choice:
1430
+ message = Message()
1431
+
1432
+ class Response:
1433
+ choices = [Choice()]
1434
+ usage = None
1435
+
1436
+ payloads = [
1437
+ {"choices": [{"message": {"content": ["no"]}}]},
1438
+ Response(),
1439
+ ]
1440
+ for payload in payloads:
1441
+ calls = {"n": 0}
1442
+
1443
+ def fake_call_llm(api_key, base_url, model, messages, max_tokens, temperature, budget_tokens=None, payload=payload):
1444
+ calls["n"] += 1
1445
+ return payload
1446
+
1447
+ cache = RelevanceCache()
1448
+ result = filter_relevant(
1449
+ {"a.py": "x\n"},
1450
+ "task",
1451
+ model="m",
1452
+ cache=cache,
1453
+ api_key="k",
1454
+ call_llm=fake_call_llm,
1455
+ )
1456
+ decision = result.decisions[0]
1457
+ assert decision.failed_open is True
1458
+ assert decision.decision == "yes"
1459
+ assert decision.cached is False
1460
+ assert "malformed response" in (decision.error or "")
1461
+ assert cache.get(decision.cache_key) is None if decision.cache_key else True
1462
+ again = filter_relevant(
1463
+ {"a.py": "x\n"},
1464
+ "task",
1465
+ model="m",
1466
+ cache=cache,
1467
+ api_key="k",
1468
+ call_llm=fake_call_llm,
1469
+ )
1470
+ assert again.decisions[0].cached is False
1471
+ assert calls["n"] == 2
1472
+
1473
+
1474
+ def test_default_wrapper_caches_empty_effort_when_legacy_sdk_rejects_it(monkeypatch):
1475
+ """The real call_llm retries a legacy SDK; the cache key uses effort ""."""
1476
+ from patchling.relevance import PROMPT_VERSION
1477
+
1478
+ calls = []
1479
+
1480
+ class Message:
1481
+ content = "no, unrelated"
1482
+
1483
+ class Choice:
1484
+ message = Message()
1485
+
1486
+ class Usage:
1487
+ prompt_tokens = 3
1488
+ completion_tokens = 2
1489
+ total_tokens = 5
1490
+
1491
+ class Response:
1492
+ choices = [Choice()]
1493
+ usage = Usage()
1494
+
1495
+ class Completions:
1496
+ def create(self, **kwargs):
1497
+ calls.append("reasoning_effort" in kwargs)
1498
+ if "reasoning_effort" in kwargs:
1499
+ raise TypeError("unexpected keyword argument 'reasoning_effort'")
1500
+ return Response()
1501
+
1502
+ class Chat:
1503
+ completions = Completions()
1504
+
1505
+ class Client:
1506
+ def __init__(self, **kwargs):
1507
+ self.chat = Chat()
1508
+
1509
+ monkeypatch.setattr("patchling.core.OpenAI", Client)
1510
+ cache = RelevanceCache()
1511
+ files = {"a.py": "x = 1\n"}
1512
+ first = filter_relevant(files, "task", model="m", cache=cache, api_key="k")
1513
+ second = filter_relevant(files, "task", model="m", cache=cache, api_key="k")
1514
+ assert calls == [True, False]
1515
+ assert first.decisions[0].cached is False
1516
+ assert first.decisions[0].decision == "no"
1517
+ assert second.decisions[0].cached is True
1518
+ assert second.decisions[0].decision == "no"
1519
+ common = dict(
1520
+ model="m",
1521
+ task="task",
1522
+ content="x = 1\n",
1523
+ path="a.py",
1524
+ max_output_tokens=1024,
1525
+ prompt_version=PROMPT_VERSION,
1526
+ mode="full",
1527
+ note="",
1528
+ )
1529
+ low = RelevanceCache.make_key(**common, effort="low")
1530
+ omitted = RelevanceCache.make_key(**common, effort="")
1531
+ assert cache.get(low) is None
1532
+ assert cache.get(omitted)["decision"] == "no"
1533
+
1534
+
1317
1535
  def test_envelope_errors_fail_open_and_are_not_cached():
1318
1536
  payloads = [
1319
1537
  {"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}},
@@ -1322,6 +1540,7 @@ def test_envelope_errors_fail_open_and_are_not_cached():
1322
1540
  {"choices": [{"message": {}}]},
1323
1541
  "this is not json",
1324
1542
  None,
1543
+ {"choices": [{"message": {"content": ["no"]}}]},
1325
1544
  ]
1326
1545
  for payload in payloads:
1327
1546
  calls = {"n": 0}
@@ -1492,8 +1711,8 @@ def test_cli_apply_uses_the_full_original_file_map(tmp_path, monkeypatch):
1492
1711
 
1493
1712
  # sha256 of tests/fixtures/parity/relevance.json. That file is a byte-identical
1494
1713
  # copy of test/fixtures/parity/relevance.json on 255BITS/patchling branch
1495
- # cursor/relevance-filter-915b. A mismatch fails CI.
1496
- RELEVANCE_FIXTURE_SHA256 = "90b7299375518fbc6cb9bfeacb536f46f95c17ed72f8f66d063592bfdaec4665"
1714
+ # cursor/relevance-filter-followup-915b. A mismatch fails CI.
1715
+ RELEVANCE_FIXTURE_SHA256 = "6976ad71714be0138a6043d9bdeb724abe34a65515f8389913dca10a0c48c4f2"
1497
1716
 
1498
1717
 
1499
1718
  def test_vendored_relevance_fixture_matches_library():
@@ -1625,6 +1844,19 @@ def test_vendored_relevance_fixture_matches_library():
1625
1844
  expect = case.get("binary", case.get("expect_binary"))
1626
1845
  assert is_binary_content(sample) is expect, case
1627
1846
 
1847
+ bom = data.get("bom")
1848
+ if bom:
1849
+ from patchling.relevance import _prepare_content
1850
+
1851
+ sample = bytes(bom["bytes"])
1852
+ binary, text = _prepare_content(sample)
1853
+ assert binary is False
1854
+ assert text == bom["text"]
1855
+ assert text.startswith("\ufeff")
1856
+ assert count_tokens(text) == bom["tokens"]
1857
+ assert _truncate_text(text, None, bom["maxTokens"], None) == bom["truncated"]
1858
+ assert _truncate_text(text, None, bom["maxTokens"], None).startswith("\ufeff")
1859
+
1628
1860
  ignore = data["ignore"]
1629
1861
  assert list(DEFAULT_SKIP_PATTERNS) == list(ignore["patterns"])
1630
1862
  for example in ignore["examples"]:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes