3819 lines
145 KiB
Python
3819 lines
145 KiB
Python
"""Tests for PiExecutor."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import tempfile
|
|
import textwrap
|
|
import unittest
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
from omnigent.inner.executor import (
|
|
ExecutorConfig,
|
|
ExecutorError,
|
|
ReasoningChunk,
|
|
TextChunk,
|
|
ToolCallComplete,
|
|
ToolCallRequest,
|
|
ToolCallStatus,
|
|
TurnComplete,
|
|
)
|
|
from omnigent.inner.pi_executor import (
|
|
PiExecutor,
|
|
_build_models_json,
|
|
_generate_extension_js,
|
|
_pi_provider_for_model,
|
|
_PiRpcSession,
|
|
_redact_argv_for_log,
|
|
_safe_dumps,
|
|
_sanitize_schema,
|
|
_split_pi_prompt,
|
|
_ToolServer,
|
|
)
|
|
from omnigent.onboarding.databricks_config import DATABRICKS_CLAUDE_DEFAULT_MODEL
|
|
from omnigent.runtime.harnesses._scaffold import PolicyVerdictPayload
|
|
|
|
|
|
def _run(coro):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
loop.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper fakes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeStreamReader:
|
|
"""Simulates asyncio.StreamReader with pre-loaded lines."""
|
|
|
|
def __init__(self, lines: list[bytes]):
|
|
self._buffer = bytearray(b"".join(lines))
|
|
|
|
async def readline(self) -> bytes:
|
|
if not self._buffer:
|
|
return b""
|
|
newline_index = self._buffer.find(b"\n")
|
|
if newline_index >= 0:
|
|
end = newline_index + 1
|
|
line = bytes(self._buffer[:end])
|
|
del self._buffer[:end]
|
|
return line
|
|
line = bytes(self._buffer)
|
|
self._buffer.clear()
|
|
return line
|
|
|
|
async def read(self, n: int = -1) -> bytes:
|
|
if not self._buffer:
|
|
return b""
|
|
if n is None or n < 0 or n > len(self._buffer):
|
|
n = len(self._buffer)
|
|
chunk = bytes(self._buffer[:n])
|
|
del self._buffer[:n]
|
|
return chunk
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
line = await self.readline()
|
|
if not line:
|
|
raise StopAsyncIteration
|
|
return line
|
|
|
|
|
|
class _FakeStreamWriter:
|
|
def __init__(self):
|
|
self.data: list[bytes] = []
|
|
self._closed = False
|
|
|
|
def write(self, data: bytes):
|
|
self.data.append(data)
|
|
|
|
async def drain(self):
|
|
pass
|
|
|
|
def close(self):
|
|
self._closed = True
|
|
|
|
async def wait_closed(self):
|
|
pass
|
|
|
|
|
|
class _FakeProcess:
|
|
def __init__(
|
|
self, stdout_lines: list[str] | None = None, stderr_lines: list[str] | None = None
|
|
):
|
|
stdout_bytes = [(line + "\n").encode() for line in (stdout_lines or [])]
|
|
stderr_bytes = [(line + "\n").encode() for line in (stderr_lines or [])]
|
|
self.stdin = _FakeStreamWriter()
|
|
self.stdout = _FakeStreamReader(stdout_bytes)
|
|
self.stderr = _FakeStreamReader(stderr_bytes)
|
|
self.returncode = None
|
|
self.pid = 99999
|
|
|
|
def terminate(self):
|
|
self.returncode = 0
|
|
|
|
def kill(self):
|
|
self.returncode = -9
|
|
|
|
async def wait(self):
|
|
return self.returncode or 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _sanitize_schema tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSanitizeSchema(unittest.TestCase):
|
|
def test_removes_examples_and_default(self):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"a": {"type": "string", "examples": ["foo"], "default": "bar"},
|
|
},
|
|
}
|
|
result = _sanitize_schema(schema)
|
|
self.assertNotIn("examples", result["properties"]["a"])
|
|
self.assertNotIn("default", result["properties"]["a"])
|
|
|
|
def test_collapses_anyof_to_first_typed(self):
|
|
schema = {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{"type": "integer"},
|
|
]
|
|
}
|
|
result = _sanitize_schema(schema)
|
|
self.assertEqual(result, {"type": "string"})
|
|
|
|
def test_removes_additional_properties(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"x": {"type": "integer"}},
|
|
}
|
|
result = _sanitize_schema(schema)
|
|
self.assertNotIn("additionalProperties", result)
|
|
|
|
def test_nested_properties_are_sanitized(self):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"nested": {
|
|
"type": "object",
|
|
"properties": {
|
|
"inner": {"type": "string", "default": "d"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result = _sanitize_schema(schema)
|
|
self.assertNotIn("default", result["properties"]["nested"]["properties"]["inner"])
|
|
|
|
def test_items_are_sanitized(self):
|
|
schema = {
|
|
"type": "array",
|
|
"items": {"type": "string", "examples": ["a"]},
|
|
}
|
|
result = _sanitize_schema(schema)
|
|
self.assertNotIn("examples", result["items"])
|
|
|
|
def test_passthrough_for_non_dict(self):
|
|
self.assertEqual(_sanitize_schema("hello"), "hello")
|
|
|
|
|
|
@pytest.mark.parametrize("union_key", ["anyOf", "oneOf", "allOf"])
|
|
def test_sanitize_union_prefers_object_branch(union_key: str) -> None:
|
|
"""
|
|
A union with both a string and an object branch must collapse to
|
|
the OBJECT branch, with its properties intact and its own
|
|
``additionalProperties`` stripped.
|
|
|
|
If the string branch wins instead, the pi LLM sees the param as a
|
|
plain string and serializes structured args as a JSON string —
|
|
this is exactly how nessie's purpose-guard policy ended up
|
|
denying every sub-agent dispatch on pi.
|
|
|
|
:param union_key: The JSON Schema union keyword under test,
|
|
e.g. ``"anyOf"`` — all three must collapse identically.
|
|
"""
|
|
schema = {
|
|
union_key: [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"input": {"type": "string"},
|
|
"purpose": {"type": "string"},
|
|
},
|
|
"required": ["input"],
|
|
"additionalProperties": False,
|
|
},
|
|
]
|
|
}
|
|
# Exact dict: object branch chosen, properties/required preserved,
|
|
# additionalProperties stripped. {"type": "string"} here means the
|
|
# collapse regressed to first-typed-branch.
|
|
assert _sanitize_schema(schema) == {
|
|
"type": "object",
|
|
"properties": {
|
|
"input": {"type": "string"},
|
|
"purpose": {"type": "string"},
|
|
},
|
|
"required": ["input"],
|
|
}
|
|
|
|
|
|
def test_sanitize_union_without_object_falls_back_to_first_typed() -> None:
|
|
"""
|
|
With no object branch, the collapse falls back to the FIRST typed
|
|
branch, skipping untyped entries.
|
|
|
|
``{"type": "string"}`` here would mean ordering broke;
|
|
``{"description": ...}`` would mean an untyped branch was chosen.
|
|
"""
|
|
schema = {
|
|
"anyOf": [
|
|
{"description": "untyped branch"},
|
|
{"type": "integer"},
|
|
{"type": "string"},
|
|
]
|
|
}
|
|
assert _sanitize_schema(schema) == {"type": "integer"}
|
|
|
|
|
|
def test_sanitize_union_nested_in_properties_keeps_object_branch() -> None:
|
|
"""
|
|
A union nested inside an outer object's ``properties`` collapses
|
|
to its object branch while sibling properties pass through
|
|
untouched — the recursion into ``properties`` must apply the same
|
|
object-preference as the top level.
|
|
"""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"args": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{
|
|
"type": "object",
|
|
"properties": {"input": {"type": "string"}},
|
|
"required": ["input"],
|
|
"additionalProperties": False,
|
|
},
|
|
]
|
|
},
|
|
"other": {"type": "integer"},
|
|
},
|
|
"required": ["args"],
|
|
}
|
|
assert _sanitize_schema(schema) == {
|
|
"type": "object",
|
|
"properties": {
|
|
"args": {
|
|
"type": "object",
|
|
"properties": {"input": {"type": "string"}},
|
|
"required": ["input"],
|
|
},
|
|
"other": {"type": "integer"},
|
|
},
|
|
"required": ["args"],
|
|
}
|
|
|
|
|
|
def test_sanitize_real_sys_session_send_args_collapses_to_object() -> None:
|
|
"""
|
|
The REAL ``sys_session_send`` schema's ``args`` param (anyOf of
|
|
string | {input, purpose} object) must collapse to the object
|
|
branch so the model emits structured args.
|
|
|
|
Uses the actual schema builder from spawn.py, not a copy — if the
|
|
spawn schema's shape drifts, this test follows it. A string-typed
|
|
``args`` result reproduces the nessie-on-pi dispatch denial
|
|
("Missing object args with purpose").
|
|
"""
|
|
from omnigent.tools.builtins.spawn import _build_sys_session_send_schema
|
|
|
|
params = _build_sys_session_send_schema({})["function"]["parameters"]
|
|
object_branch = next(
|
|
b for b in params["properties"]["args"]["anyOf"] if b.get("type") == "object"
|
|
)
|
|
|
|
sanitized_args = _sanitize_schema(params)["properties"]["args"]
|
|
|
|
# Structured fields the purpose guard and the per-dispatch model
|
|
# override read must survive the collapse.
|
|
assert sanitized_args["type"] == "object"
|
|
assert set(sanitized_args["properties"]) == {
|
|
"input",
|
|
"purpose",
|
|
"model",
|
|
"file_ids",
|
|
"cost_budget",
|
|
}
|
|
assert sanitized_args["required"] == ["input"]
|
|
# Exact dict: the chosen object branch minus its stripped
|
|
# additionalProperties — anything else means extra keys leaked or
|
|
# the wrong branch was picked.
|
|
expected = {k: v for k, v in object_branch.items() if k != "additionalProperties"}
|
|
assert sanitized_args == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _pi_provider_for_model tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPiProviderForModel(unittest.TestCase):
|
|
def test_gpt_model(self):
|
|
self.assertEqual(_pi_provider_for_model("databricks-gpt-5-4-mini"), "databricks")
|
|
|
|
def test_claude_model(self):
|
|
self.assertEqual(
|
|
_pi_provider_for_model("databricks-claude-sonnet-4-6"), "databricks-anthropic"
|
|
)
|
|
|
|
def test_other_model(self):
|
|
self.assertEqual(
|
|
_pi_provider_for_model("databricks-meta-llama-3.3-70b-instruct"),
|
|
"databricks-completions",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _split_pi_prompt tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_split_pi_prompt_separates_text_and_images():
|
|
# #515: multimodal blocks must go to Pi's native message + images, not be
|
|
# JSON-encoded into the text (which the model reads as a literal blob).
|
|
message, images = _split_pi_prompt(
|
|
[
|
|
{"type": "input_text", "text": "what is in this image?"},
|
|
{"type": "input_image", "image_url": "data:image/png;base64,AAAA"},
|
|
]
|
|
)
|
|
assert message == "what is in this image?"
|
|
assert images == [{"type": "image", "data": "AAAA", "mimeType": "image/png"}]
|
|
|
|
|
|
def test_split_pi_prompt_text_only_has_no_images():
|
|
message, images = _split_pi_prompt([{"type": "input_text", "text": "hi"}])
|
|
assert message == "hi"
|
|
assert images == []
|
|
|
|
|
|
def test_split_pi_prompt_rejects_non_data_uri_image():
|
|
# A non-data-URI input_image (e.g. a file reference) cannot be forwarded
|
|
# inline to Pi, so it raises a clear ValueError -- run_turn catches this and
|
|
# surfaces it as an ExecutorError instead of crashing the turn (#516 review).
|
|
with pytest.raises(ValueError, match="inline data URI"):
|
|
_split_pi_prompt([{"type": "input_image", "image_url": "file-abc123"}])
|
|
|
|
|
|
def test_split_pi_prompt_inlines_text_input_file():
|
|
# #516 review: a text-like input_file is decoded into the message (so the
|
|
# model can read it) rather than dropped or hard-failed — mirroring codex.
|
|
import base64 as _b64
|
|
|
|
payload = _b64.b64encode(b"hello from a file").decode()
|
|
message, images = _split_pi_prompt(
|
|
[
|
|
{"type": "input_text", "text": "summarize:"},
|
|
{"type": "input_file", "file_data": f"data:text/markdown;base64,{payload}"},
|
|
]
|
|
)
|
|
assert message == "summarize:\nhello from a file"
|
|
assert images == []
|
|
|
|
|
|
def test_split_pi_prompt_skips_binary_input_file():
|
|
# A binary input_file (e.g. PDF) can't be inlined as text; it's skipped
|
|
# (with a logged warning), not raised — the turn still runs.
|
|
message, images = _split_pi_prompt(
|
|
[
|
|
{"type": "input_text", "text": "hi"},
|
|
{"type": "input_file", "file_data": "data:application/pdf;base64,JVBERi0x"},
|
|
]
|
|
)
|
|
assert message == "hi"
|
|
assert images == []
|
|
|
|
|
|
def test_split_pi_prompt_rejects_genuinely_unknown_block_type():
|
|
# A block type Pi can't handle at all still fails loudly rather than
|
|
# silently vanishing -> run_turn surfaces it as an ExecutorError.
|
|
with pytest.raises(ValueError, match="Unsupported content block type"):
|
|
_split_pi_prompt([{"type": "input_audio", "audio": "..."}])
|
|
|
|
|
|
# _build_models_json tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildModelsJson(unittest.TestCase):
|
|
def test_has_three_providers(self):
|
|
result = _build_models_json("https://host.example.com", "tok123")
|
|
providers = result["providers"]
|
|
self.assertIn("databricks", providers)
|
|
self.assertIn("databricks-anthropic", providers)
|
|
self.assertIn("databricks-completions", providers)
|
|
|
|
def test_dynamic_model_declared_image_capable(self):
|
|
# #515: a dynamically-registered model must advertise image input, or
|
|
# Pi's transformMessages strips every image block ("model does not
|
|
# support images") before the message reaches the provider.
|
|
model = "databricks-qwen2-5-vl-72b"
|
|
result = _build_models_json("https://host.example.com", "tok", model=model)
|
|
provider = result["providers"][_pi_provider_for_model(model)]
|
|
entry = next(e for e in provider["models"] if e["id"] == model)
|
|
self.assertEqual(entry.get("input"), ["text", "image"])
|
|
|
|
def test_static_model_declared_image_capable(self):
|
|
# #516 review: a STATIC (pre-registered) vision model must also
|
|
# advertise image input. The dynamic-registration append is gated on
|
|
# the model not already being listed, so a default model like GPT-5.4
|
|
# (openai-completions) or Claude would otherwise keep an input-less
|
|
# entry and have its images stripped.
|
|
for model in ("databricks-gpt-5-4", "databricks-claude-opus-4-8"):
|
|
result = _build_models_json("https://host.example.com", "tok", model=model)
|
|
provider = result["providers"][_pi_provider_for_model(model)]
|
|
entry = next(e for e in provider["models"] if e["id"] == model)
|
|
self.assertEqual(entry.get("input"), ["text", "image"], model)
|
|
|
|
def test_base_urls_use_host(self):
|
|
result = _build_models_json("https://host.example.com/", "tok")
|
|
p = result["providers"]
|
|
self.assertTrue(
|
|
p["databricks"]["baseUrl"].startswith("https://host.example.com/serving-endpoints")
|
|
)
|
|
self.assertIn("/anthropic", p["databricks-anthropic"]["baseUrl"])
|
|
|
|
def test_base_urls_can_come_from_ucode_state(self):
|
|
result = _build_models_json(
|
|
"https://host.example.com",
|
|
"tok",
|
|
{
|
|
"claude": "https://host.example.com/ai-gateway/anthropic",
|
|
"openai": "https://host.example.com/ai-gateway/codex/v1",
|
|
},
|
|
)
|
|
p = result["providers"]
|
|
# The ucode ``openai`` value is the Codex Responses gateway; GPT and the
|
|
# catch-all re-route to serving-endpoints, claude keeps its gateway.
|
|
self.assertEqual(
|
|
p["databricks"]["baseUrl"],
|
|
"https://host.example.com/serving-endpoints",
|
|
)
|
|
self.assertEqual(
|
|
p["databricks-anthropic"]["baseUrl"],
|
|
"https://host.example.com/ai-gateway/anthropic",
|
|
)
|
|
self.assertEqual(
|
|
p["databricks-completions"]["baseUrl"],
|
|
"https://host.example.com/serving-endpoints",
|
|
)
|
|
|
|
def test_ucode_codex_gateway_rerouted_off_responses_path(self):
|
|
# The codex gateway 404s /chat/completions, so it must not survive onto
|
|
# a completions provider (#241 GPT 404).
|
|
result = _build_models_json(
|
|
"https://host.example.com",
|
|
"tok",
|
|
{"openai": "https://host.example.com/ai-gateway/codex/v1"},
|
|
)
|
|
for name in ("databricks", "databricks-completions"):
|
|
base_url = result["providers"][name]["baseUrl"]
|
|
self.assertNotIn("/ai-gateway/codex", base_url)
|
|
self.assertEqual(base_url, "https://host.example.com/serving-endpoints")
|
|
|
|
def test_gemini_model_routed_off_codex_gateway(self):
|
|
# Gemini falls to the databricks-completions catch-all; it must land on
|
|
# serving-endpoints, not the codex URL it used to inherit (#241).
|
|
result = _build_models_json(
|
|
"https://host.example.com",
|
|
"tok",
|
|
{"openai": "https://host.example.com/ai-gateway/codex/v1"},
|
|
model="databricks-gemini-2-5-pro",
|
|
)
|
|
provider = result["providers"][_pi_provider_for_model("databricks-gemini-2-5-pro")]
|
|
self.assertEqual(provider["baseUrl"], "https://host.example.com/serving-endpoints")
|
|
self.assertIn(
|
|
"databricks-gemini-2-5-pro",
|
|
[entry.get("id") for entry in provider["models"]],
|
|
)
|
|
|
|
def test_generic_openai_base_url_used_as_is(self):
|
|
# A non-ucode openai URL (no ``/ai-gateway/codex``) must pass through so
|
|
# the re-route never breaks generic gateways.
|
|
result = _build_models_json(
|
|
"https://host.example.com",
|
|
"tok",
|
|
{"openai": "https://openrouter.ai/api/v1"},
|
|
)
|
|
p = result["providers"]
|
|
self.assertEqual(p["databricks"]["baseUrl"], "https://openrouter.ai/api/v1")
|
|
self.assertEqual(p["databricks-completions"]["baseUrl"], "https://openrouter.ai/api/v1")
|
|
|
|
def test_api_key_set(self):
|
|
result = _build_models_json("https://host.example.com", "mytoken")
|
|
for prov in result["providers"].values():
|
|
self.assertEqual(prov["apiKey"], "mytoken")
|
|
|
|
def test_gpt_provider_uses_completions_api(self):
|
|
result = _build_models_json("https://h", "t")
|
|
self.assertEqual(result["providers"]["databricks"]["api"], "openai-completions")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _generate_extension_js tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGenerateExtensionJs(unittest.TestCase):
|
|
def test_contains_tool_names(self):
|
|
schemas = [
|
|
{
|
|
"name": "my_tool",
|
|
"description": "Does stuff",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
]
|
|
js = _generate_extension_js(12345, schemas, "tok-abc123")
|
|
self.assertIn("my_tool", js)
|
|
self.assertIn("12345", js)
|
|
self.assertIn("pi.registerTool", js)
|
|
# Token embedded and sent on each request, else the server
|
|
# rejects the bridge as unauthenticated.
|
|
self.assertIn('const TOKEN = "tok-abc123";', js)
|
|
self.assertIn("token: TOKEN", js)
|
|
|
|
def test_empty_tools(self):
|
|
js = _generate_extension_js(9999, [], "tok-xyz")
|
|
self.assertIn("pi.registerTool", js)
|
|
self.assertIn("9999", js)
|
|
self.assertIn('const TOKEN = "tok-xyz";', js)
|
|
|
|
def test_registers_native_tool_call_policy_hook(self):
|
|
"""The extension installs a ``tool_call`` hook that gates native tools.
|
|
|
|
Pi's native tools (e.g. ``read``, enabled for skill loading) run
|
|
in-process and bypass the bridged ``/mcp`` policy path. The hook
|
|
closes that gap: it must (1) register a ``tool_call`` listener, (2)
|
|
skip bridged tools (already gated server-side), and (3) send a
|
|
``policy_eval`` frame and block on the verdict. If any piece is
|
|
missing the native tools run ungated.
|
|
"""
|
|
schemas = [
|
|
{
|
|
"name": "sys_os_read",
|
|
"description": "bridged read",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
]
|
|
js = _generate_extension_js(12345, schemas, "tok")
|
|
# (1) A tool_call hook is registered.
|
|
self.assertIn('pi.on("tool_call"', js)
|
|
# (2) Bridged tools are skipped so they aren't double-evaluated
|
|
# (the bridged set is built from the registered tool names).
|
|
self.assertIn("const BRIDGED = new Set(TOOLS.map((t) => t.name));", js)
|
|
self.assertIn("BRIDGED.has(event.toolName)", js)
|
|
# (3) Native tools are evaluated via a policy_eval frame and the
|
|
# block verdict is honored.
|
|
self.assertIn('kind: "policy_eval"', js)
|
|
self.assertIn("block: true", js)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _ToolServer tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestToolServer(unittest.TestCase):
|
|
def setUp(self):
|
|
try:
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
except OSError as exc:
|
|
self.skipTest(f"Local TCP sockets unavailable in this environment: {exc}")
|
|
try:
|
|
probe.bind(("127.0.0.1", 0))
|
|
except OSError as exc:
|
|
self.skipTest(f"Loopback TCP bind unavailable in this environment: {exc}")
|
|
finally:
|
|
probe.close()
|
|
|
|
async def _run_generated_bridge_tool(
|
|
self,
|
|
*,
|
|
port: int,
|
|
token: str,
|
|
timeout: float = 5.0,
|
|
) -> dict:
|
|
"""Run the generated JS extension under Node and execute one tool.
|
|
|
|
This is intentionally cross-runtime: Python starts the real loopback
|
|
server, while Node loads the generated Pi extension, captures the
|
|
registered tool, and calls its ``execute`` method. It exercises the
|
|
same JSONL/TCP boundary Pi uses without needing a live Pi process.
|
|
"""
|
|
node_path = shutil.which("node")
|
|
if node_path is None:
|
|
self.skipTest("node is required for generated Pi bridge e2e tests")
|
|
|
|
schema = [
|
|
{
|
|
"name": "exotic",
|
|
"description": "exercise generated bridge",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
}
|
|
]
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
tmp_path = Path(tmp)
|
|
extension_path = tmp_path / "omnigent_tools.js"
|
|
runner_path = tmp_path / "run_bridge.js"
|
|
extension_path.write_text(_generate_extension_js(port, schema, token))
|
|
runner_path.write_text(
|
|
textwrap.dedent(
|
|
"""
|
|
const extension = require(process.argv[2]);
|
|
let registered;
|
|
const fakePi = {
|
|
on() {},
|
|
registerTool(tool) { registered = tool; },
|
|
};
|
|
|
|
(async () => {
|
|
extension(fakePi);
|
|
if (!registered) {
|
|
throw new Error("tool was not registered");
|
|
}
|
|
const result = await registered.execute(
|
|
"call-1",
|
|
{ input: "hello" },
|
|
undefined,
|
|
undefined,
|
|
{}
|
|
);
|
|
process.stdout.write(JSON.stringify(result));
|
|
})().catch((err) => {
|
|
process.stderr.write(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
)
|
|
)
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
node_path,
|
|
str(runner_path),
|
|
str(extension_path),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
stdout, stderr = await proc.communicate()
|
|
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
self.fail(f"generated Pi bridge did not resolve within {timeout}s: {stderr_text}")
|
|
self.assertEqual(
|
|
proc.returncode,
|
|
0,
|
|
stderr.decode("utf-8", errors="replace"),
|
|
)
|
|
return json.loads(stdout.decode("utf-8"))
|
|
|
|
def test_start_and_stop(self):
|
|
async def _test():
|
|
server = _ToolServer()
|
|
port = await server.start()
|
|
self.assertGreater(port, 0)
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_tool_execution_over_tcp(self):
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
async def executor(name, args):
|
|
return {"sum": args.get("a", 0) + args.get("b", 0)}
|
|
|
|
server._tool_executor = executor
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps(
|
|
{"id": "req1", "token": server.token, "tool": "add", "args": {"a": 3, "b": 4}}
|
|
)
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
self.assertEqual(response["id"], "req1")
|
|
self.assertEqual(response["result"]["sum"], 7)
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_tool_execution_error(self):
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
async def executor(name, args):
|
|
raise ValueError("boom")
|
|
|
|
server._tool_executor = executor
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps({"id": "req2", "token": server.token, "tool": "fail", "args": {}})
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
self.assertEqual(response["id"], "req2")
|
|
self.assertIn("boom", response["error"])
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_no_executor_returns_error(self):
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
# Don't set _tool_executor
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps({"id": "req3", "token": server.token, "tool": "missing", "args": {}})
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
self.assertEqual(response["id"], "req3")
|
|
self.assertIn("No tool executor", response["error"])
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_non_json_serializable_result_returns_error_frame(self):
|
|
"""A tool result ``json.dumps`` can't encode yields an error frame.
|
|
|
|
Regression for F03: serialization happens on the response path
|
|
*outside* ``_execute``'s try, so a tool returning a ``datetime`` (or
|
|
``set``) used to raise ``TypeError`` there, close the socket with zero
|
|
bytes, and hang the JS ``callTool`` promise — wedging the whole turn.
|
|
The handler must instead always write a valid frame: here an
|
|
``{"error": ...}`` envelope, correlated by ``id``, delivered well
|
|
within the timeout (proving it did not hang).
|
|
"""
|
|
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
async def executor(name, args):
|
|
# A dict carrying values json.dumps rejects by default.
|
|
return {"when": datetime(2026, 6, 18, 12, 0, 0), "tags": {1, 2, 3}}
|
|
|
|
server._tool_executor = executor
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps({"id": "req6", "token": server.token, "tool": "exotic", "args": {}})
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
# The key assertion is that a frame arrives at all (no hang): a
|
|
# short timeout would fire if the response path crashed/closed.
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
self.assertTrue(response_line, "tool server closed without writing a frame (hang)")
|
|
response = json.loads(response_line)
|
|
self.assertEqual(response["id"], "req6")
|
|
self.assertIn("unserializable tool result", response["error"])
|
|
self.assertNotIn("result", response)
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_safe_dumps_with_non_serializable_req_id_does_not_raise(self):
|
|
"""``_safe_dumps`` never raises, even on a non-serializable ``req_id``.
|
|
|
|
The fallback envelope serializes ``req_id`` too, so a future caller
|
|
passing an id ``json.dumps`` can't encode (here a ``datetime``) must
|
|
still yield a valid frame rather than re-raising the very crash the
|
|
guard exists to prevent. The id is stringified in that envelope.
|
|
"""
|
|
bad_id = datetime(2026, 6, 18, 12, 0, 0)
|
|
out = _safe_dumps({"id": bad_id, "result": {"k": "v"}}, bad_id) # type: ignore[arg-type]
|
|
payload = json.loads(out)
|
|
self.assertEqual(payload["id"], str(bad_id))
|
|
self.assertIn("unserializable tool result", payload["error"])
|
|
self.assertNotIn("result", payload)
|
|
|
|
def test_generated_bridge_returns_error_for_unserializable_tool_result(self):
|
|
"""End-to-end: Node bridge + Python server return an error result.
|
|
|
|
The previous unit test proves the TCP server writes an error frame.
|
|
This test follows the actual Pi bridge path too: generated JS running
|
|
in Node receives that frame and returns a Pi tool result with
|
|
``isError=true`` instead of hanging or throwing.
|
|
"""
|
|
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
async def executor(name, args):
|
|
return {"when": datetime(2026, 6, 18, 12, 0, 0), "tags": {1, 2, 3}}
|
|
|
|
server._tool_executor = executor
|
|
try:
|
|
result = await self._run_generated_bridge_tool(
|
|
port=server.port,
|
|
token=server.token,
|
|
)
|
|
finally:
|
|
await server.stop()
|
|
|
|
self.assertTrue(result["isError"])
|
|
self.assertEqual(result["content"][0]["type"], "text")
|
|
payload = json.loads(result["content"][0]["text"])
|
|
self.assertIn("unserializable tool result", payload["error"])
|
|
|
|
_run(_test())
|
|
|
|
def test_generated_bridge_resolves_on_bare_socket_close(self):
|
|
"""End-to-end: a zero-byte close resolves the generated JS callTool.
|
|
|
|
This exercises the defense-in-depth close handler. If the generated
|
|
bridge only resolved on ``data`` or ``error`` events, the Node process
|
|
would hang here until ``wait_for`` timed out.
|
|
"""
|
|
|
|
async def _test():
|
|
async def close_without_response(reader, writer):
|
|
await reader.readline()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
server = await asyncio.start_server(close_without_response, "127.0.0.1", 0)
|
|
port = server.sockets[0].getsockname()[1]
|
|
try:
|
|
result = await self._run_generated_bridge_tool(
|
|
port=port,
|
|
token="close-token",
|
|
)
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
|
|
self.assertTrue(result["isError"])
|
|
payload = json.loads(result["content"][0]["text"])
|
|
self.assertIn("closed connection without a response", payload["error"])
|
|
|
|
_run(_test())
|
|
|
|
def test_request_without_token_is_rejected_without_dispatch(self):
|
|
"""An unauthenticated request is refused before reaching the executor.
|
|
|
|
A process that found the loopback port but can't read the embedded
|
|
token must not drive the tool executor. If auth were removed,
|
|
``dispatched`` would flip to ``True`` and the response would carry
|
|
the tool result instead of ``"unauthorized"``.
|
|
"""
|
|
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
dispatched = False
|
|
|
|
async def executor(name, args):
|
|
nonlocal dispatched
|
|
dispatched = True
|
|
return {"ok": True}
|
|
|
|
server._tool_executor = executor
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
# No "token" field at all.
|
|
request = json.dumps({"id": "req4", "tool": "add", "args": {}}) + "\n"
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
# The executor must never have run for an unauthenticated frame.
|
|
self.assertFalse(dispatched, "tool executor ran for an unauthenticated request")
|
|
self.assertEqual(response["error"], "unauthorized")
|
|
self.assertNotIn("result", response)
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_request_with_wrong_token_is_rejected_without_dispatch(self):
|
|
"""A forged/incorrect token is refused before reaching the executor.
|
|
|
|
Complements the missing-token case: proves the server compares the
|
|
presented token against its secret rather than merely checking that
|
|
*some* token field is present.
|
|
"""
|
|
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
dispatched = False
|
|
|
|
async def executor(name, args):
|
|
nonlocal dispatched
|
|
dispatched = True
|
|
return {"ok": True}
|
|
|
|
server._tool_executor = executor
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps(
|
|
{"id": "req5", "token": server.token + "tampered", "tool": "add", "args": {}}
|
|
)
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
self.assertFalse(dispatched, "tool executor ran for a wrong-token request")
|
|
self.assertEqual(response["error"], "unauthorized")
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_each_server_gets_a_distinct_token(self):
|
|
"""Two servers mint independent secrets.
|
|
|
|
A shared/static token would let one session's bridge authenticate
|
|
against another's server, reopening the cross-session vector. The
|
|
token is also long enough not to be brute-forceable in practice.
|
|
"""
|
|
|
|
async def _test():
|
|
a = _ToolServer()
|
|
b = _ToolServer()
|
|
self.assertNotEqual(a.token, b.token)
|
|
# token_urlsafe(32) → 256 bits → ~43 url-safe chars.
|
|
self.assertGreaterEqual(len(a.token), 40)
|
|
|
|
_run(_test())
|
|
|
|
def test_policy_eval_frame_blocks_on_deny(self):
|
|
"""A ``kind=policy_eval`` frame returns the gate's DENY verdict
|
|
without executing the tool.
|
|
|
|
This is the native-tool gate: Pi's ``tool_call`` hook asks for a
|
|
verdict on a tool it will run itself. The server must consult
|
|
``_policy_gate`` (not ``_tool_executor``) and surface
|
|
``{"block": True, ...}``. If the dispatch branch were missing, the
|
|
frame would fall through to ``_execute`` and the tool executor would
|
|
run — so we assert the executor never fired.
|
|
"""
|
|
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
executed = False
|
|
|
|
async def executor(name, args):
|
|
nonlocal executed
|
|
executed = True
|
|
return {"ok": True}
|
|
|
|
async def gate(name, args):
|
|
# Echo the inputs back so the assertion proves the real
|
|
# tool name / args reached the gate, not a fixed stub.
|
|
return {"block": True, "reason": f"denied {name}:{args.get('path')}"}
|
|
|
|
server._tool_executor = executor
|
|
server._policy_gate = gate
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps(
|
|
{
|
|
"id": "pe1",
|
|
"token": server.token,
|
|
"kind": "policy_eval",
|
|
"tool": "read",
|
|
"args": {"path": "/etc/secret"},
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
self.assertEqual(response["id"], "pe1")
|
|
# DENY verdict surfaced verbatim from the gate, proving the
|
|
# tool name + args traversed to the gate intact.
|
|
self.assertEqual(
|
|
response["verdict"], {"block": True, "reason": "denied read:/etc/secret"}
|
|
)
|
|
# Verdict-only path: the tool must NOT have executed. If this
|
|
# is True, the policy_eval branch wrongly fell through to
|
|
# _execute and the native tool ran ungated despite a DENY.
|
|
self.assertFalse(executed, "tool executor ran on a policy_eval frame")
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_policy_eval_frame_allows(self):
|
|
"""An ALLOW gate yields ``{"block": False}`` so Pi runs the tool."""
|
|
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
async def gate(name, args):
|
|
return {"block": False, "reason": ""}
|
|
|
|
server._policy_gate = gate
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps(
|
|
{
|
|
"id": "pe2",
|
|
"token": server.token,
|
|
"kind": "policy_eval",
|
|
"tool": "read",
|
|
"args": {"path": "/tmp/ok"},
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
self.assertEqual(response["verdict"], {"block": False, "reason": ""})
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_policy_eval_without_gate_fails_open(self):
|
|
"""With no ``_policy_gate`` wired, the verdict is ALLOW (fail-open).
|
|
|
|
Single-process / test paths never install a gate. The native tool
|
|
must still run rather than wedge — so an unset gate yields
|
|
``block=False`` rather than an error.
|
|
"""
|
|
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
# Deliberately leave _policy_gate unset.
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps(
|
|
{
|
|
"id": "pe3",
|
|
"token": server.token,
|
|
"kind": "policy_eval",
|
|
"tool": "read",
|
|
"args": {},
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
self.assertEqual(response["verdict"], {"block": False, "reason": ""})
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
def test_policy_eval_gate_exception_fails_open(self):
|
|
"""A gate that raises must not wedge Pi — the verdict is ALLOW.
|
|
|
|
Mirrors the runner/scaffold contract: a transient policy-evaluation
|
|
failure defaults to ALLOW. If this raised instead, every native tool
|
|
call would error out whenever the verdict round-trip hiccupped.
|
|
"""
|
|
|
|
async def _test():
|
|
server = _ToolServer()
|
|
await server.start()
|
|
|
|
async def gate(name, args):
|
|
raise RuntimeError("verdict channel down")
|
|
|
|
server._policy_gate = gate
|
|
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
request = (
|
|
json.dumps(
|
|
{
|
|
"id": "pe4",
|
|
"token": server.token,
|
|
"kind": "policy_eval",
|
|
"tool": "read",
|
|
"args": {},
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
writer.write(request.encode())
|
|
await writer.drain()
|
|
|
|
response_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
response = json.loads(response_line)
|
|
self.assertEqual(response["verdict"], {"block": False, "reason": ""})
|
|
|
|
writer.close()
|
|
await server.stop()
|
|
|
|
_run(_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _PiRpcSession tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPiRpcSession(unittest.TestCase):
|
|
def test_reader_accepts_single_stdout_line_larger_than_default_stream_limit(self):
|
|
async def _test():
|
|
rpc = _PiRpcSession()
|
|
stream = asyncio.StreamReader()
|
|
payload = "x" * (70 * 1024)
|
|
event = {
|
|
"type": "tool_execution_end",
|
|
"toolName": "large_result",
|
|
"isError": False,
|
|
"result": {"content": payload},
|
|
}
|
|
stream.feed_data((json.dumps(event) + "\n").encode())
|
|
stream.feed_eof()
|
|
rpc.process = MagicMock()
|
|
rpc.process.stdout = stream
|
|
rpc._line_queue = asyncio.Queue()
|
|
|
|
await rpc._reader()
|
|
|
|
line = await rpc.read_line(timeout=0.1)
|
|
self.assertIsNotNone(line)
|
|
parsed = json.loads(line)
|
|
self.assertEqual(parsed["type"], "tool_execution_end")
|
|
self.assertEqual(parsed["toolName"], "large_result")
|
|
self.assertEqual(parsed["result"]["content"], payload)
|
|
self.assertIsNone(await rpc.read_line(timeout=0.1))
|
|
|
|
_run(_test())
|
|
|
|
def test_send_command(self):
|
|
async def _test():
|
|
rpc = _PiRpcSession()
|
|
proc = _FakeProcess(stdout_lines=[], stderr_lines=[])
|
|
rpc.process = proc
|
|
rpc._line_queue = asyncio.Queue()
|
|
|
|
await rpc.send_command({"type": "prompt", "message": "hello", "id": "1"})
|
|
written = b"".join(proc.stdin.data)
|
|
parsed = json.loads(written.decode())
|
|
self.assertEqual(parsed["type"], "prompt")
|
|
self.assertEqual(parsed["message"], "hello")
|
|
|
|
_run(_test())
|
|
|
|
def test_send_command_raises_when_no_process(self):
|
|
async def _test():
|
|
rpc = _PiRpcSession()
|
|
with self.assertRaises(RuntimeError):
|
|
await rpc.send_command({"type": "prompt"})
|
|
|
|
_run(_test())
|
|
|
|
def test_close_terminates_process(self):
|
|
async def _test():
|
|
rpc = _PiRpcSession()
|
|
proc = _FakeProcess(stdout_lines=[], stderr_lines=[])
|
|
rpc.process = proc
|
|
rpc._line_queue = asyncio.Queue()
|
|
# Start reader tasks that will end immediately
|
|
rpc._read_task = asyncio.create_task(asyncio.sleep(0))
|
|
rpc._stderr_task = asyncio.create_task(asyncio.sleep(0))
|
|
await asyncio.sleep(0.01) # let tasks finish
|
|
await rpc.close()
|
|
self.assertTrue(proc.terminate == proc.terminate) # terminated was called
|
|
self.assertIsNone(rpc.process)
|
|
|
|
_run(_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PiExecutor constructor tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPiExecutorConstructor(unittest.TestCase):
|
|
def test_constructor_finds_pi(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
self.assertEqual(executor._pi_path, "/usr/bin/pi")
|
|
|
|
def test_constructor_raises_when_pi_not_found(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value=None):
|
|
with self.assertRaises(ImportError):
|
|
PiExecutor()
|
|
|
|
def test_constructor_databricks_with_env(self):
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch(
|
|
"omnigent.inner.pi_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(host="https://h.example.com", token="tok"),
|
|
),
|
|
):
|
|
executor = PiExecutor(gateway=True)
|
|
self.assertTrue(executor._gateway)
|
|
self.assertEqual(executor._databricks_host, "https://h.example.com")
|
|
self.assertEqual(executor._databricks_token, "tok")
|
|
|
|
def test_constructor_databricks_with_host_override_requires_auth_command(self):
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch("omnigent.inner.pi_executor._read_databrickscfg") as read_cfg,
|
|
):
|
|
with self.assertRaisesRegex(OSError, "requires a gateway auth command"):
|
|
PiExecutor(
|
|
gateway=True,
|
|
databricks_profile="missing-profile",
|
|
gateway_host="https://example.databricks.com/",
|
|
)
|
|
|
|
read_cfg.assert_not_called()
|
|
|
|
def test_constructor_databricks_with_auth_command(self):
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch("omnigent.inner.pi_executor._read_databrickscfg") as read_cfg,
|
|
patch(
|
|
"omnigent.inner.pi_executor._fetch_shell_command_token",
|
|
return_value="command-token",
|
|
) as fetch_command_token,
|
|
):
|
|
executor = PiExecutor(
|
|
gateway=True,
|
|
databricks_profile="missing-profile",
|
|
gateway_host="https://example.databricks.com/",
|
|
base_urls_override={
|
|
"claude": "https://example.databricks.com/ai-gateway/anthropic"
|
|
},
|
|
gateway_auth_command="printf token",
|
|
)
|
|
|
|
read_cfg.assert_not_called()
|
|
fetch_command_token.assert_called_once_with("printf token")
|
|
self.assertEqual(executor._databricks_host, "https://example.databricks.com")
|
|
self.assertEqual(executor._databricks_token, "command-token")
|
|
self.assertEqual(
|
|
executor._base_urls_override,
|
|
{"claude": "https://example.databricks.com/ai-gateway/anthropic"},
|
|
)
|
|
|
|
def test_constructor_databricks_no_creds_raises(self):
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch("omnigent.inner.pi_executor._read_databrickscfg", return_value=None),
|
|
):
|
|
with self.assertRaises(EnvironmentError):
|
|
PiExecutor(gateway=True)
|
|
|
|
def test_constructor_with_model_override(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor(model="my-model")
|
|
self.assertEqual(executor._model_override, "my-model")
|
|
|
|
def test_supports_streaming(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
self.assertTrue(executor.supports_streaming())
|
|
|
|
def test_supports_tool_calling(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
self.assertTrue(executor.supports_tool_calling())
|
|
|
|
def test_handles_tools_internally(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
self.assertTrue(executor.handles_tools_internally())
|
|
|
|
def test_supports_live_message_queue(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
self.assertTrue(executor.supports_live_message_queue())
|
|
|
|
def test_no_tools_flag_in_extra_args(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
self.assertIn("--no-tools", executor._extra_args)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PiExecutor._gate_native_tool tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGateNativeTool(unittest.TestCase):
|
|
"""``_gate_native_tool`` bridges the tool server to the scaffold's
|
|
``_policy_evaluator`` and maps the proto verdict to ``{block, reason}``.
|
|
|
|
This is the security-critical mapping: a TOOL_CALL DENY from the
|
|
Omnigent policy engine must become ``block=True`` so Pi refuses the
|
|
native tool. ALLOW (and the no-evaluator path) must become
|
|
``block=False`` so legitimate native tool use isn't broken.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _executor():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
return PiExecutor()
|
|
|
|
def test_deny_verdict_blocks_with_reason(self):
|
|
executor = self._executor()
|
|
seen: dict[str, object] = {}
|
|
|
|
async def fake_evaluator(phase, data):
|
|
seen["phase"] = phase
|
|
seen["data"] = data
|
|
return PolicyVerdictPayload(action="POLICY_ACTION_DENY", reason="no /etc reads")
|
|
|
|
executor._policy_evaluator = fake_evaluator
|
|
|
|
verdict = _run(executor._gate_native_tool("read", {"path": "/etc/secret"}))
|
|
|
|
# DENY → block, carrying the policy's human-readable reason so the
|
|
# model sees why. A False here means a denied native tool would run.
|
|
self.assertEqual(verdict, {"block": True, "reason": "no /etc reads"})
|
|
# The evaluator was invoked at the TOOL_CALL phase with the real
|
|
# tool name + args (not a fixed stub) — proving the native call's
|
|
# identity reaches the policy engine.
|
|
self.assertEqual(seen["phase"], "PHASE_TOOL_CALL")
|
|
self.assertEqual(seen["data"], {"name": "read", "arguments": {"path": "/etc/secret"}})
|
|
|
|
def test_allow_verdict_does_not_block(self):
|
|
executor = self._executor()
|
|
|
|
async def fake_evaluator(phase, data):
|
|
return PolicyVerdictPayload(action="POLICY_ACTION_ALLOW")
|
|
|
|
executor._policy_evaluator = fake_evaluator
|
|
|
|
verdict = _run(executor._gate_native_tool("read", {"path": "/tmp/ok"}))
|
|
# ALLOW must not block — otherwise every native tool call is broken.
|
|
self.assertEqual(verdict, {"block": False, "reason": ""})
|
|
|
|
def test_deny_without_reason_uses_fallback(self):
|
|
executor = self._executor()
|
|
|
|
async def fake_evaluator(phase, data):
|
|
return PolicyVerdictPayload(action="POLICY_ACTION_DENY", reason=None)
|
|
|
|
executor._policy_evaluator = fake_evaluator
|
|
|
|
verdict = _run(executor._gate_native_tool("bash", {"command": "ls"}))
|
|
# A DENY with no reason still blocks, with a non-empty fallback so
|
|
# the model never sees an empty refusal.
|
|
self.assertEqual(verdict["block"], True)
|
|
self.assertEqual(verdict["reason"], "blocked by policy")
|
|
|
|
def test_no_evaluator_allows(self):
|
|
executor = self._executor()
|
|
# No _policy_evaluator installed (single-process / pre-turn path).
|
|
verdict = _run(executor._gate_native_tool("read", {"path": "/x"}))
|
|
# Fail-open: without an evaluator wired the tool must still run.
|
|
self.assertEqual(verdict, {"block": False, "reason": ""})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PiExecutor._resolve_model tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveModel(unittest.TestCase):
|
|
def test_cfg_model_takes_priority_over_constructor(self):
|
|
# Per-turn ``cfg.model`` wins over constructor-time
|
|
# ``self._model_override``. The constructor value (from
|
|
# ``HARNESS_PI_MODEL`` at spawn time) is the spec-level
|
|
# default; ``cfg.model`` carries the per-request override
|
|
# the REPL's ``/model`` slash command sets. Without this
|
|
# precedence, mid-session model overrides would silently
|
|
# no-op on the pi harness. Mirrors ``cfg.model`` precedence
|
|
# in claude-sdk / codex / openai-agents.
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor(model="constructor-default")
|
|
self.assertEqual(
|
|
executor._resolve_model(ExecutorConfig(model="cfg-override")), "cfg-override"
|
|
)
|
|
|
|
def test_constructor_default_used_when_no_cfg_override(self):
|
|
# Constructor value acts as the spec-level default when
|
|
# ``cfg.model`` is None (no per-turn ``/model`` override
|
|
# in effect).
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor(model="constructor-default")
|
|
self.assertEqual(
|
|
executor._resolve_model(ExecutorConfig(model=None)), "constructor-default"
|
|
)
|
|
|
|
def test_cfg_model_used_when_no_constructor_default(self):
|
|
# Existing case — preserved from prior behavior. With
|
|
# neither a constructor default nor a per-turn override
|
|
# actively set on the spec, ``cfg.model`` still flows
|
|
# through unchanged.
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
self.assertEqual(
|
|
executor._resolve_model(ExecutorConfig(model="config-model")), "config-model"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PiExecutor._build_env_and_dir tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildEnvAndDir(unittest.TestCase):
|
|
def test_databricks_creates_models_json(self):
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch(
|
|
"omnigent.inner.pi_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(host="https://h.example.com", token="tok"),
|
|
),
|
|
):
|
|
executor = PiExecutor(gateway=True)
|
|
|
|
config = executor._build_env_and_dir([], None, None, None)
|
|
try:
|
|
self.assertIn("PI_CODING_AGENT_DIR", config.env)
|
|
managed_dir = Path(config.env["PI_CODING_AGENT_DIR"])
|
|
models_path = managed_dir / "models.json"
|
|
self.assertTrue(models_path.is_file())
|
|
with open(models_path) as f:
|
|
data = json.load(f)
|
|
self.assertIn("providers", data)
|
|
settings_path = managed_dir / "settings.json"
|
|
self.assertTrue(settings_path.is_file())
|
|
with open(settings_path) as f:
|
|
settings = json.load(f)
|
|
self.assertIn("retry", settings)
|
|
finally:
|
|
import shutil
|
|
|
|
shutil.rmtree(config.tmp_dir, ignore_errors=True)
|
|
|
|
def test_tools_generate_extension_js(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
|
|
tools = [
|
|
{
|
|
"name": "test_tool",
|
|
"description": "A test",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
}
|
|
]
|
|
config = executor._build_env_and_dir(tools, 12345, "tok-test", None)
|
|
try:
|
|
self.assertIn("--extension", config.extra_args)
|
|
ext_path = config.extra_args[config.extra_args.index("--extension") + 1]
|
|
self.assertTrue(os.path.exists(ext_path))
|
|
with open(ext_path) as f:
|
|
content = f.read()
|
|
self.assertIn("test_tool", content)
|
|
self.assertIn("12345", content)
|
|
# Token threads through to the on-disk extension; without it
|
|
# the Pi process couldn't authenticate to the tool server.
|
|
self.assertIn('const TOKEN = "tok-test";', content)
|
|
finally:
|
|
import shutil
|
|
|
|
shutil.rmtree(config.tmp_dir, ignore_errors=True)
|
|
|
|
|
|
def test_gateway_seeds_managed_settings_from_global_agent(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Gateway mode copies global Pi settings into the managed agent dir."""
|
|
global_agent = tmp_path / "pi-agent"
|
|
global_agent.mkdir()
|
|
(global_agent / "settings.json").write_text(
|
|
json.dumps({"extensions": ["/ext/demo.ts"], "packages": ["npm:@x/y"]}),
|
|
encoding="utf-8",
|
|
)
|
|
(global_agent / "npm").mkdir()
|
|
monkeypatch.setattr(
|
|
"omnigent.inner.pi_settings.DEFAULT_PI_AGENT_DIR",
|
|
global_agent,
|
|
)
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch(
|
|
"omnigent.inner.pi_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(host="https://h.example.com", token="tok"),
|
|
),
|
|
):
|
|
executor = PiExecutor(gateway=True)
|
|
|
|
config = executor._build_env_and_dir([], None, None, None)
|
|
try:
|
|
managed_dir = Path(config.env["PI_CODING_AGENT_DIR"])
|
|
settings = json.loads((managed_dir / "settings.json").read_text(encoding="utf-8"))
|
|
assert settings["extensions"] == ["/ext/demo.ts"]
|
|
assert settings["packages"] == ["npm:@x/y"]
|
|
assert (managed_dir / "npm").is_symlink()
|
|
finally:
|
|
shutil.rmtree(config.tmp_dir, ignore_errors=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pi tool allowlist (--no-tools + --tools) tests — function-based per
|
|
# the project's testing rules. ``--no-tools`` alone disables every tool
|
|
# in pi 0.68+ (built-in AND extension); the executor must pair it with
|
|
# ``--tools <names>`` for the bridge to actually expose anything.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_pi_extra_args_disable_native_tools_by_default() -> None:
|
|
"""
|
|
A turn with no bridged tools must still pass ``--no-tools`` so
|
|
pi's native read/bash/edit/write stay disabled. ``--tools`` is
|
|
intentionally absent — passing an empty allowlist would be a
|
|
no-op, but pi parses ``--tools `` as an error in some flag
|
|
parsers, so we just omit it.
|
|
"""
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
config = executor._build_env_and_dir([], None, None, None)
|
|
try:
|
|
# Native tools off by default.
|
|
assert "--no-tools" in config.extra_args, (
|
|
"--no-tools missing → pi's native read/bash/edit/write would be exposed"
|
|
)
|
|
# No allowlist when no tools are bridged.
|
|
assert "--tools" not in config.extra_args, (
|
|
"--tools should not appear when there are no bridged tools to allowlist"
|
|
)
|
|
finally:
|
|
import shutil
|
|
|
|
shutil.rmtree(config.tmp_dir, ignore_errors=True)
|
|
|
|
|
|
def test_pi_tools_arg_allowlists_bridged_tool_names() -> None:
|
|
"""
|
|
With bridged tools, ``--tools <comma-list>`` must appear so pi
|
|
actually exposes them to the LLM. Order: ``--no-tools`` first
|
|
(disable everything), then ``--tools`` re-enables specifically
|
|
the bridged names. Catches the regression where ``--no-tools``
|
|
alone wiped out extension tools and the model reported "I
|
|
don't have a calculate tool available."
|
|
"""
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
tools = [
|
|
{"name": "calculate", "description": "x", "parameters": {"type": "object"}},
|
|
{"name": "get_current_time", "description": "y", "parameters": {"type": "object"}},
|
|
]
|
|
config = executor._build_env_and_dir(tools, 9999, "tok-test", None)
|
|
try:
|
|
assert "--no-tools" in config.extra_args
|
|
assert "--tools" in config.extra_args, (
|
|
"--tools missing → pi 0.68+ keeps extension tools disabled, model "
|
|
"won't see calculate/get_current_time"
|
|
)
|
|
names_arg = config.extra_args[config.extra_args.index("--tools") + 1]
|
|
# Comma-separated, both bridged names + ``read`` (injected
|
|
# by the skills layer so Pi's ``formatSkillsForPrompt``
|
|
# sees it and injects the skill index into the system
|
|
# prompt — Pi gates skill-prompt injection on
|
|
# ``selectedTools.includes("read")``).
|
|
actual = sorted(names_arg.split(","))
|
|
assert actual == ["calculate", "get_current_time", "read"], (
|
|
f"unexpected --tools allowlist: {names_arg!r}; expected the "
|
|
f"two bridged tool names + 'read' (for skills)"
|
|
)
|
|
finally:
|
|
import shutil
|
|
|
|
shutil.rmtree(config.tmp_dir, ignore_errors=True)
|
|
|
|
|
|
def test_pi_tools_arg_skips_unnamed_entries() -> None:
|
|
"""
|
|
Tool schemas without a ``name`` (or with a non-string name)
|
|
are dropped from both the bridge JS registration and the
|
|
``--tools`` allowlist. Exercises the same defensive path
|
|
:func:`_generate_extension_js` already follows so the two
|
|
can't drift apart and produce a name in one but not the
|
|
other.
|
|
"""
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
tools = [
|
|
{"name": "good", "description": "x", "parameters": {"type": "object"}},
|
|
{"description": "no name field", "parameters": {"type": "object"}},
|
|
{"name": 123, "description": "non-string name", "parameters": {"type": "object"}},
|
|
]
|
|
config = executor._build_env_and_dir(tools, 1, "tok-test", None)
|
|
try:
|
|
# ``--tools`` is present (we have at least one valid name)
|
|
# and contains exactly the valid name.
|
|
assert "--tools" in config.extra_args
|
|
names_arg = config.extra_args[config.extra_args.index("--tools") + 1]
|
|
# ``read`` is also present (injected by the skills layer for
|
|
# Pi's skill-prompt gating — see ``_build_env_and_dir``).
|
|
assert sorted(names_arg.split(",")) == ["good", "read"], (
|
|
f"unnamed / non-string-named tools must be filtered; got {names_arg!r}"
|
|
)
|
|
finally:
|
|
import shutil
|
|
|
|
shutil.rmtree(config.tmp_dir, ignore_errors=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PiExecutor.run_turn tests (with mocked RPC)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunTurn(unittest.TestCase):
|
|
def _make_executor(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
return PiExecutor()
|
|
|
|
def test_empty_user_message_returns_turn_complete(self):
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
# Even though there's no user message, _ensure_rpc is called
|
|
# first, so we need to mock it.
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = []
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "assistant", "content": "hi"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
self.assertEqual(len(events), 1)
|
|
self.assertIsInstance(events[0], TurnComplete)
|
|
# Empty-prompt short-circuit signals "no assistant text this
|
|
# turn" via ``response=None``, distinct from an explicit empty
|
|
# string the LLM might produce intentionally.
|
|
self.assertIsNone(events[0].response)
|
|
|
|
_run(_test())
|
|
|
|
def test_streaming_text_events(self):
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
# Mock the RPC session
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = []
|
|
|
|
# Pre-populate the line queue
|
|
lines = [
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {"type": "text_delta", "delta": "Hello "},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {"type": "text_delta", "delta": "world"},
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
for line in lines:
|
|
fake_rpc._line_queue.put_nowait(line)
|
|
|
|
# Patch _ensure_rpc to return our fake
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
text_chunks = [e for e in events if isinstance(e, TextChunk)]
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
|
|
self.assertEqual(len(text_chunks), 2)
|
|
self.assertEqual(text_chunks[0].text, "Hello ")
|
|
self.assertEqual(text_chunks[1].text, "world")
|
|
self.assertEqual(len(turn_complete), 1)
|
|
self.assertEqual(turn_complete[0].response, "Hello world")
|
|
|
|
_run(_test())
|
|
|
|
def test_tool_execution_events(self):
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = []
|
|
|
|
lines = [
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{"type": "tool_execution_start", "toolName": "add", "args": {"a": 1, "b": 2}}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"type": "tool_execution_end",
|
|
"toolName": "add",
|
|
"isError": False,
|
|
"result": {"sum": 3},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {"type": "text_delta", "delta": "3"},
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
for line in lines:
|
|
fake_rpc._line_queue.put_nowait(line)
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "add 1 and 2"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
tool_requests = [e for e in events if isinstance(e, ToolCallRequest)]
|
|
tool_completes = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
|
|
self.assertEqual(len(tool_requests), 1)
|
|
self.assertEqual(tool_requests[0].name, "add")
|
|
self.assertEqual(tool_requests[0].args, {"a": 1, "b": 2})
|
|
|
|
self.assertEqual(len(tool_completes), 1)
|
|
self.assertEqual(tool_completes[0].name, "add")
|
|
self.assertEqual(tool_completes[0].status, ToolCallStatus.SUCCESS)
|
|
|
|
_run(_test())
|
|
|
|
def test_error_on_failed_response(self):
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = []
|
|
|
|
lines = [
|
|
json.dumps({"type": "response", "success": False, "error": "bad request"}),
|
|
]
|
|
for line in lines:
|
|
fake_rpc._line_queue.put_nowait(line)
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertIsInstance(events[0], ExecutorError)
|
|
self.assertIn("bad request", events[0].message)
|
|
|
|
_run(_test())
|
|
|
|
def test_eof_without_response_yields_error(self):
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc._line_queue.put_nowait(None) # EOF
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = ["error: something went wrong"]
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertIsInstance(events[0], ExecutorError)
|
|
self.assertIn("something went wrong", events[0].message)
|
|
|
|
_run(_test())
|
|
|
|
def test_agent_end_extracts_response_from_messages(self):
|
|
"""When no text deltas were streamed, response is extracted from agent_end messages."""
|
|
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = []
|
|
|
|
lines = [
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "agent_end",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Final answer"}],
|
|
},
|
|
],
|
|
}
|
|
),
|
|
]
|
|
for line in lines:
|
|
fake_rpc._line_queue.put_nowait(line)
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
self.assertEqual(len(turn_complete), 1)
|
|
self.assertEqual(turn_complete[0].response, "Final answer")
|
|
|
|
_run(_test())
|
|
|
|
def test_tool_error_event(self):
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = []
|
|
|
|
lines = [
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "tool_execution_end",
|
|
"toolName": "fail_tool",
|
|
"isError": True,
|
|
"result": "Something broke",
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
for line in lines:
|
|
fake_rpc._line_queue.put_nowait(line)
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "fail"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
tool_completes = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
self.assertEqual(len(tool_completes), 1)
|
|
self.assertEqual(tool_completes[0].status, ToolCallStatus.ERROR)
|
|
self.assertIn("Something broke", tool_completes[0].error)
|
|
|
|
_run(_test())
|
|
|
|
def test_message_end_with_error_stop_reason(self):
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = []
|
|
|
|
lines = [
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "message_end",
|
|
"message": {"stopReason": "error", "errorMessage": "Rate limited"},
|
|
}
|
|
),
|
|
]
|
|
for line in lines:
|
|
fake_rpc._line_queue.put_nowait(line)
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
self.assertEqual(len(events), 1)
|
|
self.assertIsInstance(events[0], ExecutorError)
|
|
self.assertIn("Rate limited", events[0].message)
|
|
|
|
_run(_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pi thinking-delta → ReasoningChunk tests — function-based per the
|
|
# project's testing rules. Event dicts mirror pi-ai's
|
|
# ``AssistantMessageEvent`` union: ``thinking_start`` /
|
|
# ``thinking_delta`` / ``thinking_end`` each carry ``contentIndex``
|
|
# (and ``delta`` for the delta variant), wrapped by the RPC layer in
|
|
# ``{"type": "message_update", "assistantMessageEvent": ...}``.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _executor_with_scripted_rpc(lines: list[str], model: str | None = None) -> PiExecutor:
|
|
"""
|
|
Build a :class:`PiExecutor` whose RPC session replays scripted JSONL.
|
|
|
|
:param lines: JSONL event lines the fake Pi process emits, in order,
|
|
e.g. ``[json.dumps({"type": "response", "success": True})]``.
|
|
:param model: Optional model override (``self._model_override``),
|
|
used to exercise the usage ``model`` fallback when the assistant
|
|
message omits its own ``model`` field.
|
|
:returns: Executor with ``_ensure_rpc`` patched to a fake session
|
|
pre-loaded with ``lines``.
|
|
"""
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor(model=model)
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = _FakeProcess()
|
|
fake_rpc._stderr_lines = []
|
|
for line in lines:
|
|
fake_rpc._line_queue.put_nowait(line)
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
return executor
|
|
|
|
|
|
def test_pi_thinking_deltas_stream_as_reasoning_chunks() -> None:
|
|
"""
|
|
A pi thinking block (``thinking_start`` → ``thinking_delta``\\* →
|
|
``thinking_end``) streams as ReasoningChunk events: a
|
|
``reasoning_started`` marker, then one ``reasoning_text`` chunk per
|
|
delta. Reasoning text must NOT leak into the final response text.
|
|
"""
|
|
|
|
async def _test() -> None:
|
|
executor = _executor_with_scripted_rpc(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {"type": "thinking_start", "contentIndex": 0},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {
|
|
"type": "thinking_delta",
|
|
"contentIndex": 0,
|
|
"delta": "Let me ",
|
|
},
|
|
}
|
|
),
|
|
# Empty delta — must be dropped, not emitted as a no-op chunk.
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {
|
|
"type": "thinking_delta",
|
|
"contentIndex": 0,
|
|
"delta": "",
|
|
},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {
|
|
"type": "thinking_delta",
|
|
"contentIndex": 0,
|
|
"delta": "reason.",
|
|
},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {
|
|
"type": "thinking_end",
|
|
"contentIndex": 0,
|
|
"content": "Let me reason.",
|
|
},
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {
|
|
"type": "text_delta",
|
|
"contentIndex": 1,
|
|
"delta": "Answer",
|
|
},
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
reasoning = [e for e in events if isinstance(e, ReasoningChunk)]
|
|
# 3 = the thinking_start marker + 2 non-empty deltas. A 4th
|
|
# chunk means the empty delta leaked; fewer means thinking
|
|
# events were dropped (the pre-fix behavior).
|
|
assert len(reasoning) == 3, f"expected 3 ReasoningChunks, got {reasoning}"
|
|
assert reasoning[0].event_type == "reasoning_started"
|
|
# The started marker carries no text — it only anchors the rail.
|
|
assert reasoning[0].delta == ""
|
|
assert reasoning[1].event_type == "reasoning_text"
|
|
assert reasoning[1].delta == "Let me "
|
|
assert reasoning[2].event_type == "reasoning_text"
|
|
assert reasoning[2].delta == "reason."
|
|
|
|
text_chunks = [e for e in events if isinstance(e, TextChunk)]
|
|
assert len(text_chunks) == 1
|
|
assert text_chunks[0].text == "Answer"
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
# Reasoning must stay out of the final text — "Let me reason."
|
|
# appearing here means thinking deltas were concatenated into
|
|
# response_text.
|
|
assert turn_complete[0].response == "Answer"
|
|
|
|
_run(_test())
|
|
|
|
|
|
def test_pi_thinking_and_text_delta_ordering_preserved() -> None:
|
|
"""
|
|
Interleaved thinking and text deltas stream in arrival order, so
|
|
the web UI renders the reasoning rail and assistant text in the
|
|
sequence the model produced them.
|
|
"""
|
|
|
|
async def _test() -> None:
|
|
def _update(ame: dict[str, object]) -> str:
|
|
"""Wrap an assistantMessageEvent in a message_update line.
|
|
|
|
:param ame: The ``assistantMessageEvent`` payload,
|
|
e.g. ``{"type": "text_delta", "contentIndex": 1, "delta": "hi"}``.
|
|
:returns: The JSONL line for the fake Pi process to emit.
|
|
"""
|
|
return json.dumps({"type": "message_update", "assistantMessageEvent": ame})
|
|
|
|
executor = _executor_with_scripted_rpc(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
_update({"type": "thinking_delta", "contentIndex": 0, "delta": "plan"}),
|
|
_update({"type": "text_delta", "contentIndex": 1, "delta": "step one"}),
|
|
_update({"type": "thinking_delta", "contentIndex": 2, "delta": "revise"}),
|
|
_update({"type": "text_delta", "contentIndex": 3, "delta": " step two"}),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
streamed = [e for e in events if isinstance(e, (ReasoningChunk, TextChunk))]
|
|
# Exact arrival order: reasoning, text, reasoning, text. Any
|
|
# regrouping (e.g. buffering reasoning until the end) breaks
|
|
# the live interleaving the UI renders.
|
|
assert [type(e).__name__ for e in streamed] == [
|
|
"ReasoningChunk",
|
|
"TextChunk",
|
|
"ReasoningChunk",
|
|
"TextChunk",
|
|
], f"unexpected stream order: {streamed}"
|
|
assert streamed[0].delta == "plan"
|
|
assert streamed[1].text == "step one"
|
|
assert streamed[2].delta == "revise"
|
|
assert streamed[3].text == " step two"
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
# Only text deltas accumulate; "plan"/"revise" here means
|
|
# reasoning leaked into the response.
|
|
assert turn_complete[0].response == "step one step two"
|
|
|
|
_run(_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PiExecutor session management tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSessionManagement(unittest.TestCase):
|
|
def test_session_key_from_session_id(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
key = executor._session_key([{"role": "user", "content": "hi", "session_id": "abc"}])
|
|
self.assertEqual(key, "abc")
|
|
|
|
def test_session_key_from_metadata(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
key = executor._session_key(
|
|
[{"role": "user", "content": "hi", "metadata": {"session_id": "xyz"}}]
|
|
)
|
|
self.assertEqual(key, "xyz")
|
|
|
|
def test_session_key_default(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
key = executor._session_key([{"role": "user", "content": "hi"}])
|
|
self.assertEqual(key, "__default__")
|
|
|
|
def test_close_session(self):
|
|
async def _test():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
|
|
mock_rpc = MagicMock()
|
|
mock_rpc.close = AsyncMock()
|
|
from omnigent.inner.pi_executor import _PiSessionState
|
|
|
|
executor._session_states["test"] = _PiSessionState(rpc=mock_rpc)
|
|
|
|
await executor.close_session("test")
|
|
mock_rpc.close.assert_called_once()
|
|
self.assertNotIn("test", executor._session_states)
|
|
|
|
_run(_test())
|
|
|
|
def test_enqueue_session_message(self):
|
|
async def _test():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
|
|
mock_rpc = MagicMock()
|
|
mock_rpc.send_command = AsyncMock()
|
|
from omnigent.inner.pi_executor import _PiSessionState
|
|
|
|
executor._session_states["test"] = _PiSessionState(rpc=mock_rpc)
|
|
|
|
result = await executor.enqueue_session_message("test", "STOP")
|
|
self.assertTrue(result)
|
|
mock_rpc.send_command.assert_called_once()
|
|
cmd = mock_rpc.send_command.call_args[0][0]
|
|
self.assertEqual(cmd["type"], "steer")
|
|
self.assertEqual(cmd["message"], "STOP")
|
|
|
|
_run(_test())
|
|
|
|
def test_enqueue_session_message_no_session(self):
|
|
async def _test():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
|
|
result = await executor.enqueue_session_message("nonexistent", "STOP")
|
|
self.assertFalse(result)
|
|
|
|
_run(_test())
|
|
|
|
def test_interrupt_session_aborts_then_drops_session(self):
|
|
"""A user interrupt aborts the turn AND drops the session.
|
|
|
|
Pi resumes the same subprocess on the next turn and sends only the
|
|
latest user message, so a retained session (``_has_sent_prompt``
|
|
still True) would bypass the runner's ``[System: interrupted]``
|
|
marker and continue the abandoned request. Dropping the session
|
|
forces a fresh subprocess that replays full history. An empty
|
|
``_session_states`` is the invariant that prevents the leak.
|
|
"""
|
|
|
|
async def _test():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
|
|
mock_rpc = MagicMock()
|
|
mock_rpc.send_command = AsyncMock()
|
|
mock_rpc.close = AsyncMock()
|
|
from omnigent.inner.pi_executor import _PiSessionState
|
|
|
|
executor._session_states["test"] = _PiSessionState(rpc=mock_rpc)
|
|
|
|
result = await executor.interrupt_session("test")
|
|
self.assertTrue(result)
|
|
# Abort is sent first to halt the in-flight turn.
|
|
mock_rpc.send_command.assert_called_once()
|
|
cmd = mock_rpc.send_command.call_args[0][0]
|
|
self.assertEqual(cmd["type"], "abort")
|
|
# Then the session rpc is closed and the state removed so the next
|
|
# turn starts fresh and replays full history (marker included).
|
|
mock_rpc.close.assert_awaited_once()
|
|
self.assertEqual(executor._session_states, {})
|
|
|
|
_run(_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PiExecutor.close tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClose(unittest.TestCase):
|
|
def test_close_all_sessions_and_tool_server(self):
|
|
async def _test():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
|
|
mock_rpc1 = MagicMock()
|
|
mock_rpc1.close = AsyncMock()
|
|
mock_rpc2 = MagicMock()
|
|
mock_rpc2.close = AsyncMock()
|
|
from omnigent.inner.pi_executor import _PiSessionState
|
|
|
|
executor._session_states["s1"] = _PiSessionState(rpc=mock_rpc1)
|
|
executor._session_states["s2"] = _PiSessionState(rpc=mock_rpc2)
|
|
|
|
mock_tool_server = MagicMock()
|
|
mock_tool_server.stop = AsyncMock()
|
|
executor._tool_server = mock_tool_server
|
|
|
|
await executor.close()
|
|
mock_rpc1.close.assert_called_once()
|
|
mock_rpc2.close.assert_called_once()
|
|
mock_tool_server.stop.assert_called_once()
|
|
|
|
_run(_test())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PiExecutor blocked tool detection tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBlockedToolDetection(unittest.TestCase):
|
|
"""Verify that policy-blocked tool results are detected and mapped to BLOCKED status."""
|
|
|
|
def _make_executor(self):
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
return PiExecutor()
|
|
|
|
def _run_with_events(self, event_lines):
|
|
"""Helper: create a fake RPC session with given event lines and collect events."""
|
|
|
|
async def _test():
|
|
executor = self._make_executor()
|
|
|
|
fake_rpc = _PiRpcSession()
|
|
fake_rpc._line_queue = asyncio.Queue()
|
|
fake_rpc.process = MagicMock()
|
|
fake_rpc.process.returncode = None
|
|
fake_rpc.process.stdin = _FakeStreamWriter()
|
|
fake_rpc._stderr_lines = []
|
|
|
|
for line in event_lines:
|
|
fake_rpc._line_queue.put_nowait(line)
|
|
|
|
async def fake_ensure_rpc(*args, **kwargs):
|
|
return fake_rpc
|
|
|
|
executor._ensure_rpc = fake_ensure_rpc
|
|
|
|
return [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "test"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
return _run(_test())
|
|
|
|
def test_blocked_dict_result(self):
|
|
"""Result is a direct dict with blocked=True."""
|
|
events = self._run_with_events(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "tool_execution_end",
|
|
"toolName": "ping",
|
|
"isError": True,
|
|
"result": {"blocked": True, "reason": "Policy blocked it"},
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
tool_completes = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
self.assertEqual(len(tool_completes), 1)
|
|
self.assertEqual(tool_completes[0].status, ToolCallStatus.BLOCKED)
|
|
self.assertIn("Policy blocked it", tool_completes[0].error)
|
|
|
|
def test_blocked_content_wrapped_result(self):
|
|
"""Result is wrapped in Pi extension format with JSON text."""
|
|
blocked_json = json.dumps({"blocked": True, "reason": "Not allowed"})
|
|
events = self._run_with_events(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "tool_execution_end",
|
|
"toolName": "ping",
|
|
"isError": True,
|
|
"result": {"content": [{"type": "text", "text": blocked_json}]},
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
tool_completes = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
self.assertEqual(len(tool_completes), 1)
|
|
self.assertEqual(tool_completes[0].status, ToolCallStatus.BLOCKED)
|
|
self.assertIn("Not allowed", tool_completes[0].error)
|
|
|
|
def test_blocked_string_result(self):
|
|
"""Result is a JSON string with blocked=True."""
|
|
events = self._run_with_events(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "tool_execution_end",
|
|
"toolName": "ping",
|
|
"isError": True,
|
|
"result": json.dumps({"blocked": True, "reason": "Denied"}),
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
tool_completes = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
self.assertEqual(len(tool_completes), 1)
|
|
self.assertEqual(tool_completes[0].status, ToolCallStatus.BLOCKED)
|
|
self.assertIn("Denied", tool_completes[0].error)
|
|
|
|
def test_blocked_nested_isError_in_result(self):
|
|
"""Pi reports isError:false at top level but result.isError:true with blocked content."""
|
|
blocked_json = json.dumps({"blocked": True, "reason": "Policy says no"})
|
|
events = self._run_with_events(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "tool_execution_end",
|
|
"toolName": "ping",
|
|
"isError": False, # top-level is False!
|
|
"result": {
|
|
"content": [{"type": "text", "text": blocked_json}],
|
|
"isError": True, # nested isError
|
|
},
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
tool_completes = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
self.assertEqual(len(tool_completes), 1)
|
|
self.assertEqual(tool_completes[0].status, ToolCallStatus.BLOCKED)
|
|
self.assertIn("Policy says no", tool_completes[0].error)
|
|
|
|
def test_non_blocked_error_stays_error(self):
|
|
"""A regular error (not blocked) stays as ERROR status."""
|
|
events = self._run_with_events(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "tool_execution_end",
|
|
"toolName": "fail",
|
|
"isError": True,
|
|
"result": "Connection refused",
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
tool_completes = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
self.assertEqual(len(tool_completes), 1)
|
|
self.assertEqual(tool_completes[0].status, ToolCallStatus.ERROR)
|
|
|
|
|
|
def _make_pi_skill_dir(root: Path, name: str) -> Path:
|
|
"""Create a minimal valid skill directory for the resolver tests."""
|
|
skill_dir = root / name
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(f"---\nname: {name}\n---\n# {name}\n")
|
|
return skill_dir
|
|
|
|
|
|
def test_resolve_pi_skill_args_all(tmp_path: Path) -> None:
|
|
"""``skills_filter='all'`` produces ``--skill <path>`` for every
|
|
bundle skill, with NO ``--no-skills`` (Pi's host auto-discovery
|
|
stays on).
|
|
|
|
Mirrors the SDK semantics: ``"all"`` exposes everything Pi can
|
|
discover (host) AND everything the agent ships (bundle).
|
|
Failing this test means the resolver's ``"all"`` branch dropped
|
|
bundle skills, host skills, or both.
|
|
"""
|
|
from omnigent.inner.pi_executor import _resolve_pi_skill_args
|
|
|
|
bundle = tmp_path / "bundle"
|
|
skills_root = bundle / "skills"
|
|
_make_pi_skill_dir(skills_root, "alpha")
|
|
_make_pi_skill_dir(skills_root, "beta")
|
|
|
|
args = _resolve_pi_skill_args("all", bundle)
|
|
|
|
# Two bundle skills must produce two ``--skill`` flags. If 0,
|
|
# the resolver lost the bundle source. If ``--no-skills`` shows
|
|
# up, host discovery would be incorrectly suppressed for "all".
|
|
assert args.count("--skill") == 2, (
|
|
f"expected 2 --skill flags for two bundle skills, got args={args}"
|
|
)
|
|
assert "--no-skills" not in args, (
|
|
f"--no-skills must NOT appear for skills='all'; would suppress "
|
|
f"Pi's host discovery. Got args={args}"
|
|
)
|
|
paths = [args[i + 1] for i, tok in enumerate(args) if tok == "--skill"]
|
|
assert str(skills_root / "alpha") in paths
|
|
assert str(skills_root / "beta") in paths
|
|
|
|
|
|
def test_resolve_pi_skill_args_none(tmp_path: Path) -> None:
|
|
"""``skills_filter='none'`` produces exactly ``['--no-skills']``.
|
|
|
|
No ``--skill`` flags either — explicit paths would override
|
|
``--no-skills`` per Pi's flag semantics, so the hermetic case
|
|
must be empty everywhere.
|
|
"""
|
|
from omnigent.inner.pi_executor import _resolve_pi_skill_args
|
|
|
|
bundle = tmp_path / "bundle"
|
|
skills_root = bundle / "skills"
|
|
_make_pi_skill_dir(skills_root, "alpha")
|
|
|
|
args = _resolve_pi_skill_args("none", bundle)
|
|
|
|
# Exact equality: any leak would either drop --no-skills (Pi
|
|
# would auto-discover) or add stray --skill flags (Pi would
|
|
# load them despite --no-skills).
|
|
assert args == ["--no-skills"], (
|
|
f"skills='none' must produce exactly ['--no-skills']; "
|
|
f"got {args}. Stray --skill flags would override "
|
|
f"--no-skills and load skills anyway."
|
|
)
|
|
|
|
|
|
def test_resolve_pi_skill_args_named_subset(tmp_path: Path) -> None:
|
|
"""``skills_filter=[name, ...]`` produces ``--no-skills`` plus
|
|
one ``--skill <path>`` per named bundle skill.
|
|
|
|
Names not present in the bundle are silently skipped — adding
|
|
a ``--skill`` flag pointing at a non-existent path would crash
|
|
Pi at startup.
|
|
"""
|
|
from omnigent.inner.pi_executor import _resolve_pi_skill_args
|
|
|
|
bundle = tmp_path / "bundle"
|
|
skills_root = bundle / "skills"
|
|
_make_pi_skill_dir(skills_root, "alpha")
|
|
_make_pi_skill_dir(skills_root, "beta")
|
|
|
|
args = _resolve_pi_skill_args(["alpha", "missing_skill"], bundle)
|
|
|
|
# Must start with --no-skills (suppress Pi auto-discovery so
|
|
# only the named skills surface).
|
|
assert args[0] == "--no-skills"
|
|
# Exactly one --skill, pointing at alpha. ``missing_skill`` is
|
|
# silently dropped.
|
|
assert args.count("--skill") == 1
|
|
paths = [args[i + 1] for i, tok in enumerate(args) if tok == "--skill"]
|
|
assert paths == [str(skills_root / "alpha")], (
|
|
f"expected only ['{skills_root}/alpha'], got {paths}. "
|
|
f"If 'beta' appears, the per-name filter is matching too "
|
|
f"broadly. If empty, the resolver dropped a named skill "
|
|
f"that exists."
|
|
)
|
|
|
|
|
|
def test_resolve_pi_skill_args_no_bundle() -> None:
|
|
"""When ``bundle_dir`` is ``None`` the resolver still produces
|
|
sane output: ``[]`` for ``"all"`` (Pi's auto-discovery still
|
|
runs), ``["--no-skills"]`` for the suppression cases.
|
|
|
|
Catches a regression where the resolver would crash on missing
|
|
bundle — the agent would fail to spawn at all.
|
|
"""
|
|
from omnigent.inner.pi_executor import _resolve_pi_skill_args
|
|
|
|
assert _resolve_pi_skill_args("all", None) == []
|
|
assert _resolve_pi_skill_args("none", None) == ["--no-skills"]
|
|
# List with no bundle: just --no-skills, no --skill flags
|
|
# (no source to resolve names against).
|
|
assert _resolve_pi_skill_args(["alpha"], None) == ["--no-skills"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Databricks gateway default-model + models.json parity tests — the pi
|
|
# mirror of the claude-sdk default plumbing. The ucode-cached
|
|
# path gets its default from the producer (workflow.py); these cover the
|
|
# executor's own profile-derived path and the models.json invariants.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_profile_gateway_resolves_databricks_default_model() -> None:
|
|
"""
|
|
On the profile-derived gateway path (no gateway host / base URL — the
|
|
producer's ucode lookup early-returned), a missing model resolves to
|
|
the shared Databricks default instead of ``None``.
|
|
|
|
Failure means pi falls back to its own host default — an
|
|
Anthropic-direct id the Databricks AI gateway rejects, surfacing as a
|
|
model error on the agent's first turn.
|
|
"""
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch(
|
|
"omnigent.inner.pi_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(host="https://h.example.com", token="tok"),
|
|
),
|
|
):
|
|
executor = PiExecutor(gateway=True)
|
|
assert executor._resolve_model(ExecutorConfig(model=None)) == DATABRICKS_CLAUDE_DEFAULT_MODEL
|
|
|
|
|
|
def test_profile_gateway_default_does_not_clobber_explicit_model() -> None:
|
|
"""
|
|
The profile-path default only fills a gap — an explicit constructor
|
|
model (``HARNESS_PI_MODEL``) is used as-is.
|
|
|
|
Failure means the fallback overrides a model the spec or ucode
|
|
state pinned deliberately.
|
|
"""
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch(
|
|
"omnigent.inner.pi_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(host="https://h.example.com", token="tok"),
|
|
),
|
|
):
|
|
executor = PiExecutor(gateway=True, model="databricks-gpt-5-4")
|
|
assert executor._resolve_model(ExecutorConfig(model=None)) == "databricks-gpt-5-4"
|
|
|
|
|
|
def test_ucode_gateway_host_path_does_not_inject_default_model() -> None:
|
|
"""
|
|
On the ucode-cached gateway path (gateway host + auth command supplied
|
|
by the producer) the executor must NOT invent a model: the producer
|
|
already applied the default via ``UcodeHarnessConfig`` — mirrors
|
|
claude-sdk's gating, so the two layers can't fight over precedence.
|
|
|
|
Failure (a non-None resolve here) means the executor would mask
|
|
producer-side model resolution bugs instead of failing visibly.
|
|
"""
|
|
with (
|
|
patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"),
|
|
patch(
|
|
"omnigent.inner.pi_executor._fetch_shell_command_token",
|
|
return_value="command-token",
|
|
),
|
|
):
|
|
executor = PiExecutor(
|
|
gateway=True,
|
|
gateway_host="https://example.databricks.com",
|
|
gateway_auth_command="printf token",
|
|
)
|
|
assert executor._resolve_model(ExecutorConfig(model=None)) is None
|
|
|
|
|
|
def test_non_gateway_path_does_not_inject_default_model() -> None:
|
|
"""
|
|
Off the gateway entirely (direct Anthropic / pi-native auth), a missing
|
|
model stays ``None`` so pi picks its own default — a ``databricks-*``
|
|
id would not resolve outside the gateway's models.json.
|
|
"""
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
assert executor._resolve_model(ExecutorConfig(model=None)) is None
|
|
|
|
|
|
def test_databricks_default_model_is_resolvable_in_models_json() -> None:
|
|
"""
|
|
The shared Databricks default must route to the anthropic provider AND
|
|
be listed in that provider's models — otherwise the default the
|
|
producer/executor inject can't be resolved by pi at spawn time.
|
|
|
|
Failure means the default-model constant and pi's models.json drifted
|
|
apart: every modelless gateway agent would fail its first turn with a
|
|
pi "unknown model" error.
|
|
"""
|
|
assert _pi_provider_for_model(DATABRICKS_CLAUDE_DEFAULT_MODEL) == "databricks-anthropic"
|
|
models = _build_models_json("https://host.example.com", "tok")
|
|
anthropic_ids = [m["id"] for m in models["providers"]["databricks-anthropic"]["models"]]
|
|
assert DATABRICKS_CLAUDE_DEFAULT_MODEL in anthropic_ids
|
|
|
|
|
|
def test_models_json_lists_only_gateway_verified_models() -> None:
|
|
"""
|
|
The hardcoded model lists match the set verified live against the
|
|
Databricks gateway endpoint metadata and the API paths pi uses
|
|
(Anthropic Messages for Claude, OpenAI-compatible serving endpoints for
|
|
GPT).
|
|
|
|
Failure direction matters: a missing working id silently shrinks pi's
|
|
model menu; a reintroduced broken id (``sonnet-4-5-v2`` rejects
|
|
Anthropic passthrough, the llama endpoint 404s) fails at request time
|
|
for anyone who selects it.
|
|
"""
|
|
models = _build_models_json("https://host.example.com", "tok")
|
|
providers = models["providers"]
|
|
anthropic_ids = [m["id"] for m in providers["databricks-anthropic"]["models"]]
|
|
assert anthropic_ids == [
|
|
"databricks-claude-opus-4-8",
|
|
"databricks-claude-sonnet-4-6",
|
|
"databricks-claude-sonnet-4-5",
|
|
]
|
|
openai_ids = [m["id"] for m in providers["databricks"]["models"]]
|
|
assert openai_ids == [
|
|
"databricks-gpt-5-4-mini",
|
|
"databricks-gpt-5-4",
|
|
"databricks-gpt-5-5",
|
|
"databricks-gpt-5-5-pro",
|
|
]
|
|
# The llama serving endpoint no longer exists; the provider stays as
|
|
# the routing home for future non-Claude/GPT endpoints.
|
|
assert providers["databricks-completions"]["models"] == []
|
|
|
|
|
|
def test_models_json_uses_oss_verified_gpt_55_caps() -> None:
|
|
"""GPT-5.5 endpoint metadata on the OSS profile advertises 128K output."""
|
|
models = _build_models_json("https://host.example.com", "tok")
|
|
by_id = {m["id"]: m for m in models["providers"]["databricks"]["models"]}
|
|
for model_id in ("databricks-gpt-5-5", "databricks-gpt-5-5-pro"):
|
|
assert by_id[model_id]["contextWindow"] == 400000
|
|
assert by_id[model_id]["maxTokens"] == 128000
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_models_json: run-model registration (generic gateway models)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_models_json_registers_unknown_model_with_routed_provider() -> None:
|
|
"""A model outside the static Databricks lists is registered so Pi resolves it.
|
|
|
|
Reproduces the OpenRouter failure: ``moonshotai/kimi-k2.6`` routes to
|
|
the ``databricks-completions`` catch-all, whose static model list is
|
|
empty — without registration Pi rejects the
|
|
``databricks-completions/moonshotai/kimi-k2.6`` selector with
|
|
"Model not found" before the first turn. A regression that drops the
|
|
registration brings that startup failure back for every non-Databricks
|
|
gateway model.
|
|
"""
|
|
result = _build_models_json(
|
|
"https://unused.example.com",
|
|
"or-key",
|
|
{"openai": "https://openrouter.ai/api/v1"},
|
|
model="moonshotai/kimi-k2.6",
|
|
)
|
|
completions = result["providers"]["databricks-completions"]
|
|
# The run model is registered (so Pi resolves it) under the provider
|
|
# _pi_provider_for_model routes it to, advertising image input so Pi
|
|
# doesn't strip attached images (#515).
|
|
entry = next((e for e in completions["models"] if e["id"] == "moonshotai/kimi-k2.6"), None)
|
|
assert entry == {"id": "moonshotai/kimi-k2.6", "input": ["text", "image"]}
|
|
# …and that provider points at the generic gateway with the
|
|
# Chat-Completions dialect OpenRouter speaks.
|
|
assert completions["baseUrl"] == "https://openrouter.ai/api/v1"
|
|
assert completions["api"] == "openai-completions"
|
|
# The other providers don't pick up the foreign id.
|
|
assert all(
|
|
m.get("id") != "moonshotai/kimi-k2.6"
|
|
for name in ("databricks", "databricks-anthropic")
|
|
for m in result["providers"][name]["models"]
|
|
)
|
|
|
|
|
|
def test_build_models_json_known_model_not_duplicated_and_lists_not_mutated() -> None:
|
|
"""A model already in a static list is not re-registered, and the static
|
|
module-level lists never absorb a run's model id.
|
|
|
|
The second build (no model) must not contain the first build's foreign
|
|
id — if it does, the registration mutated the shared module-level list
|
|
instead of rebinding, leaking one run's model into every later
|
|
subprocess config.
|
|
"""
|
|
result = _build_models_json(
|
|
"https://host.example.com", "tok", model="databricks-claude-sonnet-4-6"
|
|
)
|
|
anthropic_ids = [m["id"] for m in result["providers"]["databricks-anthropic"]["models"]]
|
|
# Exactly one entry for the already-listed id — no duplicate appended.
|
|
assert anthropic_ids.count("databricks-claude-sonnet-4-6") == 1
|
|
|
|
_build_models_json("https://host.example.com", "tok", model="moonshotai/kimi-k2.6")
|
|
fresh = _build_models_json("https://host.example.com", "tok")
|
|
# A model-less build after a foreign-model build is pristine: empty
|
|
# catch-all list, exactly the static Databricks ids elsewhere.
|
|
assert fresh["providers"]["databricks-completions"]["models"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _clean_pi_env + spawn-env isolation tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_clean_pi_env_excludes_host_secrets(monkeypatch) -> None:
|
|
"""Host/server credentials never pass the Pi env allowlist by default.
|
|
|
|
The Pi spawn previously merged the full ``os.environ`` into the
|
|
subprocess, so server-side credentials (cloud tokens, Databricks
|
|
PATs, provider API keys) were readable inside the (sandboxed) Pi
|
|
process.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner.pi_executor import _clean_pi_env
|
|
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "dapi-secret")
|
|
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
|
monkeypatch.setenv("FAKE_HOST_SECRET", "PWNED")
|
|
monkeypatch.setenv("HOME", "/home/tester")
|
|
monkeypatch.setenv("PATH", "/usr/bin")
|
|
|
|
env = _clean_pi_env()
|
|
|
|
# None of these match an allowlist entry; any one appearing means
|
|
# the allowlist regressed to a denylist or an environ passthrough.
|
|
assert "DATABRICKS_TOKEN" not in env
|
|
assert "AWS_SECRET_ACCESS_KEY" not in env
|
|
assert "ANTHROPIC_API_KEY" not in env
|
|
assert "FAKE_HOST_SECRET" not in env
|
|
# The basics a node CLI needs still pass through (PATH resolves the
|
|
# ``#!/usr/bin/env node`` shebang; HOME locates ~/.pi).
|
|
assert env.get("HOME") == "/home/tester"
|
|
assert env.get("PATH") == "/usr/bin"
|
|
|
|
|
|
def test_clean_pi_env_extra_allowed_is_exact_opt_in(monkeypatch) -> None:
|
|
"""``extra_allowed`` admits exactly the named variables, nothing more.
|
|
|
|
This is the ``os_env.sandbox.env_passthrough`` hook — e.g. a
|
|
direct (non-gateway) run that authenticates Pi via
|
|
``ANTHROPIC_API_KEY`` names it in the spec. Other credentials in
|
|
the host env must stay excluded.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner.pi_executor import _clean_pi_env
|
|
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
|
monkeypatch.setenv("DATABRICKS_TOKEN", "dapi-secret")
|
|
|
|
env = _clean_pi_env(["ANTHROPIC_API_KEY"])
|
|
|
|
# The opted-in key passes through with its value intact…
|
|
assert env.get("ANTHROPIC_API_KEY") == "sk-ant-test"
|
|
# …without widening the allowlist for anything else.
|
|
assert "DATABRICKS_TOKEN" not in env
|
|
|
|
|
|
def test_clean_pi_env_passes_pi_and_proxy_config(monkeypatch) -> None:
|
|
"""Pi's own config and proxy/TLS settings survive the scrub.
|
|
|
|
These are the categories the Pi CLI actually reads (``PI_*`` knobs,
|
|
proxy env, node's CA override) — dropping them would break
|
|
corp-proxy and custom-agent-dir setups.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner.pi_executor import _clean_pi_env
|
|
|
|
monkeypatch.setenv("PI_SKIP_VERSION_CHECK", "1")
|
|
monkeypatch.setenv("HTTPS_PROXY", "http://proxy:8080")
|
|
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/etc/ssl/corp-ca.pem")
|
|
|
|
env = _clean_pi_env()
|
|
|
|
assert env.get("PI_SKIP_VERSION_CHECK") == "1"
|
|
assert env.get("HTTPS_PROXY") == "http://proxy:8080"
|
|
assert env.get("NODE_EXTRA_CA_CERTS") == "/etc/ssl/corp-ca.pem"
|
|
|
|
|
|
def test_clean_pi_env_includes_omnigent_session_marker(monkeypatch) -> None:
|
|
"""The ``OMNIGENT`` session marker survives the Pi env scrub.
|
|
|
|
The marker (set once on the runner) must reach the Pi CLI so the
|
|
shell commands Pi runs can detect they are inside an Omnigent
|
|
session, like ``CLAUDE_CODE`` / ``CODEX``.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner.pi_executor import _clean_pi_env
|
|
from omnigent.runner.identity import (
|
|
OMNIGENT_SESSION_ENV_VALUE,
|
|
OMNIGENT_SESSION_ENV_VAR,
|
|
)
|
|
|
|
monkeypatch.setenv(OMNIGENT_SESSION_ENV_VAR, OMNIGENT_SESSION_ENV_VALUE)
|
|
|
|
env = _clean_pi_env()
|
|
|
|
assert env.get(OMNIGENT_SESSION_ENV_VAR) == OMNIGENT_SESSION_ENV_VALUE
|
|
|
|
|
|
def test_rpc_start_spawns_with_exact_env(monkeypatch) -> None:
|
|
"""``_PiRpcSession.start`` passes the caller's env dict verbatim.
|
|
|
|
Guards the spawn-site fix: the old code spawned with
|
|
``env={**os.environ, **env}``, so every host env var leaked into
|
|
the Pi subprocess. If that merge is reintroduced, the seeded
|
|
``FAKE_HOST_SECRET`` appears in the captured spawn env and the
|
|
equality assertion fails.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner import pi_executor as pi_mod
|
|
|
|
monkeypatch.setenv("FAKE_HOST_SECRET", "PWNED")
|
|
captured: dict[str, dict[str, str]] = {}
|
|
|
|
async def _fake_spawn(*args, **kwargs):
|
|
captured["env"] = kwargs["env"]
|
|
return _FakeProcess(stdout_lines=[], stderr_lines=[])
|
|
|
|
monkeypatch.setattr(pi_mod, "_create_subprocess_exec", _fake_spawn)
|
|
|
|
async def _test():
|
|
rpc = _PiRpcSession()
|
|
await rpc.start(
|
|
"/fake/pi",
|
|
env={"PATH": "/usr/bin", "PI_CODING_AGENT_DIR": "/tmp/pi-agent"},
|
|
)
|
|
await rpc.close()
|
|
|
|
_run(_test())
|
|
|
|
# Exactly the executor-built env — nothing merged from os.environ.
|
|
assert captured["env"] == {"PATH": "/usr/bin", "PI_CODING_AGENT_DIR": "/tmp/pi-agent"}
|
|
|
|
|
|
def test_redact_argv_for_log_hides_system_prompt() -> None:
|
|
"""``_redact_argv_for_log`` replaces the system-prompt value with a
|
|
length-only placeholder while leaving every other flag visible."""
|
|
secret = "SUPER SECRET SYSTEM PROMPT that must never hit the logs"
|
|
args = [
|
|
"/fake/pi",
|
|
"--mode",
|
|
"rpc",
|
|
"--no-session",
|
|
"--model",
|
|
"databricks/some-model",
|
|
"--append-system-prompt",
|
|
secret,
|
|
"--extension",
|
|
"/tmp/ext.js",
|
|
]
|
|
|
|
redacted = _redact_argv_for_log(args)
|
|
|
|
rendered = " ".join(redacted)
|
|
assert secret not in rendered
|
|
assert f"[system prompt {len(secret)} chars]" in redacted
|
|
# Other flags stay visible for debugging.
|
|
assert "--mode" in redacted
|
|
assert "rpc" in redacted
|
|
assert "--model" in redacted
|
|
assert "databricks/some-model" in redacted
|
|
assert "--extension" in redacted
|
|
assert "/tmp/ext.js" in redacted
|
|
|
|
|
|
def test_redact_argv_for_log_hides_two_token_system_prompt_flag() -> None:
|
|
"""The two-token ``--system-prompt <value>`` form is redacted too, not just
|
|
``--append-system-prompt``."""
|
|
secret = "REPLACEMENT SYSTEM PROMPT that must never hit the logs"
|
|
args = ["/fake/pi", "--system-prompt", secret, "--mode", "rpc"]
|
|
|
|
redacted = _redact_argv_for_log(args)
|
|
|
|
assert secret not in " ".join(redacted)
|
|
assert f"[system prompt {len(secret)} chars]" in redacted
|
|
assert "--system-prompt" in redacted
|
|
assert "--mode" in redacted
|
|
assert "rpc" in redacted
|
|
|
|
|
|
def test_redact_argv_for_log_hides_equals_joined_system_prompt() -> None:
|
|
"""``_redact_argv_for_log`` redacts the equals-joined
|
|
``--append-system-prompt=<value>`` / ``--system-prompt=<value>`` forms while
|
|
keeping the flag name visible."""
|
|
secret = "SUPER SECRET SYSTEM PROMPT that must never hit the logs"
|
|
for flag in ("--append-system-prompt", "--system-prompt"):
|
|
args = ["/fake/pi", "--mode", "rpc", f"{flag}={secret}", "--extension", "/tmp/ext.js"]
|
|
|
|
redacted = _redact_argv_for_log(args)
|
|
|
|
rendered = " ".join(redacted)
|
|
assert secret not in rendered
|
|
assert f"{flag}=[system prompt {len(secret)} chars]" in redacted
|
|
# Flag name and other tokens stay visible for debugging.
|
|
assert "--mode" in redacted
|
|
assert "rpc" in redacted
|
|
assert "--extension" in redacted
|
|
assert "/tmp/ext.js" in redacted
|
|
|
|
|
|
def test_rpc_start_log_does_not_leak_system_prompt(monkeypatch, caplog) -> None:
|
|
"""``_PiRpcSession.start`` must not write the full ``--append-system-prompt``
|
|
value to the debug log; it should be redacted to a length placeholder.
|
|
|
|
Guards F92: the old code logged ``" ".join(args)`` verbatim, leaking the
|
|
entire system prompt into debug logs.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param caplog: Pytest log-capture fixture.
|
|
"""
|
|
from omnigent.inner import pi_executor as pi_mod
|
|
|
|
test_prompt = "TOP-SECRET-SYSTEM-PROMPT-DO-NOT-LOG-12345"
|
|
|
|
async def _fake_spawn(*args, **kwargs):
|
|
return _FakeProcess(stdout_lines=[], stderr_lines=[])
|
|
|
|
monkeypatch.setattr(pi_mod, "_create_subprocess_exec", _fake_spawn)
|
|
|
|
async def _test():
|
|
rpc = _PiRpcSession()
|
|
await rpc.start(
|
|
"/fake/pi",
|
|
env={"PATH": "/usr/bin"},
|
|
model="some-model",
|
|
system_prompt=test_prompt,
|
|
extra_args=["--extension", "/tmp/ext.js"],
|
|
)
|
|
await rpc.close()
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="omnigent.inner.pi_executor"):
|
|
_run(_test())
|
|
|
|
spawn_logs = [
|
|
r.getMessage() for r in caplog.records if "PiExecutor: spawning" in r.getMessage()
|
|
]
|
|
assert spawn_logs, "expected a 'PiExecutor: spawning' debug log line"
|
|
spawn_line = spawn_logs[0]
|
|
|
|
assert test_prompt not in spawn_line
|
|
assert f"[system prompt {len(test_prompt)} chars]" in spawn_line
|
|
# Non-sensitive flags remain visible for debugging.
|
|
assert "--mode" in spawn_line
|
|
assert "--extension" in spawn_line
|
|
|
|
|
|
def test_run_turn_spawn_log_redacts_system_prompt_end_to_end(monkeypatch, caplog) -> None:
|
|
"""The normal ``PiExecutor.run_turn`` path must pass the system prompt to
|
|
Pi without leaking it into the spawn debug log.
|
|
|
|
This drives the real executor wiring from ``run_turn`` through
|
|
``_ensure_rpc`` and ``_PiRpcSession.start`` with only subprocess creation
|
|
stubbed, so it catches regressions at the behavior boundary users hit.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param caplog: Pytest log-capture fixture.
|
|
"""
|
|
from omnigent.inner import pi_executor as pi_mod
|
|
|
|
test_prompt = "END-TO-END-SYSTEM-PROMPT-LEAK-SENTINEL-67890"
|
|
captured: dict[str, list[str]] = {}
|
|
|
|
async def _fake_spawn(*args, **kwargs):
|
|
captured["argv"] = list(args)
|
|
return _FakeProcess(
|
|
stdout_lines=[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {"type": "text_delta", "delta": "hi"},
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
],
|
|
stderr_lines=[],
|
|
)
|
|
|
|
monkeypatch.setattr(pi_mod, "_create_subprocess_exec", _fake_spawn)
|
|
|
|
async def _test():
|
|
executor = PiExecutor(pi_path="/usr/bin/pi")
|
|
try:
|
|
return [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
test_prompt,
|
|
)
|
|
]
|
|
finally:
|
|
await executor.close()
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="omnigent.inner.pi_executor"):
|
|
events = _run(_test())
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
assert turn_complete[0].response == "hi"
|
|
|
|
argv = captured["argv"]
|
|
assert "--append-system-prompt" in argv
|
|
assert argv[argv.index("--append-system-prompt") + 1] == test_prompt
|
|
|
|
spawn_logs = [
|
|
r.getMessage() for r in caplog.records if "PiExecutor: spawning" in r.getMessage()
|
|
]
|
|
assert spawn_logs, "expected a 'PiExecutor: spawning' debug log line"
|
|
spawn_line = spawn_logs[0]
|
|
|
|
assert test_prompt not in spawn_line
|
|
assert f"[system prompt {len(test_prompt)} chars]" in spawn_line
|
|
assert "--append-system-prompt" in spawn_line
|
|
assert "--mode" in spawn_line
|
|
|
|
|
|
def test_run_turn_spawn_env_has_no_host_secrets(monkeypatch) -> None:
|
|
"""A host secret seeded in ``os.environ`` never reaches the spawned
|
|
Pi process through the full real path (reproduces the leak PoC).
|
|
|
|
Unlike the unit tests above, this drives ``run_turn`` through the
|
|
REAL ``_ensure_rpc`` → ``_build_env_and_dir`` → ``start`` chain with
|
|
only the module-level subprocess seam stubbed, so it fails if ANY
|
|
layer regresses (``__init__`` reverting to ``os.environ.copy()``,
|
|
the spawn merge coming back, etc.).
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner import pi_executor as pi_mod
|
|
|
|
monkeypatch.setenv("FAKE_HOST_SECRET", "PWNED")
|
|
captured: dict[str, dict[str, str]] = {}
|
|
|
|
async def _fake_spawn(*args, **kwargs):
|
|
captured["env"] = kwargs["env"]
|
|
return _FakeProcess(
|
|
stdout_lines=[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {"type": "text_delta", "delta": "hi"},
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
],
|
|
stderr_lines=[],
|
|
)
|
|
|
|
monkeypatch.setattr(pi_mod, "_create_subprocess_exec", _fake_spawn)
|
|
|
|
async def _test():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
try:
|
|
return [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
finally:
|
|
await executor.close()
|
|
|
|
events = _run(_test())
|
|
|
|
# The turn really ran end-to-end (not an early error short-circuit):
|
|
# the fake Pi's text made it through the event pipeline.
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
assert turn_complete[0].response == "hi"
|
|
# The PoC invariant: the seeded host secret is absent from the env
|
|
# the real executor handed to the spawn. PATH proves the allowlist
|
|
# base populated (an empty env would also "pass" the absence check).
|
|
assert "FAKE_HOST_SECRET" not in captured["env"]
|
|
assert captured["env"].get("PATH") == os.environ["PATH"]
|
|
|
|
|
|
def test_run_turn_spawn_env_honors_spec_env_passthrough(monkeypatch) -> None:
|
|
"""``os_env.sandbox.env_passthrough`` names reach the spawned Pi env.
|
|
|
|
The opt-in counterpart to the scrub test above: a spec that
|
|
declares ``env_passthrough: ["MY_OPTED_TOKEN"]`` gets exactly that
|
|
variable inside the Pi process, while undeclared host secrets stay
|
|
out. Fails if ``PiExecutor.__init__`` stops threading the spec's
|
|
passthrough list into ``_clean_pi_env``.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner import pi_executor as pi_mod
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
monkeypatch.setenv("MY_OPTED_TOKEN", "opted-in-value")
|
|
monkeypatch.setenv("FAKE_HOST_SECRET", "PWNED")
|
|
captured: dict[str, dict[str, str]] = {}
|
|
|
|
async def _fake_spawn(*args, **kwargs):
|
|
captured["env"] = kwargs["env"]
|
|
return _FakeProcess(
|
|
stdout_lines=[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
],
|
|
stderr_lines=[],
|
|
)
|
|
|
|
monkeypatch.setattr(pi_mod, "_create_subprocess_exec", _fake_spawn)
|
|
|
|
async def _test():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
# ``type="none"`` skips the sandbox wrap so the test stays
|
|
# platform-independent; env scrubbing applies either way.
|
|
executor = PiExecutor(
|
|
os_env=OSEnvSpec(
|
|
sandbox=OSEnvSandboxSpec(type="none", env_passthrough=["MY_OPTED_TOKEN"]),
|
|
),
|
|
)
|
|
try:
|
|
return [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
finally:
|
|
await executor.close()
|
|
|
|
events = _run(_test())
|
|
|
|
# The turn completed (the spawn path actually ran, not an error exit).
|
|
assert any(isinstance(e, TurnComplete) for e in events)
|
|
# Declared name passes through with its host value; undeclared
|
|
# secrets are still scrubbed.
|
|
assert captured["env"].get("MY_OPTED_TOKEN") == "opted-in-value"
|
|
assert "FAKE_HOST_SECRET" not in captured["env"]
|
|
|
|
|
|
def test_pi_sandbox_launcher_policy_carries_spawn_env_allowlist(monkeypatch, tmp_path) -> None:
|
|
"""The sandbox launcher policy names exactly the env the executor spawns.
|
|
|
|
Defense in depth: ``_clean_pi_env`` already filters the spawn env,
|
|
and the launcher (``run_launcher``) additionally prunes its
|
|
inherited environment to ``SandboxPolicy.spawn_env_allowlist``.
|
|
This test pins the wiring between the two — if ``PiExecutor`` stops
|
|
passing ``spawn_env_names`` (or drops ``PI_CODING_AGENT_DIR``, which
|
|
only joins the env per-spawn on the gateway path), the launcher
|
|
prune would strip vars the executor deliberately set, silently
|
|
breaking sandboxed gateway runs.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param tmp_path: Pytest tmp dir used as the sandbox cwd so the
|
|
policy resolve walks a tiny tree.
|
|
"""
|
|
from omnigent.inner import sandbox as sandbox_mod
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
from omnigent.inner.sandbox import SandboxPolicy
|
|
|
|
monkeypatch.setenv("FAKE_HOST_SECRET", "PWNED")
|
|
captured: dict[str, SandboxPolicy] = {}
|
|
|
|
def _fake_create_exec_launcher(target_path: str, sandbox: SandboxPolicy) -> str:
|
|
captured["policy"] = sandbox
|
|
return "/fake/launcher"
|
|
|
|
def _fake_resolve_sandbox(_os_env: OSEnvSpec, cwd: Path) -> SandboxPolicy:
|
|
return SandboxPolicy(
|
|
backend_type="linux_bwrap",
|
|
active=True,
|
|
read_roots=[cwd.resolve(strict=False)],
|
|
write_roots=[cwd.resolve(strict=False)],
|
|
write_files=[],
|
|
allow_network=False,
|
|
)
|
|
|
|
# ``_try_sandbox_pi`` resolves this name from the module at call
|
|
# time (function-local ``from .sandbox import ...``), so patching
|
|
# the module attribute intercepts the real call.
|
|
monkeypatch.setattr(sandbox_mod, "resolve_sandbox", _fake_resolve_sandbox)
|
|
monkeypatch.setattr(sandbox_mod, "create_exec_launcher", _fake_create_exec_launcher)
|
|
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor(
|
|
cwd=str(tmp_path),
|
|
os_env=OSEnvSpec(sandbox=OSEnvSandboxSpec(type="linux_bwrap")),
|
|
)
|
|
|
|
# The launcher wrap actually engaged (not the silent no-sandbox
|
|
# fallback in ``_try_sandbox_pi``'s except clause).
|
|
assert executor._sandboxed is True
|
|
assert executor._pi_launch_path == "/fake/launcher"
|
|
allowlist = captured["policy"].spawn_env_allowlist
|
|
assert allowlist is not None
|
|
# Per-spawn gateway var must be pruneproof even though it is not in
|
|
# the clean base env yet at construction time.
|
|
assert "PI_CODING_AGENT_DIR" in allowlist
|
|
# A clean-env staple proves the executor's deliberate env names got
|
|
# baked in (an empty allowlist would prune PATH and break node).
|
|
assert "PATH" in allowlist
|
|
# The host secret is not in the clean env, so it must not be in the
|
|
# launcher's keep-set either.
|
|
assert "FAKE_HOST_SECRET" not in allowlist
|
|
|
|
|
|
def test_run_turn_bridge_extension_carries_live_server_token(monkeypatch) -> None:
|
|
"""The generated bridge extension carries the live server's token
|
|
through the full ``run_turn`` → ``_ensure_tool_server`` →
|
|
``_ensure_rpc`` → ``_build_env_and_dir`` chain.
|
|
|
|
Bridging a tool starts a real ``_ToolServer`` with its own minted
|
|
token; this drives the real wiring (only the subprocess seam stubbed)
|
|
and reads the on-disk extension. If ``_ensure_rpc`` passed a
|
|
stale/blank token, the embedded ``TOKEN`` would no longer equal the
|
|
server's secret — so this asserts they match exactly.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
from omnigent.inner import pi_executor as pi_mod
|
|
|
|
captured: dict[str, str] = {}
|
|
|
|
async def _fake_spawn(*args, **kwargs):
|
|
# argv carries ``--extension <path>``; read the file while it
|
|
# still exists (the tmp dir is cleaned on executor.close()).
|
|
argv = list(args)
|
|
ext_path = argv[argv.index("--extension") + 1]
|
|
with open(ext_path) as f:
|
|
captured["extension"] = f.read()
|
|
return _FakeProcess(
|
|
stdout_lines=[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
],
|
|
stderr_lines=[],
|
|
)
|
|
|
|
monkeypatch.setattr(pi_mod, "_create_subprocess_exec", _fake_spawn)
|
|
|
|
tools = [{"name": "lookup", "description": "x", "parameters": {"type": "object"}}]
|
|
|
|
async def _test():
|
|
with patch("omnigent.inner.pi_executor._find_pi_cli", return_value="/usr/bin/pi"):
|
|
executor = PiExecutor()
|
|
try:
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi"}],
|
|
tools,
|
|
"system",
|
|
)
|
|
]
|
|
# Capture the live server token before close() tears it down.
|
|
assert executor._tool_server is not None
|
|
return events, executor._tool_server.token
|
|
finally:
|
|
await executor.close()
|
|
|
|
events, live_token = _run(_test())
|
|
|
|
# The turn ran end-to-end (the spawn path executed, extension written).
|
|
assert any(isinstance(e, TurnComplete) for e in events)
|
|
# The token embedded in the bridge must be the SERVER's actual secret —
|
|
# a mismatch (or empty token) would make Pi's tool calls unauthorized.
|
|
assert f"const TOKEN = {json.dumps(live_token)};" in captured["extension"]
|
|
# Sanity: a freshly minted token is non-trivial (not "" or a stub).
|
|
assert len(live_token) >= 40
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pi token-usage → TurnComplete.usage tests
|
|
#
|
|
# pi (``@mariozechner/pi-coding-agent``) forwards assistant messages whose
|
|
# ``usage`` object carries ``input`` / ``output`` / ``cacheRead`` /
|
|
# ``cacheWrite`` / ``totalTokens`` token counts (plus a ``cost`` breakdown),
|
|
# and the message itself carries the resolved ``model``. The executor maps
|
|
# those onto omnigent's usage schema so pi sub-agent cost is priced the same
|
|
# way as ``claude-sdk`` and ``codex`` turns. These tests assert the MAPPED
|
|
# values, not just presence, so a wrong field mapping fails loud.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _pi_assistant_message_with_usage(
|
|
*,
|
|
text: str = "Done.",
|
|
model: str | None = "claude-sonnet-4-6",
|
|
input_tokens: int = 1200,
|
|
output_tokens: int = 350,
|
|
cache_read: int = 800,
|
|
cache_write: int = 64,
|
|
total_tokens: int = 2414,
|
|
) -> dict[str, object]:
|
|
"""
|
|
Build a realistic pi assistant message dict carrying a ``usage``
|
|
object, mirroring pi-ai's ``AssistantMessage`` / ``Usage`` shape.
|
|
|
|
:returns: A message dict suitable for ``event["message"]`` on a
|
|
``message_end`` event or an entry in ``event["messages"]`` on
|
|
``agent_end``.
|
|
"""
|
|
msg: dict[str, object] = {
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": text}],
|
|
"stopReason": "stop",
|
|
"usage": {
|
|
"input": input_tokens,
|
|
"output": output_tokens,
|
|
"cacheRead": cache_read,
|
|
"cacheWrite": cache_write,
|
|
"totalTokens": total_tokens,
|
|
# pi also forwards a per-field cost breakdown; the executor
|
|
# ignores it (omnigent prices from token counts), but include
|
|
# it so the fixture matches the real wire shape.
|
|
"cost": {
|
|
"input": 0.0036,
|
|
"output": 0.00525,
|
|
"cacheRead": 0.00024,
|
|
"cacheWrite": 0.00024,
|
|
"total": 0.00933,
|
|
},
|
|
},
|
|
}
|
|
if model is not None:
|
|
msg["model"] = model
|
|
return msg
|
|
|
|
|
|
def test_pi_usage_captured_from_message_end() -> None:
|
|
"""
|
|
A ``message_end`` event whose assistant message carries a ``usage``
|
|
object surfaces on ``TurnComplete.usage`` with each pi field mapped to
|
|
the omnigent schema key. Asserts the actual numbers so a swapped or
|
|
dropped mapping (e.g. cacheRead→cache_creation instead of cache_read)
|
|
fails loud rather than passing on mere presence.
|
|
"""
|
|
|
|
async def _test() -> None:
|
|
executor = _executor_with_scripted_rpc(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {"type": "text_delta", "delta": "Done."},
|
|
}
|
|
),
|
|
json.dumps({"type": "message_end", "message": _pi_assistant_message_with_usage()}),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
usage = turn_complete[0].usage
|
|
# usage must be populated — None here means the message_end capture
|
|
# site never ran or the mapping returned None for a real usage dict.
|
|
assert usage is not None, "pi usage was not threaded onto TurnComplete"
|
|
# Each value proves a specific pi-field → omnigent-key mapping:
|
|
assert usage["input_tokens"] == 1200 # <- usage.input
|
|
assert usage["output_tokens"] == 350 # <- usage.output
|
|
assert usage["total_tokens"] == 2414 # <- usage.totalTokens
|
|
assert usage["cache_read_input_tokens"] == 800 # <- usage.cacheRead
|
|
assert usage["cache_creation_input_tokens"] == 64 # <- usage.cacheWrite
|
|
# model comes from the assistant message (used for cost pricing).
|
|
assert usage["model"] == "claude-sonnet-4-6"
|
|
# The text still streams through unaffected by usage capture.
|
|
assert turn_complete[0].response == "Done."
|
|
|
|
_run(_test())
|
|
|
|
|
|
def test_pi_usage_fallback_from_agent_end() -> None:
|
|
"""
|
|
When no ``message_end`` carried usage, the ``agent_end`` handler falls
|
|
back to the last assistant message in ``event["messages"]``. Asserts
|
|
the mapped numbers so the fallback path is proven to map, not just to
|
|
set a non-None dict.
|
|
"""
|
|
|
|
async def _test() -> None:
|
|
executor = _executor_with_scripted_rpc(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
# No message_end frame — usage must come from agent_end.
|
|
json.dumps(
|
|
{
|
|
"type": "agent_end",
|
|
"messages": [
|
|
{"role": "user", "content": "hello"},
|
|
_pi_assistant_message_with_usage(
|
|
text="Answer.",
|
|
input_tokens=42,
|
|
output_tokens=7,
|
|
cache_read=0,
|
|
cache_write=0,
|
|
total_tokens=49,
|
|
),
|
|
],
|
|
}
|
|
),
|
|
]
|
|
)
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
usage = turn_complete[0].usage
|
|
# None means the agent_end fallback never scanned messages for usage.
|
|
assert usage is not None, "agent_end usage fallback did not fire"
|
|
assert usage["input_tokens"] == 42 # <- usage.input from agent_end msg
|
|
assert usage["output_tokens"] == 7 # <- usage.output
|
|
assert usage["total_tokens"] == 49 # <- usage.totalTokens
|
|
# Zero cache fields are still mapped (guarded with ``or 0``).
|
|
assert usage["cache_read_input_tokens"] == 0
|
|
assert usage["cache_creation_input_tokens"] == 0
|
|
assert usage["model"] == "claude-sonnet-4-6"
|
|
# Response text also derives from the agent_end assistant message.
|
|
assert turn_complete[0].response == "Answer."
|
|
|
|
_run(_test())
|
|
|
|
|
|
def test_pi_usage_model_falls_back_to_configured_model() -> None:
|
|
"""
|
|
When the assistant message omits ``model``, the usage ``model`` falls
|
|
back to the executor's configured model so cost pricing still has a
|
|
model id. Proves the fallback, not the message-supplied value.
|
|
"""
|
|
|
|
async def _test() -> None:
|
|
executor = _executor_with_scripted_rpc(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "message_end",
|
|
"message": _pi_assistant_message_with_usage(model=None),
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
],
|
|
model="databricks-claude-sonnet-4-6",
|
|
)
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
usage = turn_complete[0].usage
|
|
assert usage is not None
|
|
# The message had no "model" key, so the executor's configured
|
|
# model must fill in. A wrong value means the fallback was skipped.
|
|
assert usage["model"] == "databricks-claude-sonnet-4-6"
|
|
|
|
_run(_test())
|
|
|
|
|
|
def test_pi_usage_sums_across_multiple_message_end() -> None:
|
|
"""
|
|
A multi-step turn (a tool-use loop emits one ``message_end`` per LLM
|
|
call) sums token counts across every call for billing, while
|
|
``context_tokens`` reflects ONLY the last call's total.
|
|
|
|
This is the behavior that makes pi cost match claude-sdk / codex /
|
|
openai-agents on tool-loop turns: a regression that kept only the last
|
|
``message_end`` (the original single-capture behavior) would undercount
|
|
``input_tokens`` as 1200 instead of the summed 2200, so this asserts
|
|
the sum explicitly.
|
|
"""
|
|
|
|
async def _test() -> None:
|
|
executor = _executor_with_scripted_rpc(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
# First LLM call: model decides to call a tool.
|
|
json.dumps(
|
|
{
|
|
"type": "message_end",
|
|
"message": _pi_assistant_message_with_usage(
|
|
text="",
|
|
input_tokens=1000,
|
|
output_tokens=200,
|
|
cache_read=500,
|
|
cache_write=50,
|
|
total_tokens=1750,
|
|
),
|
|
}
|
|
),
|
|
# Second LLM call: model produces the final answer.
|
|
json.dumps(
|
|
{
|
|
"type": "message_end",
|
|
"message": _pi_assistant_message_with_usage(
|
|
text="Done.",
|
|
input_tokens=1200,
|
|
output_tokens=350,
|
|
cache_read=800,
|
|
cache_write=64,
|
|
total_tokens=2414,
|
|
),
|
|
}
|
|
),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
usage = turn_complete[0].usage
|
|
assert usage is not None, "usage was not aggregated across message_end events"
|
|
# Summed across both calls — last-only capture would give 1200 / 350.
|
|
assert usage["input_tokens"] == 2200 # 1000 + 1200
|
|
assert usage["output_tokens"] == 550 # 200 + 350
|
|
assert usage["total_tokens"] == 4164 # 1750 + 2414
|
|
assert usage["cache_read_input_tokens"] == 1300 # 500 + 800
|
|
assert usage["cache_creation_input_tokens"] == 114 # 50 + 64
|
|
# context_tokens is the LAST call's total only (proxy for next-request
|
|
# context fill); summing it would double-count re-sent history. If
|
|
# this equals 4164 (the summed total), the last-call rule regressed.
|
|
assert usage["context_tokens"] == 2414
|
|
|
|
_run(_test())
|
|
|
|
|
|
def test_pi_turn_without_usage_leaves_usage_none() -> None:
|
|
"""
|
|
A turn whose pi events never carry a ``usage`` object completes with
|
|
``TurnComplete.usage`` left as ``None`` (cost tracking simply skipped),
|
|
and the response text is unaffected. Guards against fabricating a
|
|
zero-filled usage dict when pi reports nothing.
|
|
"""
|
|
|
|
async def _test() -> None:
|
|
executor = _executor_with_scripted_rpc(
|
|
[
|
|
json.dumps({"type": "response", "success": True}),
|
|
json.dumps(
|
|
{
|
|
"type": "message_update",
|
|
"assistantMessageEvent": {"type": "text_delta", "delta": "Hi there"},
|
|
}
|
|
),
|
|
# message_end with no usage object, then a usage-less agent_end.
|
|
json.dumps({"type": "message_end", "message": {"stopReason": "stop"}}),
|
|
json.dumps({"type": "agent_end", "messages": []}),
|
|
]
|
|
)
|
|
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"system",
|
|
)
|
|
]
|
|
|
|
turn_complete = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_complete) == 1
|
|
# No usage anywhere → usage stays None rather than a zero-filled dict.
|
|
assert turn_complete[0].usage is None
|
|
# The turn still completes normally with its streamed text.
|
|
assert turn_complete[0].response == "Hi there"
|
|
|
|
_run(_test())
|