Files
nisse/nisse-core/tests/test_render.py
T
dongarandClaude Opus 5.5 e351d0a74e
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m37s
CI / e2e (pull_request) Successful in 4m5s
nisse-core: group modules into packages by architecture section
A pure move; behaviour unchanged.

- models (L9, L7): llm, catalog, probe, render, render_anthropic, context
- log (L7, L6): canonical, views
- state (L8): store (was state), reconcile, worker, cancel, dump
- knowledge (L10, L6.4): vault, memory
- conversation (L5): transport, matrix
- execution (L13): ssh (was execution), testing
- authority (P3, P4, L4): actions
- gitea, oplog, envfile stay at the top until each gains a second module.

Every import, monkeypatch target, script and doc reference rewritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-13 21:16:09 +00:00

152 lines
5.8 KiB
Python

import json
from itertools import pairwise
from nisse_core.log.canonical import (
AssistantText,
Compaction,
GateOpened,
StoredEvent,
ToolCall,
ToolResult,
UserMessage,
)
from nisse_core.models.render import OpenAIChatRenderer, effective_events
def stored(events: list) -> list[StoredEvent]:
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
def test_renders_mixed_transcript_in_order() -> None:
events = stored(
[
UserMessage(text="run the tests", source="m:1"),
ToolCall(call_id="call-9", tool="run_tests", arguments={"path": "x"}),
ToolResult(call_id="call-9", result="1 passed", success=True),
AssistantText(text="all green", model="local/qwen"),
]
)
body = OpenAIChatRenderer().render(events, tools=None, model="local/qwen")
assert body["model"] == "local/qwen"
roles = [m["role"] for m in body["messages"]]
assert roles == ["user", "assistant", "tool", "assistant"]
call = body["messages"][1]["tool_calls"][0]
assert call["id"] == "call-9"
assert call["function"]["name"] == "run_tests"
assert json.loads(call["function"]["arguments"]) == {"path": "x"}
assert body["messages"][2]["tool_call_id"] == "call-9"
assert "tools" not in body
def test_system_prompt_and_tools_pass_through() -> None:
tools = [{"type": "function", "function": {"name": "run_tests"}}]
body = OpenAIChatRenderer().render(
stored([UserMessage(text="hi", source="m:1")]),
tools=tools,
model="local/qwen",
system="you are nisse-dev",
)
assert body["messages"][0] == {"role": "system", "content": "you are nisse-dev"}
assert body["tools"] == tools
def test_compaction_replaces_its_range_only() -> None:
events = stored(
[
UserMessage(text="old-1", source="m:1"),
AssistantText(text="old-2", model="q"),
UserMessage(text="kept", source="m:3"),
]
)
events.append(
StoredEvent(
id=4,
ts="t",
event=Compaction(summary="the old stuff", model="q", replaces=[1, 2]),
)
)
remaining = effective_events(events)
assert [e.id for e in remaining] == [3, 4]
body = OpenAIChatRenderer().render(events, tools=None, model="q")
contents = [m["content"] for m in body["messages"]]
# Both render as user messages, so they share one: roles must alternate.
assert contents == ["kept\n\n[summary of earlier conversation]\nthe old stuff"]
def test_tool_call_without_a_result_gets_a_placeholder() -> None:
# Providers reject a dangling tool_use; this happens after a crash (L8)
# or with turn-ending gate tools like submit_plan.
events = stored(
[
UserMessage(text="go", source="m:1"),
ToolCall(call_id="call-x", tool="submit_plan", arguments={}),
]
)
body = OpenAIChatRenderer().render(events, tools=None, model="q")
assert [m["role"] for m in body["messages"]] == ["user", "assistant", "tool"]
assert body["messages"][2]["tool_call_id"] == "call-x"
assert body["messages"][2]["content"] == "[no result recorded]"
def test_answered_tool_call_gets_no_placeholder() -> None:
events = stored(
[
ToolCall(call_id="call-x", tool="run_command", arguments={}),
ToolResult(call_id="call-x", result="ok", success=True),
]
)
body = OpenAIChatRenderer().render(events, tools=None, model="q")
assert [m["role"] for m in body["messages"]] == ["assistant", "tool"]
assert body["messages"][1]["content"] == "ok"
def test_gate_event_renders_as_context() -> None:
events = stored([GateOpened(gate="arch", reason="plan submitted (rev-1)")])
body = OpenAIChatRenderer().render(events, tools=None, model="q")
assert body["messages"][0]["role"] == "user"
assert "arch" in body["messages"][0]["content"]
def _roles(body: dict) -> list[str]:
return [m["role"] for m in body["messages"]]
def test_one_turn_is_one_assistant_message() -> None:
# Text and two parallel tool calls from one model turn: the OpenAI format
# carries them as a single assistant message with a tool_calls list.
events = stored(
[
UserMessage(text="go", source="m:1"),
AssistantText(text="Looking around first.", model="q"),
ToolCall(call_id="a", tool="list_dir", arguments={"path": "."}),
ToolCall(call_id="b", tool="read_file", arguments={"path": "README.md"}),
ToolResult(call_id="a", result="README.md", success=True),
ToolResult(call_id="b", result="hello", success=True),
]
)
body = OpenAIChatRenderer().render(events, tools=None, model="q")
assert _roles(body) == ["user", "assistant", "tool", "tool"]
turn = body["messages"][1]
assert turn["content"] == "Looking around first."
assert [c["id"] for c in turn["tool_calls"]] == ["a", "b"]
def test_roles_never_repeat_for_strict_templates() -> None:
"""Mistral's chat template raises on two user or two assistant messages
in a row; every mix of events must render without one."""
events = stored(
[
UserMessage(text="one", source="m:1"),
UserMessage(text="two", source="m:2"),
AssistantText(text="thinking", model="q"),
ToolCall(call_id="a", tool="list_dir", arguments={}),
ToolResult(call_id="a", result="x", success=True),
AssistantText(text="more", model="q"),
AssistantText(text="and more", model="q"),
ToolCall(call_id="b", tool="finish", arguments={}),
]
)
roles = _roles(OpenAIChatRenderer().render(events, tools=None, model="q"))
for first, second in pairwise(roles):
assert not (first == second and first in ("user", "assistant")), roles