Files
nisse/nisse-dev/tests/model_contract.py
T
dongarandClaude Opus 5.5 154a0c3fa8
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m42s
CI / e2e (pull_request) Successful in 4m0s
M7.1: extract nisse-core
What isn't the dev agent's moves to nisse-core, unchanged in behaviour,
with its tests: canonical, render, render_anthropic, llm, catalog, probe,
context, views, state, reconcile, worker, cancel, dump, transport, matrix,
execution, testing, vault, memory, oplog, envfile and gitea (whole).

- nisse_core.state.StateStore holds the tables every agent has; an
  agent's subclass adds its tables (version, migrations), roles and
  views. nisse_dev.state.StateStore adds the build VM blocks (v5).
- views.project takes what each role is shown, rather than a fixed table.
- Vault takes the agent's name (agent=), for provenance.
- A test that nisse_core never imports an agent.
- The loop, config, git, delegation, expert, tools and evals stay in
  nisse-dev until nisse-ops has a loop to share.

Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on
the agent host and Terraform; Architecture Part L names the package.

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

66 lines
2.8 KiB
Python

"""What a real model API insists on, for scripted test models.
Both formats the agent sends require a tool call's result to come right
after it: in Anthropic's, the next message is the user's and starts with a
tool_result for every tool_use, before any text; in OpenAI's, the assistant
message with tool_calls is followed directly by a tool message for each. A
scripted model that doesn't check this lets a broken transcript pass the
unit tests and be rejected live; this makes it fail here.
"""
from typing import Any
from nisse_core.render import OpenAIChatRenderer
from nisse_core.render_anthropic import AnthropicMessagesRenderer
def assert_renderable(events: list[Any]) -> None:
_anthropic(AnthropicMessagesRenderer().render(list(events), None, "m")["messages"])
_openai(OpenAIChatRenderer().render(list(events), None, "m")["messages"])
def _anthropic(messages: list[dict[str, Any]]) -> None:
# A request ends on the user's side: current models refuse assistant
# prefill ("The conversation must end with a user message"), found live
# on 2026-10-07 when the driver's prose nudge left its own reply last.
assert not messages or messages[-1]["role"] == "user", (
f"anthropic messages.{len(messages) - 1}: the conversation ends with an "
"assistant message (prefill), which the API refuses"
)
for index, message in enumerate(messages):
if message["role"] != "assistant":
continue
used = [b["id"] for b in message["content"] if b.get("type") == "tool_use"]
if not used:
continue
following = messages[index + 1] if index + 1 < len(messages) else None
assert following is not None and following["role"] == "user", (
f"anthropic messages.{index}: tool_use with no user message after it"
)
blocks = following["content"]
leading = []
for block in blocks:
if block.get("type") != "tool_result":
break
leading.append(block["tool_use_id"])
assert set(used) <= set(leading), (
f"anthropic messages.{index}: tool_use ids {sorted(set(used) - set(leading))} "
"lack a tool_result at the start of the next message"
)
def _openai(messages: list[dict[str, Any]]) -> None:
for index, message in enumerate(messages):
calls = [c["id"] for c in message.get("tool_calls") or []]
if not calls:
continue
answered = []
for after in messages[index + 1 :]:
if after["role"] != "tool":
break
answered.append(after["tool_call_id"])
assert set(calls) <= set(answered), (
f"openai messages.{index}: tool_calls {sorted(set(calls) - set(answered))} "
"are not answered by the tool messages right after them"
)