Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion skillopt_sleep/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,24 @@ def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:

@staticmethod
def _parse_jsonl_response(raw: str) -> str:
"""Concatenate assistant text from a Copilot JSONL event stream.

Behaviourally identical to ``skillopt.model.copilot_backend``'s
``parse_copilot_jsonl``; vendored because this package keeps ZERO
dependency on the research package (see the module docstring of
``skillopt_sleep.gate`` for the same arrangement). Keep the two in sync.

A malformed event must never take down the whole stream: the CLI emits
one JSON object per line, so a single bad line loses at most that line's
text while the surrounding ``assistant.message`` events still parse.
``data`` is therefore type-checked rather than assumed to be an object.
A truthy non-dict (``"text"``, ``5``, a non-empty list) would otherwise
raise ``AttributeError`` from the field access, which no caller catches.

The exception list is deliberately wider than the research-package copy:
``json.loads`` raises ``RecursionError`` rather than ``JSONDecodeError``
on a deeply nested payload.
"""
parts: List[str] = []
for line in raw.splitlines():
line = line.strip()
Expand All @@ -1579,8 +1597,13 @@ def _parse_jsonl_response(raw: str) -> str:
obj = json.loads(line)
except (ValueError, RecursionError, TypeError):
continue
if not isinstance(obj, dict):
continue
if obj.get("type") == "assistant.message":
content = (obj.get("data") or {}).get("content")
data = obj.get("data")
if not isinstance(data, dict):
continue
content = data.get("content")
if isinstance(content, str) and content:
parts.append(content)
return "\n".join(parts).strip()
Expand Down
20 changes: 20 additions & 0 deletions tests/test_sleep_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,26 @@ def test_parse_jsonl_ignores_oversized_integer(self):
raw = '{"type":"assistant.message","data":' + "9" * 5000 + "}"
self.assertEqual(CopilotCliBackend._parse_jsonl_response(raw), "")

def test_parse_jsonl_skips_non_dict_data_without_losing_stream(self):
# A malformed event must cost only its own line: the surrounding
# assistant.message events still have to reach the caller. Mirrors
# tests/test_copilot_exec_backend.py for the research-package copy.
from skillopt_sleep.backend import CopilotCliBackend
for bad in ('"text"', "5", "[1,2]", "true"):
raw = "\n".join([
'{"type":"assistant.message","data":{"content":"first"}}',
'{"type":"assistant.message","data":' + bad + "}",
'{"type":"assistant.message","data":{"content":"second"}}',
])
with self.subTest(data=bad):
self.assertEqual(
CopilotCliBackend._parse_jsonl_response(raw), "first\nsecond"
)

def test_parse_jsonl_ignores_non_object_top_level(self):
from skillopt_sleep.backend import CopilotCliBackend
self.assertEqual(CopilotCliBackend._parse_jsonl_response("[]\nnull\n"), "")

def test_isolated_home_by_default(self):
from skillopt_sleep.backend import CopilotCliBackend
be = CopilotCliBackend()
Expand Down