test(sdk-python): revalidate the poetry lock and cover the partialjson parse path

Widening the partialjson constraint in #6123 invalidated poetry.lock's
content-hash, so a bare `poetry install` in sdk-python fails with
"pyproject.toml changed significantly since poetry.lock was last generated".
CI never saw it because test_unit-python-sdk.yml runs `poetry lock` first, and
publishing is unaffected because `poetry build` ignores the lock — but local
dev is blocked until someone relocks. Refreshed with Poetry 2.1.3 (the lock's
own generator) so the diff stays limited to the hash, and moved partialjson to
1.1.0 so CI exercises the version a fresh install now resolves. ag-ui-langgraph
is deliberately left at 0.0.42: a from-scratch resolve pulls 0.0.43, which
fails four intercepted-tool-call tests on a missing `emit_raw_events`.

The parse path had no coverage at all — disabling JSONParser.parse outright
left all 225 tests passing, because every partialjson failure mode degrades to
"no predicted state was emitted" behind the bare `except` in predict_state().
These tests pin the guarantees the `>=0.0.8,<2.0.0` range must keep, and are
version-agnostic about intermediate frames, which legitimately differ (1.1.0
preserves trailing whitespace mid-string where 0.0.8 dropped it).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Taylor
2026-08-18 10:02:19 -05:00
parent f8b5de55ab
commit d37fe9cbd2
2 changed files with 155 additions and 4 deletions
+7 -4
View File
@@ -3115,16 +3115,19 @@ files = [
[[package]]
name = "partialjson"
version = "0.0.8"
version = "1.1.0"
description = "Parse incomplete or partial json"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "partialjson-0.0.8-py3-none-any.whl", hash = "sha256:22c6c60944137f931a7033fa0eeee2d74b49114f3d45c25a560b07a6ebf22b76"},
{file = "partialjson-0.0.8.tar.gz", hash = "sha256:91217e19a15049332df534477f56420065ad1729cedee7d8c7433e1d2acc7dca"},
{file = "partialjson-1.1.0-py3-none-any.whl", hash = "sha256:380891bf2f4ae05094839c27dceec9b51db02aabeb46b9a2acef3d63755ddcea"},
{file = "partialjson-1.1.0.tar.gz", hash = "sha256:733e540b91e8d9bb6b0d29caf02a4e67e213103d40e25c5f6d16401db3f6ede2"},
]
[package.extras]
json5 = ["json5"]
[[package]]
name = "pdfminer-six"
version = "20251230"
@@ -5642,4 +5645,4 @@ crewai = ["crewai"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.15"
content-hash = "5d1af465655fc26c4bfdd9c81edc0760c838e8328ce5ba7c860b18640488d8a4"
content-hash = "f14f92647724856998f774f4626d99a63de8206a3f56862d8a5d67620f712c62"
@@ -0,0 +1,148 @@
"""Tripwire coverage for the partialjson dependency behind predict_state().
`pyproject.toml` allows any partialjson in `>=0.0.8,<2.0.0` (#6123, issue #4131).
The only consumer is `predict_state()` in copilotkit/runloop.py, which parses the
still-incomplete tool-call argument buffer via `JSONParser().parse(...)` inside a
bare `except`. That makes a regression silent: every partialjson failure mode
degrades to "no predicted state was emitted", and nothing else in the suite looks
at this path — disabling `JSONParser.parse` outright left all other tests passing.
These assertions are deliberately version-agnostic. Intermediate frames legitimately
differ across the allowed range (1.1.0 keeps trailing whitespace inside a partially
streamed string where 0.0.8 dropped it), so we pin only the guarantees the range must
keep: a completed payload parses exactly, and a prefix yields a prefix.
"""
import json
from partialjson.json_parser import JSONParser
from copilotkit.protocol import RuntimeEventTypes
from copilotkit.runloop import predict_state
TOOL_NAME = "set_plan"
ARGUMENTS = {
"task": "Write a haiku about the sea",
"steps": ["draft", "revise"],
"done": False,
}
PAYLOAD = json.dumps(ARGUMENTS)
def _execution() -> dict:
"""A CopilotKitRunExecution primed to predict `task` and the whole argument dict."""
return {
"thread_id": "t-1",
"agent_name": "agent",
"run_id": "run-1",
"should_exit": False,
"node_name": "node",
"is_finished": False,
"predict_state_configuration": {
"plan": {"tool_name": TOOL_NAME, "tool_argument": "task"},
"whole": {"tool_name": TOOL_NAME},
},
"predicted_state": {},
"argument_buffer": "",
"current_tool_call": None,
"state": {},
}
def _stream(chunk_size: int) -> tuple[list[dict], dict]:
"""Stream PAYLOAD through predict_state() and collect each predicted state."""
execution = _execution()
predict_state(
thread_id="t-1",
agent_name="agent",
run_id="run-1",
execution=execution,
event={"type": RuntimeEventTypes.ACTION_EXECUTION_START, "actionName": TOOL_NAME},
)
frames = []
for start in range(0, len(PAYLOAD), chunk_size):
message = predict_state(
thread_id="t-1",
agent_name="agent",
run_id="run-1",
execution=execution,
event={
"type": RuntimeEventTypes.ACTION_EXECUTION_ARGS,
"args": PAYLOAD[start : start + chunk_size],
},
)
if message is not None:
frames.append(dict(execution["predicted_state"]))
return frames, execution["predicted_state"]
def test_streaming_arguments_emit_predicted_state():
"""A streamed tool call must produce predicted-state updates, not silence."""
frames, _ = _stream(chunk_size=1)
assert frames, (
"no predicted state was emitted while arguments streamed — partialjson "
"parsed nothing usable from any prefix of the buffer"
)
assert len(frames) > 10, f"expected many incremental frames, got {len(frames)}"
def test_completed_arguments_parse_exactly():
"""Once the buffer is complete the prediction must equal the real arguments."""
for chunk_size in (1, 3, 7, 20):
_, predicted = _stream(chunk_size)
assert predicted["whole"] == ARGUMENTS, f"chunk_size={chunk_size}"
assert predicted["plan"] == ARGUMENTS["task"], f"chunk_size={chunk_size}"
def test_intermediate_predictions_are_prefixes_of_the_final_value():
"""Every partial value must be a prefix of the finished string, never garbage."""
frames, predicted = _stream(chunk_size=1)
final = predicted["plan"]
for frame in frames:
partial = frame.get("plan")
if partial is None:
continue
assert isinstance(partial, str), f"expected a string, got {partial!r}"
# Trailing whitespace handling differs across the allowed range, so compare
# on the stripped prefix rather than the raw frame.
assert final.startswith(partial.rstrip()), f"{partial!r} is not a prefix of {final!r}"
def test_unterminated_escape_does_not_escape_predict_state():
"""Prefixes that older partialjson rejects must stay contained by the bare except."""
execution = _execution()
predict_state(
thread_id="t-1",
agent_name="agent",
run_id="run-1",
execution=execution,
event={"type": RuntimeEventTypes.ACTION_EXECUTION_START, "actionName": TOOL_NAME},
)
# 0.0.8 raises JSONDecodeError here; 1.x parses it. Either way predict_state()
# must not propagate the failure to the run loop.
predict_state(
thread_id="t-1",
agent_name="agent",
run_id="run-1",
execution=execution,
event={"type": RuntimeEventTypes.ACTION_EXECUTION_ARGS, "args": '{"task": "line\\'},
)
def test_partialjson_api_contract():
"""The API predict_state() depends on, asserted directly against the dependency.
This is the tripwire for a future release inside `>=0.0.8,<2.0.0`: the range
admits versions that do not exist yet, and this is what notices if one of them
changes the constructor, the method, or the parse of a truncated object.
"""
parser = JSONParser()
assert parser.parse(PAYLOAD) == ARGUMENTS
assert parser.parse('{"task": "wri') == {"task": "wri"}
assert parser.parse('{"steps": ["draft"') == {"steps": ["draft"]}
assert parser.parse("") == {}