Files
openworker/tests/test_engine.py
T
Buddhika GodakandaandClaude d9fbb0790b Recover truncated tool calls, and never pass a leaked one off as an answer
Small local models drift off the tool-call format, especially with a large
tool schema in play. Two failures followed from that, both of which ended
the turn looking like the model had simply stopped mid-sentence.

Salvage handled well-formed Qwen/Hermes XML but gave up entirely on a call
cut off partway through. It now takes the function name plus every
parameter that actually closed. A trailing unterminated `<parameter=…>` is
dropped rather than guessed, so a half-written path or file body can never
reach a tool; if that leaves a required argument missing, the call fails
validation and the model gets a corrective tool error, which is the agent
loop working.

Worse, a call that never parsed at all was reported as `status: completed`
— indistinguishable from the model deciding it was done, leaving the user
with narration trailing off into stray closing tags. It now ends on the
error path, so the GUI offers Retry. That is the right affordance here: the
drift is probabilistic, not deterministic, so the same model usually
succeeds on a second attempt.

Detection ignores fenced and inline code, so a model *explaining* tool-call
syntax is still a real answer, and requires that tools were offered at all.

Reported against qwen3.5-9b on LM Studio, 2026-07-26.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 05:50:54 +05:30

478 lines
16 KiB
Python

"""P2 gate tests — turn engine + event bus (scripted provider, no network)."""
from __future__ import annotations
import asyncio
import threading
import time
import aisuite as ai
from coworker.engine import ApprovalOutcome, PermissionRequest, TurnEngine
from coworker.events import EventType
from coworker.permissions import PermissionEngine
from coworker.providers import (
AssistantTurn,
ModelCapabilities,
ProviderClient,
StreamChunk,
ToolCall,
)
from coworker.tools import ToolRegistry
def _text_turn(text):
return AssistantTurn(text=text, finish_reason="stop")
def _tool_turn(name, args, call_id="call_1"):
return AssistantTurn(
tool_calls=[ToolCall(id=call_id, name=name, arguments=args)],
finish_reason="tool_calls",
)
class ScriptedProvider(ProviderClient):
"""Returns queued AssistantTurns; streams via the base default (one final chunk)."""
def __init__(self, turns, *, loop=False):
self._turns = list(turns)
self._loop = loop
self.calls = 0
def complete(self, *, model, messages, tools=None, **settings):
self.calls += 1
return self._turns[0] if self._loop else self._turns.pop(0)
def capabilities(self, model):
return ModelCapabilities()
def _engine(tmp_path, turns, *, approver=None, loop=False, max_iterations=12):
provider = ScriptedProvider(turns, loop=loop)
registry = ToolRegistry()
registry.register_all(ai.toolkits.files(root=str(tmp_path), allow_write=True))
permissions = PermissionEngine(workspace_root=tmp_path)
engine = TurnEngine(
provider=provider,
registry=registry,
permissions=permissions,
model="gpt-5.5",
approver=approver,
max_iterations=max_iterations,
)
return engine, provider
def _collect(engine, user_input):
async def _run():
return [ev async for ev in engine.run(user_input)]
return asyncio.run(_run())
def _types(events):
return [ev.type for ev in events]
# -- tests ----------------------------------------------------------------------
def test_no_tool_turn(tmp_path):
engine, _ = _engine(tmp_path, [_text_turn("all done")])
events = _collect(engine, "hi")
assert _types(events) == [
EventType.TURN_START,
EventType.ASSISTANT_MESSAGE,
EventType.TURN_END,
]
assert events[1].data["text"] == "all done"
assert events[-1].data["status"] == "completed"
def test_tool_turn_order_and_execution(tmp_path):
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
engine, _ = _engine(
tmp_path,
[_tool_turn("read_file", {"path": "a.txt"}), _text_turn("it says hello")],
)
events = _collect(engine, "read a.txt")
assert EventType.PERMISSION_REQUIRED not in _types(events)
assert _types(events) == [
EventType.TURN_START,
EventType.ASSISTANT_MESSAGE,
EventType.TOOL_PROPOSED,
EventType.TOOL_STARTED,
EventType.TOOL_FINISHED,
EventType.ITERATION_END,
EventType.ASSISTANT_MESSAGE,
EventType.TURN_END,
]
finished = next(e for e in events if e.type == EventType.TOOL_FINISHED)
assert finished.data["status"] == "ok"
assert any(
m.get("role") == "tool" and "hello" in m["content"] for m in engine.messages
)
def test_write_requires_approval_then_approved(tmp_path):
async def approve_once(_req: PermissionRequest):
return ApprovalOutcome.ONCE
engine, _ = _engine(
tmp_path,
[
_tool_turn("write_file", {"path": "new.py", "content": "print(1)\n"}),
_text_turn("wrote new.py"),
],
approver=approve_once,
)
events = _collect(engine, "create new.py")
assert EventType.PERMISSION_REQUIRED in _types(events)
assert (tmp_path / "new.py").read_text() == "print(1)\n"
def test_denied_tool_yields_error_and_continues(tmp_path):
async def deny(_req: PermissionRequest):
return ApprovalOutcome.DENY
engine, _ = _engine(
tmp_path,
[
_tool_turn("write_file", {"path": "new.py", "content": "x"}),
_text_turn("ok, skipped it"),
],
approver=deny,
)
events = _collect(engine, "create new.py")
assert not (tmp_path / "new.py").exists()
finished = next(e for e in events if e.type == EventType.TOOL_FINISHED)
assert finished.data["status"] == "denied"
assert _types(events)[-1] == EventType.TURN_END
assert any(
m.get("role") == "tool" and "not executed" in m["content"]
for m in engine.messages
)
def test_max_iterations_rail(tmp_path):
engine, provider = _engine(
tmp_path, [_tool_turn("list_files", {})], loop=True, max_iterations=3
)
events = _collect(engine, "loop forever")
end = events[-1]
assert end.type == EventType.TURN_END
assert end.data["status"] == "max_iterations_exceeded"
assert provider.calls == 3
def test_interrupt_between_iterations(tmp_path):
engine_holder = {}
async def approve_and_interrupt(_req: PermissionRequest):
engine_holder["engine"].request_interrupt()
return ApprovalOutcome.ONCE
engine, provider = _engine(
tmp_path,
[
_tool_turn("write_file", {"path": "x.py", "content": "x"}),
_text_turn("should not be reached"),
],
approver=approve_and_interrupt,
)
engine_holder["engine"] = engine
events = _collect(engine, "do a thing")
assert events[-1].type == EventType.INTERRUPTED
assert provider.calls == 1
def test_steering_injects_next_turn(tmp_path):
engine, provider = _engine(tmp_path, [_text_turn("first"), _text_turn("second")])
engine.queue_steering("actually, also do this")
events = _collect(engine, "do the first thing")
assert provider.calls == 2
assert any(
m.get("role") == "user" and m["content"] == "actually, also do this"
for m in engine.messages
)
assert events[-1].data["status"] == "completed"
# -- parallel tool execution ------------------------------------------------------
def _multi_tool_turn(calls):
return AssistantTurn(
tool_calls=[
ToolCall(id=f"call_{i}", name=name, arguments=args)
for i, (name, args) in enumerate(calls)
],
finish_reason="tool_calls",
)
def _bare_engine(tmp_path, turns):
provider = ScriptedProvider(turns)
registry = ToolRegistry()
permissions = PermissionEngine(workspace_root=tmp_path)
engine = TurnEngine(
provider=provider,
registry=registry,
permissions=permissions,
model="gpt-5.5",
)
return engine, registry
def test_low_risk_tool_calls_run_concurrently(tmp_path):
# Both tools block on a 2-party barrier: the turn only completes if the engine
# really runs them at the same time (sequential execution would trip the timeout
# and surface as an error result).
barrier = threading.Barrier(2, timeout=5)
low = ai.ToolMetadata(category="search", risk_level="low", requires_approval=False)
def side_a():
"""Wait for side_b."""
barrier.wait()
return {"side": "a"}
def side_b():
"""Wait for side_a."""
barrier.wait()
return {"side": "b"}
engine, registry = _bare_engine(
tmp_path,
[_multi_tool_turn([("side_a", {}), ("side_b", {})]), _text_turn("done")],
)
registry.register(side_a, metadata=low)
registry.register(side_b, metadata=low)
events = _collect(engine, "go")
finished = [e for e in events if e.type == EventType.TOOL_FINISHED]
assert len(finished) == 2
assert all(e.data["status"] == "ok" for e in finished)
# a tool result message exists for every call id
tool_ids = {
m.get("tool_call_id") for m in engine.messages if m.get("role") == "tool"
}
assert tool_ids == {"call_0", "call_1"}
def test_non_low_risk_tool_calls_stay_sequential(tmp_path):
order = []
medium = ai.ToolMetadata(
category="filesystem", risk_level="medium", requires_approval=False
)
def first():
"""Record start/end with a delay."""
order.append("first-start")
time.sleep(0.2)
order.append("first-end")
return "ok"
def second():
"""Record start/end."""
order.append("second-start")
order.append("second-end")
return "ok"
engine, registry = _bare_engine(
tmp_path,
[_multi_tool_turn([("first", {}), ("second", {})]), _text_turn("done")],
)
registry.register(first, metadata=medium)
registry.register(second, metadata=medium)
_collect(engine, "go")
assert order == ["first-start", "first-end", "second-start", "second-end"]
class StreamingProvider(ProviderClient):
def complete(self, **kwargs): # pragma: no cover - streamed instead
raise NotImplementedError
def capabilities(self, model):
return ModelCapabilities()
def stream(self, *, model, messages, tools=None, **settings):
for piece in ["Hel", "lo, ", "world"]:
yield StreamChunk(text_delta=piece)
yield StreamChunk(turn=AssistantTurn(text="Hello, world", finish_reason="stop"))
def test_streaming_emits_deltas(tmp_path):
registry = ToolRegistry()
permissions = PermissionEngine(workspace_root=tmp_path)
engine = TurnEngine(
provider=StreamingProvider(),
registry=registry,
permissions=permissions,
model="gpt-5.5",
)
events = _collect(engine, "say hi")
deltas = [e.data["text"] for e in events if e.type == EventType.ASSISTANT_DELTA]
assert deltas == ["Hel", "lo, ", "world"]
final = next(e for e in events if e.type == EventType.ASSISTANT_MESSAGE)
assert final.data["text"] == "Hello, world"
assert events[-1].type == EventType.TURN_END
def _pdf_file_part():
import base64
import io
from pypdf import PdfWriter
writer = PdfWriter()
writer.add_blank_page(width=100, height=100)
buf = io.BytesIO()
writer.write(buf)
url = "data:application/pdf;base64," + base64.b64encode(buf.getvalue()).decode()
return {"type": "file", "file": {"filename": "d.pdf", "file_data": url}}
def test_outbound_adapts_pdf_for_non_pdf_models(tmp_path):
# ScriptedProvider reports default caps (pdf=False) → the file part must be
# replaced at send time while the stored history keeps the real document.
engine, _ = _engine(tmp_path, [_text_turn("ok")])
engine.messages.append(
{
"role": "user",
"content": [{"type": "text", "text": "read this"}, _pdf_file_part()],
}
)
parts = engine._outbound_messages()[-1]["content"]
assert all(p["type"] != "file" for p in parts)
assert "d.pdf" in parts[-1]["text"]
assert engine.messages[-1]["content"][1]["type"] == "file" # history untouched
def test_outbound_keeps_pdf_for_native_models(tmp_path):
class NativeProvider(ScriptedProvider):
def capabilities(self, model):
return ModelCapabilities(vision=True, pdf=True)
engine, _ = _engine(tmp_path, [_text_turn("ok")])
engine.provider = NativeProvider([_text_turn("ok")])
message = {
"role": "user",
"content": [{"type": "text", "text": "read this"}, _pdf_file_part()],
}
engine.messages.append(message)
assert engine._outbound_messages()[-1]["content"][1]["type"] == "file"
def test_provider_extras_persist_on_message_and_survive_outbound(tmp_path):
"""A turn's provider-private sidecar (`extras`, e.g. Gemini thought signatures) rides
the persisted assistant message and is NOT stripped by _outbound_messages — the owning
provider needs it back; foreign providers strip it themselves."""
turn = AssistantTurn(
text="ok",
finish_reason="stop",
extras={"_gemini": {"text_sig": "c2ln", "call_sigs": []}},
)
engine, _ = _engine(tmp_path, [turn])
_collect(engine, "hi")
persisted = engine.messages[-1]
assert persisted["_gemini"] == {"text_sig": "c2ln", "call_sigs": []}
outbound = engine._outbound_messages()[-1]
assert outbound["_gemini"] == {"text_sig": "c2ln", "call_sigs": []}
assert "ts" not in outbound # display sidecars still stripped
def test_switch_model_appends_notice_only_midsession(tmp_path):
engine, _ = _engine(tmp_path, [_text_turn("ok")])
# Fresh session: first bind is silent.
assert engine.switch_model("zai:glm-5.2") is None
assert engine.model == "zai:glm-5.2"
_collect(engine, "hi")
# Same model: no-op.
assert engine.switch_model("zai:glm-5.2") is None
# Real mid-session switch: persisted marker with the matrix label.
text = engine.switch_model("kimi:kimi-k2.6")
assert "Kimi K2.6" in text and engine.model == "kimi:kimi-k2.6"
notice = engine.messages[-1]
assert notice["role"] == "notice" and notice["kind"] == "model_switch"
assert all(m.get("role") != "notice" for m in engine._outbound_messages())
def test_switch_model_warns_when_images_meet_text_only_model(tmp_path):
class NoVisionProvider(ScriptedProvider):
def capabilities(self, model):
return ModelCapabilities(vision=False)
engine, _ = _engine(tmp_path, [_text_turn("ok")])
engine.provider = NoVisionProvider([_text_turn("ok")])
engine.messages.append(
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
],
}
)
text = engine.switch_model("zai:glm-5.2")
assert "images" in text # degradation is called out in the marker
def test_outbound_replaces_images_for_non_vision_models(tmp_path):
class NoVisionProvider(ScriptedProvider):
def capabilities(self, model):
return ModelCapabilities(vision=False)
engine, _ = _engine(tmp_path, [_text_turn("ok")])
engine.provider = NoVisionProvider([_text_turn("ok")])
engine.messages.append(
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
],
}
)
parts = engine._outbound_messages()[-1]["content"]
assert all(p["type"] != "image_url" for p in parts)
assert "not viewable" in parts[-1]["text"]
assert engine.messages[-1]["content"][1]["type"] == "image_url" # history untouched
def test_leaked_tool_call_ends_the_turn_as_a_retriable_error(tmp_path):
"""A tool call the endpoint couldn't parse must not pass as an answer. Ending "completed"
made a half-written call indistinguishable from the model deciding it was done — the user
saw narration trailing off into stray tags (owner report 2026-07-26, qwen3.5-9b on LM
Studio). It ends on the error path so the GUI offers Retry; the drift is probabilistic, so
retrying the same model usually works."""
leaked = "Let me read the key files.\n<tool_call>\n<function=nope_not_a_tool>\n<parameter="
engine, _ = _engine(tmp_path, [_text_turn(leaked)])
events = _collect(engine, "explore the codebase")
assert EventType.ERROR in _types(events)
assert EventType.TURN_END not in _types(events)
err = next(ev for ev in events if ev.type == EventType.ERROR)
assert err.data["error_type"] == "UnparsedToolCall"
assert "couldn't parse" in err.data["error"]
# Persisted as an error notice, which is what unlocks retry().
assert engine.messages[-1] == {
**engine.messages[-1],
"role": "notice",
"kind": "error",
}
assert engine._tail_is_retriable_error() is True
def test_ordinary_text_answer_still_completes(tmp_path):
"""Guard the other side: prose that merely mentions tool syntax inside code fences is a
real answer and must still complete normally."""
engine, _ = _engine(
tmp_path,
[_text_turn("Qwen writes calls like:\n```\n<tool_call><function=x>\n```\nThat's it.")],
)
events = _collect(engine, "how does qwen format tool calls?")
assert EventType.ERROR not in _types(events)
assert next(ev for ev in events if ev.type == EventType.TURN_END).data["status"] == "completed"