3785 lines
145 KiB
Python
3785 lines
145 KiB
Python
"""Tests for ClaudeSDKExecutor."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from omnigent.inner.claude_sdk_executor import _to_anthropic_content_blocks
|
|
from omnigent.inner.executor import (
|
|
ExecutorError,
|
|
TextChunk,
|
|
ToolCallComplete,
|
|
ToolCallRequest,
|
|
ToolCallStatus,
|
|
TurnComplete,
|
|
)
|
|
|
|
|
|
def _run(coro):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Prompt extraction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPromptExtraction(unittest.TestCase):
|
|
def _make_executor(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
return ClaudeSDKExecutor()
|
|
|
|
def test_resumed_session_uses_last_user_message(self):
|
|
executor = self._make_executor()
|
|
messages = [
|
|
{"role": "user", "content": "First question"},
|
|
{"role": "assistant", "content": "First answer"},
|
|
{"role": "user", "content": "Second question"},
|
|
]
|
|
self.assertEqual(
|
|
executor._build_prompt(messages, resume_session=True),
|
|
"Second question",
|
|
)
|
|
|
|
def test_empty_messages(self):
|
|
executor = self._make_executor()
|
|
self.assertEqual(executor._build_prompt([], resume_session=False), "")
|
|
|
|
def test_no_user_messages(self):
|
|
executor = self._make_executor()
|
|
messages = [{"role": "assistant", "content": "Hello"}]
|
|
self.assertEqual(executor._build_prompt(messages, resume_session=False), "")
|
|
|
|
def test_dict_content_converted(self):
|
|
executor = self._make_executor()
|
|
messages = [{"role": "user", "content": {"key": "value"}}]
|
|
self.assertIn("key", executor._build_prompt(messages, resume_session=False))
|
|
|
|
def test_fresh_session_with_history_serializes_context(self):
|
|
executor = self._make_executor()
|
|
messages = [
|
|
{"role": "user", "content": "The secret codeword is ZEBRA-99."},
|
|
{"role": "assistant", "content": "Understood."},
|
|
{"role": "user", "content": "Summarize our conversation."},
|
|
]
|
|
prompt = executor._build_prompt(messages, resume_session=False)
|
|
self.assertIn("Conversation so far:", prompt)
|
|
self.assertIn("ZEBRA-99", prompt)
|
|
self.assertIn("Summarize our conversation.", prompt)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Constructor and properties
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConstructor(unittest.TestCase):
|
|
def test_default_values(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.spec.types import RetryPolicy
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
self.assertFalse(executor._os_env)
|
|
self.assertIsNone(executor._os_env_spec)
|
|
self.assertIsNone(executor._cwd)
|
|
self.assertIsNone(executor._model_override)
|
|
self.assertEqual(executor._permission_mode, "auto")
|
|
self.assertIsNone(executor._tool_executor)
|
|
self.assertEqual(executor._clients, {})
|
|
self.assertEqual(executor._crashed_sessions, {})
|
|
self.assertFalse(executor._gateway)
|
|
# _extra_env carries the default RetryPolicy's CLI env vars
|
|
# (ANTHROPIC_MAX_RETRIES + ANTHROPIC_REQUEST_TIMEOUT_SECONDS).
|
|
self.assertEqual(executor._extra_env, RetryPolicy().claude_cli.env())
|
|
|
|
def test_os_env_spec_with_no_sandbox_keeps_native_tools_enabled(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
executor = ClaudeSDKExecutor(
|
|
os_env=OSEnvSpec(
|
|
type="caller_process",
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
)
|
|
)
|
|
self.assertTrue(executor._os_env)
|
|
self.assertIsNotNone(executor._os_env_spec)
|
|
|
|
def test_os_env_spec_wraps_cli_and_enables_native_tools(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor, PreparedClaudeCli
|
|
from omnigent.inner.datamodel import OSEnvSpec
|
|
|
|
spec = OSEnvSpec(type="caller_process", cwd="/tmp/work")
|
|
with (
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor._find_system_claude",
|
|
return_value="/usr/bin/claude",
|
|
),
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor.prepare_claude_cli_path",
|
|
return_value=PreparedClaudeCli(
|
|
cli_path="/tmp/omnigent-claude-wrapper",
|
|
enable_native_tools=True,
|
|
),
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(os_env=spec)
|
|
|
|
self.assertTrue(executor._os_env)
|
|
self.assertEqual(executor._os_env_spec, spec)
|
|
self.assertEqual(executor._cli_path, "/tmp/omnigent-claude-wrapper")
|
|
self.assertEqual(executor._cwd, "/tmp/work")
|
|
|
|
def test_prepare_claude_cli_path_adds_internal_roots_to_read_allowlist(self):
|
|
from omnigent.inner.claude_sdk_executor import prepare_claude_cli_path
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
from omnigent.inner.sandbox import SandboxPolicy
|
|
|
|
captured: dict[str, SandboxPolicy] = {}
|
|
|
|
def _capture_launcher(_path: str, sandbox: SandboxPolicy) -> str:
|
|
captured["sandbox"] = sandbox
|
|
return "/tmp/launcher"
|
|
|
|
spec = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd="/tmp/work",
|
|
sandbox=OSEnvSandboxSpec(
|
|
type="linux_bwrap",
|
|
read_paths=["."],
|
|
write_paths=["."],
|
|
allow_network=True,
|
|
),
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor.resolve_sandbox",
|
|
return_value=SandboxPolicy(
|
|
backend_type="linux_bwrap",
|
|
active=True,
|
|
read_roots=[Path("/tmp/work")],
|
|
write_roots=[Path("/tmp/work")],
|
|
write_files=[Path("/dev/null")],
|
|
allow_network=True,
|
|
),
|
|
),
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor._claude_internal_write_roots",
|
|
return_value=[Path("/home/test/.claude/sessions")],
|
|
),
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor._claude_internal_write_files",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor.create_exec_launcher",
|
|
side_effect=_capture_launcher,
|
|
),
|
|
):
|
|
prepared = prepare_claude_cli_path("/usr/bin/claude", spec)
|
|
|
|
self.assertEqual(prepared.cli_path, "/tmp/launcher")
|
|
self.assertTrue(prepared.enable_native_tools)
|
|
# Sandbox helpers call ``.resolve(strict=False)``, which on
|
|
# macOS rewrites ``/home`` → ``/System/Volumes/Data/home``.
|
|
# Compare against the resolved form so the assertion is stable
|
|
# across platforms.
|
|
expected = Path("/home/test/.claude/sessions").resolve(strict=False)
|
|
self.assertIn(expected, captured["sandbox"].read_roots)
|
|
|
|
def test_default_process_sandbox_wraps_cli_without_enabling_native_tools(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
with (
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor._find_system_claude",
|
|
return_value="/usr/bin/claude",
|
|
),
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor.prepare_tight_cli_process_path",
|
|
return_value="/tmp/omnigent-claude-tight-wrapper",
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor()
|
|
|
|
self.assertFalse(executor._os_env)
|
|
self.assertIsNone(executor._os_env_spec)
|
|
self.assertEqual(
|
|
executor._cli_path,
|
|
"/tmp/omnigent-claude-tight-wrapper",
|
|
)
|
|
|
|
def test_os_env_spec_without_supported_native_sandbox_disables_native_tools(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor, PreparedClaudeCli
|
|
from omnigent.inner.datamodel import OSEnvSpec
|
|
|
|
spec = OSEnvSpec(type="caller_process", cwd="/tmp/work")
|
|
with (
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor._find_system_claude",
|
|
return_value="/usr/bin/claude",
|
|
),
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor.prepare_claude_cli_path",
|
|
return_value=PreparedClaudeCli(
|
|
cli_path="/usr/bin/claude",
|
|
enable_native_tools=False,
|
|
),
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(os_env=spec)
|
|
|
|
self.assertFalse(executor._os_env)
|
|
self.assertEqual(executor._os_env_spec, spec)
|
|
self.assertEqual(executor._cli_path, "/usr/bin/claude")
|
|
|
|
def test_model_override(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
executor = ClaudeSDKExecutor(model="claude-haiku-4-5-20251001")
|
|
self.assertEqual(executor._model_override, "claude-haiku-4-5-20251001")
|
|
|
|
def test_supports_streaming(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
self.assertTrue(ClaudeSDKExecutor().supports_streaming())
|
|
|
|
def test_supports_tool_calling(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
self.assertTrue(ClaudeSDKExecutor().supports_tool_calling())
|
|
|
|
def test_databricks_flag_with_profile(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(
|
|
host="https://example.cloud.databricks.com",
|
|
token="dapi_test_token",
|
|
),
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(gateway=True)
|
|
self.assertTrue(executor._gateway)
|
|
self.assertEqual(
|
|
executor._extra_env["ANTHROPIC_BASE_URL"],
|
|
"https://example.cloud.databricks.com/ai-gateway/anthropic",
|
|
)
|
|
self.assertEqual(executor._extra_env["CLAUDE_CODE_API_KEY_HELPER_TTL_MS"], "900000")
|
|
self.assertIn(
|
|
'databricks auth token --host "https://example.cloud.databricks.com"',
|
|
executor._extra_env["OMNIGENT_CLAUDE_API_KEY_HELPER"],
|
|
)
|
|
self.assertNotIn("ANTHROPIC_AUTH_TOKEN", executor._extra_env)
|
|
|
|
def test_databricks_explicit_profile_selects_by_profile(self):
|
|
"""An explicit ``databricks_profile`` makes the token helper select
|
|
the bearer by ``--profile`` (unambiguous), not ``--host``.
|
|
|
|
Two ``~/.databrickscfg`` profiles can share one host, which makes
|
|
``databricks auth token --host`` fail ("Use --profile to specify
|
|
which profile") → empty token → a silent ``status=401``. Selecting
|
|
by ``--profile`` avoids that.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(
|
|
host="https://example.cloud.databricks.com",
|
|
token="dapi_test_token",
|
|
),
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(gateway=True, databricks_profile="oss")
|
|
helper = executor._extra_env["OMNIGENT_CLAUDE_API_KEY_HELPER"]
|
|
# Proves the selector is --profile, not --host. A regression to --host
|
|
# makes a two-profiles-one-host workspace yield an empty token → 401.
|
|
self.assertIn('databricks auth token --profile "oss"', helper)
|
|
self.assertNotIn("--host", helper)
|
|
# `--force-refresh` only exists in Databricks CLI >= v0.296.0, so it
|
|
# must be applied via a `--help` capability probe ($force), never
|
|
# passed unconditionally — an older CLI rejects the unknown flag and
|
|
# yields an empty token → silent 401.
|
|
self.assertIn("databricks auth token --help", helper)
|
|
self.assertIn("force=--force-refresh", helper)
|
|
self.assertNotIn('oss" --force-refresh', helper)
|
|
|
|
def test_databricks_flag_no_creds_raises(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch("omnigent.inner.claude_sdk_executor._resolve_gateway_env", return_value={}),
|
|
):
|
|
with self.assertRaises(EnvironmentError):
|
|
ClaudeSDKExecutor(gateway=True)
|
|
|
|
def test_databricks_flag_with_host_override(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch("omnigent.inner.databricks_executor._read_databrickscfg") as read_cfg,
|
|
):
|
|
executor = ClaudeSDKExecutor(
|
|
gateway=True,
|
|
databricks_profile="missing-profile",
|
|
gateway_host="https://example.databricks.com/",
|
|
base_url_override="https://example.databricks.com/ai-gateway/anthropic",
|
|
gateway_auth_command="printf token",
|
|
)
|
|
|
|
read_cfg.assert_not_called()
|
|
self.assertEqual(
|
|
executor._extra_env["ANTHROPIC_BASE_URL"],
|
|
"https://example.databricks.com/ai-gateway/anthropic",
|
|
)
|
|
self.assertEqual(
|
|
executor._extra_env["OMNIGENT_CLAUDE_API_KEY_HELPER"],
|
|
"printf token",
|
|
)
|
|
|
|
def test_databricks_flag_with_host_override_requires_base_url(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
self.assertRaisesRegex(OSError, "GATEWAY_BASE_URL"),
|
|
):
|
|
ClaudeSDKExecutor(
|
|
gateway=True,
|
|
gateway_host="https://example.databricks.com/",
|
|
gateway_auth_command="printf token",
|
|
)
|
|
|
|
def test_databricks_flag_with_host_override_requires_auth_command(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
self.assertRaisesRegex(OSError, "GATEWAY_AUTH_COMMAND"),
|
|
):
|
|
ClaudeSDKExecutor(
|
|
gateway=True,
|
|
gateway_host="https://example.databricks.com/",
|
|
base_url_override="https://example.databricks.com/ai-gateway/anthropic",
|
|
)
|
|
|
|
def test_databricks_false_no_extra_env(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.spec.types import RetryPolicy
|
|
|
|
executor = ClaudeSDKExecutor(gateway=False)
|
|
# gateway=False → no Databricks env, but RetryPolicy CLI env
|
|
# is always merged in. Verify the only entries are the retry env.
|
|
self.assertEqual(executor._extra_env, RetryPolicy().claude_cli.env())
|
|
|
|
def test_databricks_profile_default_model_used_when_unset(self):
|
|
"""gateway=True (profile-derived) + no model → Databricks default.
|
|
|
|
On the Databricks-profile gateway path (transport derived from
|
|
~/.databrickscfg, no gateway base URL supplied directly), a missing
|
|
model falls back to the Databricks default. The neutral
|
|
generic-provider gateway path never does this (see
|
|
``test_neutral_gateway_no_model_does_not_inject_databricks_default``).
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import (
|
|
_DATABRICKS_CLAUDE_DEFAULT_MODEL,
|
|
ClaudeSDKExecutor,
|
|
)
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
async def _t():
|
|
with patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(
|
|
host="https://example.cloud.databricks.com",
|
|
token="dapi_test_token",
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(gateway=True)
|
|
|
|
captured: dict[str, str | None] = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["model"] = model
|
|
raise RuntimeError("stop after model resolution")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertEqual(captured["model"], _DATABRICKS_CLAUDE_DEFAULT_MODEL)
|
|
|
|
_run(_t())
|
|
|
|
def test_neutral_gateway_no_model_does_not_inject_databricks_default(self):
|
|
"""Neutral gateway (base URL supplied directly) + no model → ``None``.
|
|
|
|
The neutral generic-provider gateway transport never falls back to a
|
|
``databricks-*`` model: the Omnigent producer resolves a concrete model
|
|
before spawning, so the executor passes ``None`` through to the SDK.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor(
|
|
gateway=True,
|
|
gateway_host="https://gateway.example.com",
|
|
base_url_override="https://gateway.example.com/v1",
|
|
gateway_auth_command="printf token",
|
|
)
|
|
|
|
captured: dict[str, str | None] = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["model"] = model
|
|
raise RuntimeError("stop after model resolution")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertIsNone(captured["model"])
|
|
|
|
_run(_t())
|
|
|
|
def test_gateway_model_passes_through(self):
|
|
"""Explicit model on the gateway path passes through unchanged."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
async def _t():
|
|
with patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(
|
|
host="https://example.cloud.databricks.com",
|
|
token="dapi_test_token",
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(gateway=True, model="databricks-claude-sonnet-4-6")
|
|
|
|
captured: dict[str, str | None] = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["model"] = model
|
|
raise RuntimeError("stop after model resolution")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertEqual(captured["model"], "databricks-claude-sonnet-4-6")
|
|
|
|
_run(_t())
|
|
|
|
def test_no_databricks_default_when_databricks_off(self):
|
|
"""gateway=False keeps prior behavior: None falls through to the SDK."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor(gateway=False)
|
|
captured: dict[str, str | None] = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["model"] = model
|
|
raise RuntimeError("stop after model resolution")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertIsNone(captured["model"])
|
|
|
|
_run(_t())
|
|
|
|
def test_databricks_opus_pins_thinking_to_adaptive(self):
|
|
"""gateway=True + opus sets ``thinking={"type": "adaptive", "display": "summarized"}``."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
async def _t():
|
|
with patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(
|
|
host="https://example.cloud.databricks.com",
|
|
token="dapi_test_token",
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(gateway=True, model="databricks-claude-opus-4-7")
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["thinking"] = getattr(options, "thinking", None)
|
|
raise RuntimeError("stop after options built")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertEqual(captured["thinking"], {"type": "adaptive", "display": "summarized"})
|
|
|
|
_run(_t())
|
|
|
|
def test_databricks_fable_pins_thinking_to_adaptive(self):
|
|
"""gateway=True + fable sets ``thinking={"type": "adaptive", "display": "summarized"}``.
|
|
|
|
Fable (claude-fable-5) shares Opus 4.7/4.8's adaptive-only thinking
|
|
surface, so the Databricks gateway rejects the CLI's default
|
|
thinking=enabled for it too. If this stays unset, a fable session
|
|
through the gateway 400s on the first request.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
async def _t():
|
|
with patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(
|
|
host="https://example.databricks.com",
|
|
token="dapi_test_token",
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(gateway=True, model="databricks-claude-fable-5")
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["thinking"] = getattr(options, "thinking", None)
|
|
raise RuntimeError("stop after options built")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertEqual(captured["thinking"], {"type": "adaptive", "display": "summarized"})
|
|
|
|
_run(_t())
|
|
|
|
def test_databricks_sonnet_leaves_thinking_unset(self):
|
|
"""gateway=True + non-adaptive-tier model preserves CLI default thinking."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
async def _t():
|
|
with patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(
|
|
host="https://example.cloud.databricks.com",
|
|
token="dapi_test_token",
|
|
),
|
|
):
|
|
executor = ClaudeSDKExecutor(gateway=True, model="databricks-claude-sonnet-4-6")
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["thinking"] = getattr(options, "thinking", None)
|
|
raise RuntimeError("stop after options built")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertIsNone(captured["thinking"])
|
|
|
|
_run(_t())
|
|
|
|
def test_no_databricks_leaves_thinking_unset(self):
|
|
"""gateway=False does not touch ``thinking``; preserves CLI default."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor(gateway=False, model="claude-opus-4-7")
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["thinking"] = getattr(options, "thinking", None)
|
|
raise RuntimeError("stop after options built")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertIsNone(captured["thinking"])
|
|
|
|
_run(_t())
|
|
|
|
def test_force_close_client_uses_process_tree_termination(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Transport:
|
|
def __init__(self):
|
|
self._process = SimpleNamespace(returncode=None, pid=12345, wait=AsyncMock())
|
|
self._stdout_stream = object()
|
|
self._stdin_stream = object()
|
|
self._stderr_stream = object()
|
|
self._stderr_task_group = None
|
|
self._ready = True
|
|
|
|
client = SimpleNamespace(_query=None, _transport=_Transport())
|
|
|
|
async def _t():
|
|
with patch(
|
|
"omnigent.inner.claude_sdk_executor._terminate_process_tree"
|
|
) as terminate_tree:
|
|
await ClaudeSDKExecutor._force_close_client(client)
|
|
terminate_tree.assert_called_once()
|
|
|
|
_run(_t())
|
|
|
|
def test_force_close_client_handles_sdk_without_stderr_task_group(self):
|
|
# Regression: claude-agent-sdk >=0.2.x renamed the stderr reader from an
|
|
# anyio task group (`_stderr_task_group`) to a single `_stderr_task`
|
|
# TaskHandle. A transport shaped like the current SDK (no
|
|
# `_stderr_task_group` attribute at all) must not raise AttributeError
|
|
# out of `_force_close_client` — that exception escaped the runner's
|
|
# lifespan shutdown and crashed it on every session stop.
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
stderr_task = SimpleNamespace(cancel=Mock())
|
|
|
|
class _Transport:
|
|
def __init__(self):
|
|
self._process = SimpleNamespace(returncode=None, pid=12345, wait=AsyncMock())
|
|
self._stdout_stream = object()
|
|
self._stdin_stream = object()
|
|
self._stderr_stream = object()
|
|
self._stderr_task = stderr_task # current SDK shape
|
|
self._ready = True
|
|
|
|
transport = _Transport()
|
|
client = SimpleNamespace(_query=None, _transport=transport)
|
|
|
|
async def _t():
|
|
with patch(
|
|
"omnigent.inner.claude_sdk_executor._terminate_process_tree"
|
|
) as terminate_tree:
|
|
await ClaudeSDKExecutor._force_close_client(client)
|
|
terminate_tree.assert_called_once()
|
|
|
|
_run(_t())
|
|
|
|
# New-shape stderr task was cancelled, the missing legacy attribute was
|
|
# never created, and the handle was cleared.
|
|
stderr_task.cancel.assert_called_once()
|
|
self.assertFalse(hasattr(transport, "_stderr_task_group"))
|
|
self.assertIsNone(transport._stderr_task)
|
|
|
|
def test_claude_internal_write_files_omits_missing_config(self):
|
|
from omnigent.inner.claude_sdk_executor import _claude_internal_write_files
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
home = Path(td)
|
|
config_path = home / ".claude.json"
|
|
self.assertFalse(config_path.exists())
|
|
with patch("omnigent.inner.claude_sdk_executor.pathlib.Path.home", return_value=home):
|
|
paths = _claude_internal_write_files()
|
|
|
|
self.assertEqual(paths, [])
|
|
self.assertFalse(config_path.exists())
|
|
|
|
def test_claude_internal_write_files_includes_existing_config(self):
|
|
from omnigent.inner.claude_sdk_executor import _claude_internal_write_files
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
home = Path(td)
|
|
config_path = home / ".claude.json"
|
|
config_path.write_text("{}\n", encoding="utf-8")
|
|
with patch("omnigent.inner.claude_sdk_executor.pathlib.Path.home", return_value=home):
|
|
paths = _claude_internal_write_files()
|
|
|
|
self.assertEqual(paths, [config_path])
|
|
|
|
def test_claude_internal_write_files_includes_credentials_when_present(self):
|
|
from omnigent.inner.claude_sdk_executor import _claude_internal_write_files
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
home = Path(td)
|
|
config_path = home / ".claude.json"
|
|
config_path.write_text("{}\n", encoding="utf-8")
|
|
credentials_path = home / ".claude" / ".credentials.json"
|
|
credentials_path.parent.mkdir(parents=True, exist_ok=True)
|
|
credentials_path.write_text("{}\n", encoding="utf-8")
|
|
with patch("omnigent.inner.claude_sdk_executor.pathlib.Path.home", return_value=home):
|
|
paths = _claude_internal_write_files()
|
|
|
|
self.assertEqual(paths, [config_path, credentials_path])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: MCP tool building
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildMcpTools(unittest.TestCase):
|
|
def test_builds_tools_from_schemas(self):
|
|
from omnigent.inner.claude_sdk_executor import _build_mcp_tools
|
|
|
|
async def mock_executor(name, args):
|
|
return {"result": "ok"}
|
|
|
|
schemas = [
|
|
{
|
|
"name": "calc",
|
|
"description": "Calculate",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"expr": {"type": "string"}},
|
|
},
|
|
},
|
|
]
|
|
tools = _build_mcp_tools(schemas, mock_executor)
|
|
self.assertEqual(len(tools), 1)
|
|
self.assertEqual(tools[0].name, "calc")
|
|
|
|
def test_empty_schemas(self):
|
|
from omnigent.inner.claude_sdk_executor import _build_mcp_tools
|
|
|
|
self.assertEqual(_build_mcp_tools([], None), [])
|
|
|
|
def test_handler_calls_executor(self):
|
|
from omnigent.inner.claude_sdk_executor import _build_mcp_tools
|
|
|
|
calls = []
|
|
|
|
async def mock_executor(name, args):
|
|
calls.append((name, args))
|
|
return {"result": 42}
|
|
|
|
schemas = [
|
|
{
|
|
"name": "add",
|
|
"description": "Add",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
}
|
|
]
|
|
tools = _build_mcp_tools(schemas, mock_executor)
|
|
result = _run(tools[0].handler({"a": 1}))
|
|
self.assertEqual(calls, [("add", {"a": 1})])
|
|
self.assertIn("content", result)
|
|
parsed = json.loads(result["content"][0]["text"])
|
|
self.assertEqual(parsed["result"], 42)
|
|
self.assertNotIn("isError", result)
|
|
|
|
def test_handler_marks_blocked_result_as_error(self):
|
|
from omnigent.inner.claude_sdk_executor import _build_mcp_tools
|
|
|
|
async def mock_executor(name, args):
|
|
return {"blocked": True, "reason": "Exceeded max tool calls"}
|
|
|
|
schemas = [
|
|
{
|
|
"name": "add",
|
|
"description": "Add",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
}
|
|
]
|
|
tools = _build_mcp_tools(schemas, mock_executor)
|
|
result = _run(tools[0].handler({"a": 1}))
|
|
self.assertTrue(result["isError"])
|
|
parsed = json.loads(result["content"][0]["text"])
|
|
self.assertTrue(parsed["blocked"])
|
|
|
|
def test_handler_marks_error_result_as_error(self):
|
|
from omnigent.inner.claude_sdk_executor import _build_mcp_tools
|
|
|
|
async def mock_executor(name, args):
|
|
return {"error": "boom"}
|
|
|
|
schemas = [
|
|
{
|
|
"name": "add",
|
|
"description": "Add",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
}
|
|
]
|
|
tools = _build_mcp_tools(schemas, mock_executor)
|
|
result = _run(tools[0].handler({"a": 1}))
|
|
self.assertTrue(result["isError"])
|
|
parsed = json.loads(result["content"][0]["text"])
|
|
self.assertEqual(parsed["error"], "boom")
|
|
|
|
def test_handler_no_executor(self):
|
|
from omnigent.inner.claude_sdk_executor import _build_mcp_tools
|
|
|
|
schemas = [
|
|
{
|
|
"name": "x",
|
|
"description": "x",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
}
|
|
]
|
|
tools = _build_mcp_tools(schemas, None)
|
|
result = _run(tools[0].handler({}))
|
|
parsed = json.loads(result["content"][0]["text"])
|
|
self.assertIn("error", parsed)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Databricks env resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveGatewayEnv(unittest.TestCase):
|
|
def test_from_profile(self):
|
|
from omnigent.inner.claude_sdk_executor import _resolve_gateway_env
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(
|
|
host="https://example.databricks.com",
|
|
token="dapi_abc123",
|
|
),
|
|
),
|
|
):
|
|
env = _resolve_gateway_env()
|
|
self.assertEqual(
|
|
env["ANTHROPIC_BASE_URL"],
|
|
"https://example.databricks.com/ai-gateway/anthropic",
|
|
)
|
|
self.assertEqual(env["CLAUDE_CODE_API_KEY_HELPER_TTL_MS"], "900000")
|
|
self.assertIn(
|
|
'databricks auth token --host "https://example.databricks.com"',
|
|
env["OMNIGENT_CLAUDE_API_KEY_HELPER"],
|
|
)
|
|
self.assertNotIn("ANTHROPIC_AUTH_TOKEN", env)
|
|
|
|
def test_strips_trailing_slash(self):
|
|
from omnigent.inner.claude_sdk_executor import _resolve_gateway_env
|
|
from omnigent.inner.databricks_executor import DatabricksCredentials
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch(
|
|
"omnigent.inner.databricks_executor._read_databrickscfg",
|
|
return_value=DatabricksCredentials(host="https://my-workspace.com/", token="tok"),
|
|
),
|
|
):
|
|
env = _resolve_gateway_env()
|
|
self.assertFalse(env["ANTHROPIC_BASE_URL"].endswith("//"))
|
|
self.assertTrue(env["ANTHROPIC_BASE_URL"].endswith("/ai-gateway/anthropic"))
|
|
|
|
def test_no_creds_returns_empty(self):
|
|
from omnigent.inner.claude_sdk_executor import _resolve_gateway_env
|
|
|
|
with (
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch("omnigent.inner.databricks_executor._read_databrickscfg", return_value=None),
|
|
):
|
|
env = _resolve_gateway_env()
|
|
self.assertEqual(env, {})
|
|
|
|
def test_host_override_skips_profile_lookup(self):
|
|
from omnigent.inner.claude_sdk_executor import _resolve_gateway_env
|
|
|
|
with patch("omnigent.inner.databricks_executor._read_databrickscfg") as read_cfg:
|
|
env = _resolve_gateway_env(
|
|
profile="missing-profile",
|
|
host_override="https://example.databricks.com/",
|
|
base_url_override="https://example.databricks.com/ai-gateway/anthropic",
|
|
auth_command_override="printf token",
|
|
)
|
|
|
|
read_cfg.assert_not_called()
|
|
self.assertEqual(
|
|
env["ANTHROPIC_BASE_URL"],
|
|
"https://example.databricks.com/ai-gateway/anthropic",
|
|
)
|
|
self.assertEqual(env["OMNIGENT_CLAUDE_API_KEY_HELPER"], "printf token")
|
|
|
|
def test_host_override_requires_base_url(self):
|
|
from omnigent.inner.claude_sdk_executor import _resolve_gateway_env
|
|
|
|
with self.assertRaisesRegex(OSError, "GATEWAY_BASE_URL"):
|
|
_resolve_gateway_env(
|
|
host_override="https://example.databricks.com/",
|
|
auth_command_override="printf token",
|
|
)
|
|
|
|
def test_host_override_requires_auth_command(self):
|
|
from omnigent.inner.claude_sdk_executor import _resolve_gateway_env
|
|
|
|
with self.assertRaisesRegex(OSError, "GATEWAY_AUTH_COMMAND"):
|
|
_resolve_gateway_env(
|
|
host_override="https://example.databricks.com/",
|
|
base_url_override="https://example.databricks.com/ai-gateway/anthropic",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Empty prompt handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEmptyPrompt(unittest.TestCase):
|
|
def test_empty_prompt_yields_turn_complete(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
events = [e async for e in executor.run_turn([], [], "")]
|
|
self.assertEqual(len(events), 1)
|
|
self.assertIsInstance(events[0], TurnComplete)
|
|
# Empty-prompt short-circuit signals "no assistant text this
|
|
# turn" via ``response=None`` rather than an empty string.
|
|
self.assertIsNone(events[0].response)
|
|
|
|
_run(_t())
|
|
|
|
|
|
class TestSystemMessages(unittest.TestCase):
|
|
def test_databricks_auth_uses_api_key_helper_settings(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
captured_options = []
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = type("AssistantMessage", (), {})
|
|
UserMessage = type("UserMessage", (), {})
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = _ResultMessage
|
|
StreamEvent = type("StreamEvent", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
captured_options.append(options)
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
yield _ResultMessage("default", "ok")
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
def _resolve_gateway_env(
|
|
profile=None,
|
|
*,
|
|
host_override=None,
|
|
base_url_override=None,
|
|
auth_command_override=None,
|
|
auth_refresh_interval_ms=None,
|
|
):
|
|
return {
|
|
"ANTHROPIC_BASE_URL": base_url_override or "https://host/ai-gateway/anthropic",
|
|
"OMNIGENT_CLAUDE_API_KEY_HELPER": "databricks auth token --host https://host",
|
|
"CLAUDE_CODE_API_KEY_HELPER_TTL_MS": "900000",
|
|
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1",
|
|
}
|
|
|
|
shim_upstream: dict[str, str] = {}
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
executor._gateway = True
|
|
executor._databricks_profile = "oss"
|
|
executor._base_url_override = "https://host/ai-gateway/anthropic"
|
|
executor._extra_env = _resolve_gateway_env(
|
|
profile="oss",
|
|
base_url_override="https://host/ai-gateway/anthropic",
|
|
)
|
|
with (
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor._resolve_gateway_env",
|
|
_resolve_gateway_env,
|
|
),
|
|
patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK),
|
|
):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
self.assertEqual(len(events), 1)
|
|
# The gateway path routes the CLI through the local shim
|
|
# (which restores thinking.display); record its target and
|
|
# shut it down inside the loop that started it.
|
|
self.assertIsNotNone(executor._gateway_shim)
|
|
shim_upstream["base_url"] = executor._gateway_shim.base_url
|
|
shim_upstream["upstream"] = executor._gateway_shim._upstream_base_url
|
|
await executor._gateway_shim.aclose()
|
|
|
|
_run(_t())
|
|
|
|
self.assertEqual(len(captured_options), 1)
|
|
settings = json.loads(captured_options[0].settings)
|
|
self.assertEqual(
|
|
settings["apiKeyHelper"],
|
|
"databricks auth token --host https://host",
|
|
)
|
|
# The CLI talks to the loopback shim; the shim forwards to the
|
|
# real gateway. A direct gateway URL here would mean the shim was
|
|
# bypassed and opus thinking.display stays stripped.
|
|
self.assertEqual(
|
|
captured_options[0].env["ANTHROPIC_BASE_URL"],
|
|
shim_upstream["base_url"],
|
|
)
|
|
self.assertTrue(shim_upstream["base_url"].startswith("http://127.0.0.1:"))
|
|
self.assertEqual(shim_upstream["upstream"], "https://host/ai-gateway/anthropic")
|
|
self.assertEqual(captured_options[0].env["CLAUDE_CODE_API_KEY_HELPER_TTL_MS"], "900000")
|
|
self.assertNotIn("OMNIGENT_CLAUDE_API_KEY_HELPER", captured_options[0].env)
|
|
self.assertNotIn("ANTHROPIC_AUTH_TOKEN", captured_options[0].env)
|
|
|
|
def test_auth_retry_surfaces_executor_error(self):
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
StreamEvent as SDKStreamEvent,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
SystemMessage as SDKSystemMessage,
|
|
)
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Sentinel:
|
|
pass
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _Sentinel
|
|
ResultMessage = _Sentinel
|
|
UserMessage = _Sentinel
|
|
SystemMessage = SDKSystemMessage
|
|
StreamEvent = SDKStreamEvent
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
messages = [
|
|
SDKSystemMessage(
|
|
subtype="api_retry",
|
|
data={
|
|
"type": "system",
|
|
"subtype": "api_retry",
|
|
"attempt": 1,
|
|
"max_retries": 10,
|
|
"retry_delay_ms": 500,
|
|
"error_status": 401,
|
|
"error": "authentication_failed",
|
|
},
|
|
)
|
|
]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
self.assertEqual(len(events), 1)
|
|
self.assertIsInstance(events[0], ExecutorError)
|
|
self.assertIn("authentication failed", events[0].message)
|
|
self.assertIn("401", events[0].message)
|
|
# Non-gateway executor should suggest checking CLI login, not databrickscfg
|
|
self.assertIn("claude /status", events[0].message)
|
|
self.assertNotIn("databrickscfg", events[0].message)
|
|
|
|
_run(_t())
|
|
|
|
def test_auth_retry_databricks_gateway_mentions_databrickscfg(self):
|
|
"""Databricks-profile gateway auth errors should mention ~/.databrickscfg."""
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
StreamEvent as SDKStreamEvent,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
SystemMessage as SDKSystemMessage,
|
|
)
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Sentinel:
|
|
pass
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _Sentinel
|
|
ResultMessage = _Sentinel
|
|
UserMessage = _Sentinel
|
|
SystemMessage = SDKSystemMessage
|
|
StreamEvent = SDKStreamEvent
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
messages = [
|
|
SDKSystemMessage(
|
|
subtype="api_retry",
|
|
data={
|
|
"type": "system",
|
|
"subtype": "api_retry",
|
|
"attempt": 1,
|
|
"max_retries": 10,
|
|
"retry_delay_ms": 500,
|
|
"error_status": 401,
|
|
"error": "authentication_failed",
|
|
},
|
|
)
|
|
]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
# Create a gateway executor that uses a Databricks profile path.
|
|
# gateway=True + no host/base_url overrides → _gateway_uses_databricks_profile is True.
|
|
# Patch _resolve_gateway_env to avoid needing a real ~/.databrickscfg.
|
|
with patch(
|
|
"omnigent.inner.claude_sdk_executor._resolve_gateway_env",
|
|
return_value={
|
|
"ANTHROPIC_BASE_URL": "https://host/ai-gateway/anthropic",
|
|
"CLAUDE_CODE_API_KEY_HELPER_TTL_MS": "900000",
|
|
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1",
|
|
"OMNIGENT_CLAUDE_API_KEY_HELPER": "databricks auth token ...",
|
|
},
|
|
):
|
|
executor = ClaudeSDKExecutor(gateway=True)
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hello"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
self.assertEqual(len(events), 1)
|
|
self.assertIsInstance(events[0], ExecutorError)
|
|
self.assertIn("authentication failed", events[0].message)
|
|
# Databricks gateway should mention databrickscfg
|
|
self.assertIn("databrickscfg", events[0].message)
|
|
|
|
_run(_t())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: skills_filter → SDK skills option translation + plugin manifest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillsFilterTranslation(unittest.TestCase):
|
|
"""
|
|
Pin the mapping from the spec's ``skills_filter`` to the
|
|
Claude Agent SDK's ``skills`` option.
|
|
|
|
The filter has three meaningful values; getting any of them
|
|
wrong silently changes which user / project / bundled skills
|
|
the model sees. This is the seam between AGENTSPEC.md's YAML
|
|
``skills:`` field and the SDK's documented ``skills`` knob.
|
|
"""
|
|
|
|
def test_all_lets_sdk_default_setting_sources(self) -> None:
|
|
"""
|
|
``"all"`` → SDK ``skills="all"`` and
|
|
``setting_sources=None`` (the SDK's default-derivation
|
|
kicks in, producing ``["user", "project"]``).
|
|
|
|
Claim: setting_sources is ``None`` (not ``[]``), letting
|
|
the SDK fill it in. A regression that hardcoded an
|
|
explicit list here would freeze the default and miss
|
|
future SDK changes.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import _resolve_skills_option
|
|
|
|
result = _resolve_skills_option("all")
|
|
assert result is not None
|
|
self.assertEqual(result.skills, "all")
|
|
self.assertIsNone(result.setting_sources)
|
|
|
|
def test_none_zeros_skills_and_setting_sources(self) -> None:
|
|
"""
|
|
``"none"`` → SDK ``skills=[]`` AND
|
|
``setting_sources=[]``.
|
|
|
|
BOTH must be set to truly suppress host skills. The SDK's
|
|
``_apply_skills_defaults`` auto-fills
|
|
``setting_sources=["user","project"]`` when ``skills`` is
|
|
non-None — including when ``skills=[]``. That auto-default
|
|
loads ``~/.claude/skills/`` into the system prompt
|
|
listing even though the ``Skill`` tool itself is hidden.
|
|
Forcing ``setting_sources=[]`` is what actually keeps the
|
|
listing empty. The user-reported regression: with only
|
|
``skills=[]`` set, ``skills: none`` in YAML still showed
|
|
every host skill in the model's output.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import _resolve_skills_option
|
|
|
|
result = _resolve_skills_option("none")
|
|
assert result is not None
|
|
self.assertEqual(result.skills, [])
|
|
self.assertEqual(result.setting_sources, [])
|
|
|
|
def test_list_lets_sdk_default_setting_sources(self) -> None:
|
|
"""A list of names round-trips and uses the SDK default."""
|
|
from omnigent.inner.claude_sdk_executor import _resolve_skills_option
|
|
|
|
result = _resolve_skills_option(["foo", "bar:baz"])
|
|
assert result is not None
|
|
self.assertEqual(result.skills, ["foo", "bar:baz"])
|
|
self.assertIsNone(result.setting_sources)
|
|
|
|
def test_unknown_string_returns_none_for_caller_fallback(self) -> None:
|
|
"""
|
|
Unknown strings (e.g. malformed config bypass) return
|
|
``None`` so the caller can fall back to ``"all"``. The
|
|
spec parser already validates, so this is a belt-and-
|
|
suspenders defense at the executor boundary.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import _resolve_skills_option
|
|
|
|
self.assertIsNone(_resolve_skills_option("bogus"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: StreamEvent-based streaming
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStreamEventStreaming(unittest.TestCase):
|
|
def test_live_clients_are_reused_per_omnigent_session(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
query_calls = []
|
|
connect_calls = []
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = type("AssistantMessage", (), {})
|
|
UserMessage = type("UserMessage", (), {})
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = _ResultMessage
|
|
StreamEvent = type("StreamEvent", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
messages = []
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
connect_calls.append(self)
|
|
return
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
query_calls.append(
|
|
{
|
|
"prompt": prompt,
|
|
"session_id": session_id,
|
|
"extra_args": getattr(self.options, "extra_args", {}),
|
|
"tools": getattr(self.options, "tools", None),
|
|
"allowed_tools": getattr(self.options, "allowed_tools", None),
|
|
}
|
|
)
|
|
result_session_id = (
|
|
session_id
|
|
if str(session_id).startswith("claude-")
|
|
else f"claude-{session_id}"
|
|
)
|
|
_FakeSDK.messages = [_ResultMessage(result_session_id, f"result for {prompt}")]
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
session_a = [{"role": "user", "content": "hello", "session_id": "session-a"}]
|
|
session_b = [{"role": "user", "content": "bonjour", "session_id": "session-b"}]
|
|
|
|
events_a1 = [e async for e in executor.run_turn(session_a, [], "")]
|
|
events_b1 = [e async for e in executor.run_turn(session_b, [], "")]
|
|
events_a2 = [e async for e in executor.run_turn(session_a, [], "")]
|
|
|
|
self.assertEqual(query_calls[0]["session_id"], "session-a")
|
|
# ``--bare`` was previously included to suppress host config
|
|
# leakage. It was dropped because bare mode also kills
|
|
# CLAUDE.md auto-discovery, plugin sync, and skill loading —
|
|
# which the spec's ``skills:`` field is the proper knob for.
|
|
self.assertEqual(
|
|
query_calls[0]["extra_args"],
|
|
{"no-session-persistence": None},
|
|
)
|
|
# Skill is always in the base tool set so the Skill tool is
|
|
# actually exposed to the model when ``skills="all"`` (the
|
|
# SDK only adds Skill to ``allowedTools`` — without listing
|
|
# it in ``tools`` the CLI passes ``--tools ""`` and zeros
|
|
# the base set).
|
|
self.assertEqual(query_calls[0]["tools"], ["Skill"])
|
|
self.assertEqual(query_calls[0]["allowed_tools"], [])
|
|
self.assertEqual(query_calls[1]["session_id"], "session-b")
|
|
self.assertEqual(query_calls[2]["session_id"], "session-a")
|
|
self.assertEqual(len(connect_calls), 2)
|
|
self.assertEqual(len(executor._clients), 2)
|
|
self.assertEqual(events_a1[-1].response, "result for hello")
|
|
self.assertEqual(events_b1[-1].response, "result for bonjour")
|
|
self.assertEqual(events_a2[-1].response, "result for hello")
|
|
|
|
_run(_t())
|
|
|
|
def test_os_env_spec_exposes_only_explicit_native_tools(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
captured_options = {}
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = type("AssistantMessage", (), {})
|
|
UserMessage = type("UserMessage", (), {})
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = _ResultMessage
|
|
StreamEvent = type("StreamEvent", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
messages = []
|
|
|
|
@staticmethod
|
|
def tool(name, desc, params):
|
|
def decorator(handler):
|
|
return type(
|
|
"Tool",
|
|
(),
|
|
{
|
|
"name": name,
|
|
"description": desc,
|
|
"parameters": params,
|
|
"handler": handler,
|
|
},
|
|
)()
|
|
|
|
return decorator
|
|
|
|
@staticmethod
|
|
def create_sdk_mcp_server(**kwargs):
|
|
return kwargs
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
captured_options["tools"] = getattr(options, "tools", None)
|
|
captured_options["allowed_tools"] = getattr(options, "allowed_tools", None)
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
_FakeSDK.messages = [_ResultMessage(session_id, "done")]
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
# Explicit ``sandbox=none`` so the test runs on any
|
|
# platform. The default ``OSEnvSandboxSpec.type`` is
|
|
# ``"linux_bwrap"``; without this override,
|
|
# ``resolve_sandbox`` calls into the bwrap backend and
|
|
# raises on macOS even though the test isn't exercising
|
|
# sandbox behavior.
|
|
executor = ClaudeSDKExecutor(
|
|
os_env=OSEnvSpec(
|
|
type="caller_process",
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
),
|
|
)
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi", "session_id": "session-a"}],
|
|
[
|
|
{
|
|
"name": "sleep",
|
|
"description": "sleep",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
"",
|
|
)
|
|
]
|
|
# OS operations route through sys_os_* MCP tools, not SDK
|
|
# built-ins. Only Skill remains in the native base set.
|
|
self.assertEqual(captured_options["tools"], ["Skill"])
|
|
self.assertIn("mcp__omnigent__sleep", captured_options["allowed_tools"])
|
|
self.assertNotIn("Bash", captured_options["allowed_tools"])
|
|
self.assertIsInstance(events[-1], TurnComplete)
|
|
|
|
_run(_t())
|
|
|
|
def test_mcp_only_session_disables_native_tool_base_set(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
captured_options = {}
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = type("AssistantMessage", (), {})
|
|
UserMessage = type("UserMessage", (), {})
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = _ResultMessage
|
|
StreamEvent = type("StreamEvent", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
messages = []
|
|
|
|
@staticmethod
|
|
def tool(name, desc, params):
|
|
def decorator(handler):
|
|
return type(
|
|
"Tool",
|
|
(),
|
|
{
|
|
"name": name,
|
|
"description": desc,
|
|
"parameters": params,
|
|
"handler": handler,
|
|
},
|
|
)()
|
|
|
|
return decorator
|
|
|
|
@staticmethod
|
|
def create_sdk_mcp_server(**kwargs):
|
|
return kwargs
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
captured_options["tools"] = getattr(options, "tools", None)
|
|
captured_options["allowed_tools"] = getattr(options, "allowed_tools", None)
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
_FakeSDK.messages = [_ResultMessage(session_id, "done")]
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi", "session_id": "session-a"}],
|
|
[
|
|
{
|
|
"name": "sleep",
|
|
"description": "sleep",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
"",
|
|
)
|
|
]
|
|
# Default ``skills_filter="all"`` exposes the ``Skill``
|
|
# tool so the model can invoke discovered skills via
|
|
# the Claude SDK plugin mechanism. The OS tools
|
|
# (Bash/Read/Edit/Write/Glob/Grep) stay absent — that's
|
|
# what this test pins. ``Skill`` itself doesn't widen
|
|
# the FS attack surface; it only loads pre-approved
|
|
# SKILL.md content.
|
|
self.assertEqual(captured_options["tools"], ["Skill"])
|
|
self.assertEqual(captured_options["allowed_tools"], ["mcp__omnigent__sleep"])
|
|
self.assertIsInstance(events[-1], TurnComplete)
|
|
|
|
_run(_t())
|
|
|
|
def test_session_send_tool_is_exposed_via_mcp(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
captured_options = {}
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = type("AssistantMessage", (), {})
|
|
UserMessage = type("UserMessage", (), {})
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = _ResultMessage
|
|
StreamEvent = type("StreamEvent", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
messages = []
|
|
|
|
@staticmethod
|
|
def tool(name, desc, params):
|
|
def decorator(handler):
|
|
return type(
|
|
"Tool",
|
|
(),
|
|
{
|
|
"name": name,
|
|
"description": desc,
|
|
"parameters": params,
|
|
"handler": handler,
|
|
},
|
|
)()
|
|
|
|
return decorator
|
|
|
|
@staticmethod
|
|
def create_sdk_mcp_server(**kwargs):
|
|
return kwargs
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
captured_options["allowed_tools"] = getattr(options, "allowed_tools", None)
|
|
captured_options["system_prompt"] = getattr(options, "system_prompt", None)
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
_FakeSDK.messages = [_ResultMessage(session_id, "done")]
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi", "session_id": "session-a"}],
|
|
[
|
|
{
|
|
"name": "sys_session_send",
|
|
"description": "Send into session",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"tool": {"type": "string"},
|
|
"session": {"type": "string"},
|
|
"args": {"type": "object"},
|
|
},
|
|
},
|
|
}
|
|
],
|
|
"Delegate through `sys_session_send`.",
|
|
)
|
|
]
|
|
self.assertIn("mcp__omnigent__sys_session_send", captured_options["allowed_tools"])
|
|
self.assertIn(
|
|
"use `mcp__omnigent__sys_session_send` when instructions say `sys_session_send`",
|
|
captured_options["system_prompt"],
|
|
)
|
|
self.assertIsInstance(events[-1], TurnComplete)
|
|
|
|
_run(_t())
|
|
|
|
def test_crashed_session_refuses_future_turns(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = type("AssistantMessage", (), {})
|
|
UserMessage = type("UserMessage", (), {})
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = type("ResultMessage", (), {})
|
|
StreamEvent = type("StreamEvent", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
raise RuntimeError("claude subprocess crashed")
|
|
|
|
async def receive_response(self):
|
|
if False:
|
|
yield None
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
messages = [{"role": "user", "content": "hello", "session_id": "session-a"}]
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
first_events = [e async for e in executor.run_turn(messages, [], "")]
|
|
second_events = [e async for e in executor.run_turn(messages, [], "")]
|
|
|
|
self.assertIsInstance(first_events[0], ExecutorError)
|
|
self.assertIn("claude subprocess crashed", first_events[0].message)
|
|
self.assertIsInstance(second_events[0], ExecutorError)
|
|
self.assertIn("cannot continue in this Session", second_events[0].message)
|
|
self.assertIn("claude subprocess crashed", second_events[0].message)
|
|
self.assertIn("session-a", executor._crashed_sessions)
|
|
|
|
_run(_t())
|
|
|
|
def test_close_session_disconnects_live_client(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor, _ClaudeClientState
|
|
|
|
disconnect = AsyncMock()
|
|
client = type("Client", (), {"disconnect": disconnect})()
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
executor._clients["session-a"] = _ClaudeClientState(client=client, model=None)
|
|
await executor.close_session("session-a")
|
|
disconnect.assert_awaited_once_with()
|
|
self.assertEqual(executor._clients, {})
|
|
|
|
_run(_t())
|
|
|
|
def test_interrupt_session_interrupts_then_drops_session(self):
|
|
"""A user interrupt fires a safe interrupt, then drops the session.
|
|
|
|
``run_turn`` decides whether to resume via
|
|
``session_key in self._clients``. Dropping the session after an
|
|
interrupt is what forces the next turn to rebuild from full
|
|
history (with the runner's ``[System: interrupted]`` marker)
|
|
instead of resuming a live session whose transcript still holds
|
|
the abandoned prompt. If ``_clients`` were left populated, the
|
|
next turn would resume and silently continue the canceled
|
|
instruction.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor, _ClaudeClientState
|
|
|
|
interrupt = AsyncMock()
|
|
disconnect = AsyncMock()
|
|
client = type("Client", (), {"interrupt": interrupt, "disconnect": disconnect})()
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
executor._clients["session-a"] = _ClaudeClientState(client=client, model=None)
|
|
|
|
interrupted = await executor.interrupt_session("session-a")
|
|
|
|
self.assertTrue(interrupted)
|
|
# Safe interrupt is attempted first to halt the in-flight turn.
|
|
interrupt.assert_awaited_once_with()
|
|
# Then the session is torn down (disconnect) and removed from
|
|
# _clients so the next turn cannot resume it. An empty dict here
|
|
# is the invariant that prevents the canceled-instruction leak.
|
|
disconnect.assert_awaited_once_with()
|
|
self.assertEqual(executor._clients, {})
|
|
|
|
_run(_t())
|
|
|
|
def test_interrupt_session_closes_even_when_interrupt_fails(self):
|
|
"""A failed safe interrupt still drops the session.
|
|
|
|
The session must be torn down regardless of whether the SDK's
|
|
``interrupt()`` succeeds — otherwise a flaky interrupt would
|
|
leave the abandoned-prompt session resumable. If ``close_session``
|
|
is not awaited here, the interrupt-failure path leaks the session.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor, _ClaudeClientState
|
|
|
|
async def fail_interrupt():
|
|
raise RuntimeError("boom")
|
|
|
|
client = type("Client", (), {"interrupt": fail_interrupt})()
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
executor._clients["session-a"] = _ClaudeClientState(client=client, model=None)
|
|
with patch.object(executor, "close_session", new=AsyncMock()) as close_session:
|
|
interrupted = await executor.interrupt_session("session-a")
|
|
|
|
self.assertTrue(interrupted)
|
|
close_session.assert_awaited_once_with("session-a")
|
|
|
|
_run(_t())
|
|
|
|
def test_close_disconnects_all_live_clients(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor, _ClaudeClientState
|
|
|
|
disconnect_a = AsyncMock()
|
|
disconnect_b = AsyncMock()
|
|
client_a = type("Client", (), {"disconnect": disconnect_a})()
|
|
client_b = type("Client", (), {"disconnect": disconnect_b})()
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
executor._clients["session-a"] = _ClaudeClientState(client=client_a, model=None)
|
|
executor._clients["session-b"] = _ClaudeClientState(client=client_b, model=None)
|
|
await executor.close()
|
|
disconnect_a.assert_awaited_once_with()
|
|
disconnect_b.assert_awaited_once_with()
|
|
self.assertEqual(executor._clients, {})
|
|
|
|
_run(_t())
|
|
|
|
def test_close_session_force_closes_on_loop_mismatch(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor, _ClaudeClientState
|
|
|
|
client = type("Client", (), {})()
|
|
client.disconnect = AsyncMock()
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
executor._clients["session-a"] = _ClaudeClientState(
|
|
client=client,
|
|
model=None,
|
|
loop=object(),
|
|
task=None,
|
|
)
|
|
with patch.object(executor, "_force_close_client", new=AsyncMock()) as force_close:
|
|
await executor.close_session("session-a")
|
|
client.disconnect.assert_not_awaited()
|
|
force_close.assert_awaited_once_with(client)
|
|
self.assertEqual(executor._clients, {})
|
|
|
|
_run(_t())
|
|
|
|
def test_text_deltas_yield_text_chunks(self):
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
ResultMessage as SDKResultMessage,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
StreamEvent as SDKStreamEvent,
|
|
)
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Sentinel:
|
|
pass
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _Sentinel
|
|
UserMessage = _Sentinel
|
|
SystemMessage = _Sentinel
|
|
StreamEvent = SDKStreamEvent
|
|
ResultMessage = SDKResultMessage
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
messages = [
|
|
SDKStreamEvent(
|
|
uuid="u1",
|
|
session_id="s1",
|
|
event={
|
|
"type": "content_block_delta",
|
|
"index": 0,
|
|
"delta": {"type": "text_delta", "text": "Hello"},
|
|
},
|
|
),
|
|
SDKStreamEvent(
|
|
uuid="u2",
|
|
session_id="s1",
|
|
event={
|
|
"type": "content_block_delta",
|
|
"index": 0,
|
|
"delta": {"type": "text_delta", "text": " world"},
|
|
},
|
|
),
|
|
SDKResultMessage(
|
|
subtype="result",
|
|
session_id="s1",
|
|
result="Hello world",
|
|
total_cost_usd=0.0,
|
|
duration_ms=100,
|
|
duration_api_ms=80,
|
|
is_error=False,
|
|
num_turns=1,
|
|
),
|
|
]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
chunks = [e for e in events if isinstance(e, TextChunk)]
|
|
self.assertEqual(len(chunks), 2)
|
|
self.assertEqual(chunks[0].text, "Hello")
|
|
self.assertEqual(chunks[1].text, " world")
|
|
self.assertIsInstance(events[-1], TurnComplete)
|
|
self.assertEqual(events[-1].response, "Hello world")
|
|
|
|
_run(_t())
|
|
|
|
def test_tool_use_assembly_yields_tool_call_request_with_args(self):
|
|
"""
|
|
A streaming turn that contains a ``content_block_start`` tool_use
|
|
event plus a subsequent ``AssistantMessage`` with the assembled
|
|
``ToolUseBlock.input`` must yield exactly one
|
|
:class:`ToolCallRequest` carrying the assembled args.
|
|
|
|
Regression pin: the streaming path used to emit
|
|
``ToolCallRequest(name=..., args={})`` at ``content_block_start``,
|
|
before Claude had streamed the ``input_json_delta`` events. The
|
|
REPL then rendered tool calls as ``⏵ Bash()`` with no visible
|
|
command because the args summary saw an empty dict. The fix
|
|
defers the emission to the ``AssistantMessage`` branch where
|
|
``tool_block.input`` is populated.
|
|
|
|
The test fails pre-fix at the args assertion below:
|
|
``reqs[0].args`` is ``{}``, not ``{"command": "ls -la"}``.
|
|
"""
|
|
# Real types from the SDK so the production
|
|
# ``isinstance(message, sdk.AssistantMessage)`` and
|
|
# ``isinstance(block, sdk.ToolUseBlock)`` checks fire. A
|
|
# MagicMock / _Sentinel would silently bypass both branches
|
|
# and the test would pass vacuously with zero emissions.
|
|
from claude_agent_sdk.types import (
|
|
AssistantMessage as SDKAssistantMessage,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
ResultMessage as SDKResultMessage,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
StreamEvent as SDKStreamEvent,
|
|
)
|
|
from claude_agent_sdk.types import (
|
|
ToolUseBlock as SDKToolUseBlock,
|
|
)
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Sentinel:
|
|
pass
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = SDKAssistantMessage
|
|
UserMessage = _Sentinel
|
|
SystemMessage = _Sentinel
|
|
StreamEvent = SDKStreamEvent
|
|
ResultMessage = SDKResultMessage
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
TextBlock = _Sentinel
|
|
ThinkingBlock = _Sentinel
|
|
ToolUseBlock = SDKToolUseBlock
|
|
ToolResultBlock = _Sentinel
|
|
messages = [
|
|
# Live SDK order: tool_use block_start fires before
|
|
# Claude has streamed the full ``input_json_delta``
|
|
# sequence. The production path now only tracks the
|
|
# tool id here — it does NOT emit ToolCallRequest
|
|
# yet. Keeping this event in the fixture ensures a
|
|
# future regression that re-introduces the early
|
|
# emission fails the ``len(reqs) == 1`` check below.
|
|
SDKStreamEvent(
|
|
uuid="u1",
|
|
session_id="s1",
|
|
event={
|
|
"type": "content_block_start",
|
|
"index": 0,
|
|
"content_block": {
|
|
"type": "tool_use",
|
|
"id": "tool_1",
|
|
"name": "Bash",
|
|
"input": {},
|
|
},
|
|
},
|
|
),
|
|
# Full AssistantMessage after input deltas have
|
|
# assembled — this is where ``tool_block.input`` is
|
|
# populated and the ToolCallRequest must fire.
|
|
SDKAssistantMessage(
|
|
content=[
|
|
SDKToolUseBlock(
|
|
id="tool_1",
|
|
name="Bash",
|
|
input={"command": "ls -la"},
|
|
),
|
|
],
|
|
model="claude-sonnet-4",
|
|
),
|
|
SDKResultMessage(
|
|
subtype="result",
|
|
session_id="s1",
|
|
result="",
|
|
total_cost_usd=0.0,
|
|
duration_ms=100,
|
|
duration_api_ms=80,
|
|
is_error=False,
|
|
num_turns=1,
|
|
),
|
|
]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "run ls"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
reqs = [e for e in events if isinstance(e, ToolCallRequest)]
|
|
# 1 = exactly one emission. A value of 2 would mean the
|
|
# early (empty-args) emission was re-introduced alongside
|
|
# the AssistantMessage one; 0 would mean the new path
|
|
# silently dropped the emission.
|
|
self.assertEqual(len(reqs), 1)
|
|
self.assertEqual(reqs[0].name, "Bash")
|
|
# The specific regression: args must be the assembled dict
|
|
# from ``tool_block.input``, not ``{}``. Pre-fix this was
|
|
# ``{}`` and downstream rendered ``⏵ Bash()``.
|
|
self.assertEqual(reqs[0].args, {"command": "ls -la"})
|
|
|
|
_run(_t())
|
|
|
|
def test_tool_result_error_yields_tool_call_complete_error(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _ToolUseBlock:
|
|
def __init__(self, id, name, input):
|
|
self.id = id
|
|
self.name = name
|
|
self.input = input
|
|
|
|
class _ToolResultBlock:
|
|
def __init__(self, tool_use_id, content, is_error):
|
|
self.tool_use_id = tool_use_id
|
|
self.content = content
|
|
self.is_error = is_error
|
|
|
|
class _AssistantMessage:
|
|
def __init__(self, content):
|
|
self.content = content
|
|
|
|
class _UserMessage:
|
|
def __init__(self, content):
|
|
self.content = content
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _AssistantMessage
|
|
UserMessage = _UserMessage
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = _ResultMessage
|
|
ToolUseBlock = _ToolUseBlock
|
|
ToolResultBlock = _ToolResultBlock
|
|
TextBlock = type("TextBlock", (), {})
|
|
ThinkingBlock = type("ThinkingBlock", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
messages = [
|
|
_AssistantMessage([_ToolUseBlock("tool_1", "fail", {})]),
|
|
_UserMessage(
|
|
[
|
|
_ToolResultBlock(
|
|
"tool_1",
|
|
[{"type": "text", "text": '{"error": "boom"}'}],
|
|
True,
|
|
)
|
|
]
|
|
),
|
|
_ResultMessage("s1", "done"),
|
|
]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "run fail"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
tool_events = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
self.assertEqual(len(tool_events), 1)
|
|
self.assertEqual(tool_events[0].status, ToolCallStatus.ERROR)
|
|
self.assertEqual(tool_events[0].error, "boom")
|
|
|
|
_run(_t())
|
|
|
|
def test_tool_result_blocked_yields_blocked_status(self):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _ToolUseBlock:
|
|
def __init__(self, id, name, input):
|
|
self.id = id
|
|
self.name = name
|
|
self.input = input
|
|
|
|
class _ToolResultBlock:
|
|
def __init__(self, tool_use_id, content, is_error):
|
|
self.tool_use_id = tool_use_id
|
|
self.content = content
|
|
self.is_error = is_error
|
|
|
|
class _AssistantMessage:
|
|
def __init__(self, content):
|
|
self.content = content
|
|
|
|
class _UserMessage:
|
|
def __init__(self, content):
|
|
self.content = content
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _AssistantMessage
|
|
UserMessage = _UserMessage
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = _ResultMessage
|
|
ToolUseBlock = _ToolUseBlock
|
|
ToolResultBlock = _ToolResultBlock
|
|
TextBlock = type("TextBlock", (), {})
|
|
ThinkingBlock = type("ThinkingBlock", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
messages = [
|
|
_AssistantMessage([_ToolUseBlock("tool_1", "blocked_tool", {})]),
|
|
_UserMessage(
|
|
[
|
|
_ToolResultBlock(
|
|
"tool_1",
|
|
[{"type": "text", "text": '{"blocked": true, "reason": "Nope"}'}],
|
|
True,
|
|
)
|
|
]
|
|
),
|
|
_ResultMessage("s1", "done"),
|
|
]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "run blocked"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
tool_events = [e for e in events if isinstance(e, ToolCallComplete)]
|
|
self.assertEqual(len(tool_events), 1)
|
|
self.assertEqual(tool_events[0].status, ToolCallStatus.BLOCKED)
|
|
self.assertEqual(tool_events[0].error, "Nope")
|
|
|
|
_run(_t())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _unset_env_var context manager
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unset_env_var_removes_and_restores(monkeypatch):
|
|
"""Env var present before ``with`` is absent during, restored after."""
|
|
from omnigent.inner.claude_sdk_executor import _unset_env_var
|
|
|
|
monkeypatch.setenv("CLAUDECODE", "parent-value")
|
|
with _unset_env_var("CLAUDECODE"):
|
|
assert "CLAUDECODE" not in os.environ
|
|
assert os.environ["CLAUDECODE"] == "parent-value"
|
|
|
|
|
|
def test_unset_env_var_noop_when_unset(monkeypatch):
|
|
"""When env var is not set before ``with``, block runs cleanly and key stays unset."""
|
|
from omnigent.inner.claude_sdk_executor import _unset_env_var
|
|
|
|
monkeypatch.delenv("CLAUDECODE", raising=False)
|
|
with _unset_env_var("CLAUDECODE"):
|
|
assert "CLAUDECODE" not in os.environ
|
|
assert "CLAUDECODE" not in os.environ
|
|
|
|
|
|
def test_unset_env_var_restores_on_exception(monkeypatch):
|
|
"""Restoration must still happen when the block raises."""
|
|
from omnigent.inner.claude_sdk_executor import _unset_env_var
|
|
|
|
monkeypatch.setenv("CLAUDECODE", "original")
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
with _unset_env_var("CLAUDECODE"):
|
|
assert "CLAUDECODE" not in os.environ
|
|
raise RuntimeError("boom")
|
|
assert os.environ["CLAUDECODE"] == "original"
|
|
|
|
|
|
def test_databricks_model_without_routing_raises() -> None:
|
|
"""``databricks-*`` model with ``gateway=False`` raises ``ValueError``.
|
|
|
|
Without the guard the executor silently hits ``api.anthropic.com``
|
|
and surfaces a confusing "model not found" error.
|
|
"""
|
|
import pytest
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
with pytest.raises(ValueError, match="Databricks-hosted model"):
|
|
ClaudeSDKExecutor(
|
|
model="databricks-claude-sonnet-4-6",
|
|
gateway=False,
|
|
)
|
|
|
|
|
|
def test_non_databricks_model_without_routing_does_not_raise() -> None:
|
|
"""Non-``databricks-*`` model with ``gateway=False`` must not raise.
|
|
|
|
Ensures the guard only fires on the ``databricks-`` prefix.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
executor = ClaudeSDKExecutor(
|
|
model="claude-3-5-sonnet-20241022",
|
|
gateway=False,
|
|
)
|
|
assert not executor._gateway
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ANTHROPIC_API_KEY stripping during connect
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_anthropic_api_key_stripped_during_connect(monkeypatch):
|
|
"""``_get_or_create_client`` must strip ``ANTHROPIC_API_KEY`` from
|
|
``os.environ`` during the ``connect()`` window so the Claude CLI
|
|
uses subscription auth instead of a developer API key.
|
|
|
|
This test drives ``_get_or_create_client`` with a stub SDK and
|
|
captures ``os.environ`` at the moment ``connect()`` is invoked,
|
|
ensuring both ``CLAUDECODE`` and ``ANTHROPIC_API_KEY`` are absent.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
# Env snapshot captured inside connect() -- proves the real code
|
|
# path strips the keys, not just a standalone _unset_env_var call.
|
|
connect_env: dict[str, str] = {}
|
|
|
|
class _StubClient:
|
|
"""Minimal client whose connect() captures os.environ."""
|
|
|
|
def __init__(self, options):
|
|
"""Store options for protocol compliance.
|
|
|
|
:param options: SDK options object (unused by stub).
|
|
"""
|
|
self.options = options
|
|
self._query = None
|
|
self._transport = None
|
|
|
|
async def connect(self) -> None:
|
|
"""Record env state at the moment the real code calls connect."""
|
|
connect_env.update(os.environ)
|
|
|
|
async def disconnect(self) -> None:
|
|
"""No-op disconnect."""
|
|
|
|
class _StubSDK:
|
|
"""Minimal SDK whose ClaudeSDKClient captures env during connect."""
|
|
|
|
ClaudeSDKClient = _StubClient
|
|
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-secret")
|
|
monkeypatch.setenv("CLAUDECODE", "parent-value")
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
options = SimpleNamespace()
|
|
await executor._get_or_create_client(
|
|
_StubSDK, # type: ignore[arg-type]
|
|
session_key="test-session",
|
|
options=options,
|
|
model=None,
|
|
)
|
|
|
|
# Both keys must be absent at the moment connect() ran.
|
|
assert "ANTHROPIC_API_KEY" not in connect_env, (
|
|
"ANTHROPIC_API_KEY leaked into connect() -- subscription auth bypassed"
|
|
)
|
|
assert "CLAUDECODE" not in connect_env, (
|
|
"CLAUDECODE leaked into connect() -- nested-session error risk"
|
|
)
|
|
# Both are restored after _get_or_create_client returns.
|
|
assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-test-secret"
|
|
assert os.environ["CLAUDECODE"] == "parent-value"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_or_create_client_surfaces_cli_stderr_on_connect_timeout(monkeypatch):
|
|
"""A connect timeout must include the CLI's stderr tail in the
|
|
raised ``TimeoutError`` so CI logs surface what the subprocess was
|
|
doing while it hung.
|
|
"""
|
|
from omnigent.inner import claude_sdk_executor as cse
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
monkeypatch.setattr(cse, "_CONNECT_TIMEOUT_SECONDS", 0.2)
|
|
|
|
class _StubClient:
|
|
def __init__(self, options) -> None:
|
|
self.options = options
|
|
# ``_force_close_client`` reads these on the failure path.
|
|
self._query = None
|
|
self._transport = None
|
|
|
|
async def connect(self) -> None:
|
|
assert callable(self.options.stderr), (
|
|
"executor must install a stderr callback before connect"
|
|
)
|
|
self.options.stderr("Spawning claude CLI...\n")
|
|
self.options.stderr("ERROR: hanging on auth refresh\n")
|
|
await asyncio.sleep(5.0)
|
|
|
|
async def disconnect(self) -> None:
|
|
pass
|
|
|
|
class _StubSDK:
|
|
ClaudeSDKClient = _StubClient
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
options = SimpleNamespace(stderr=None)
|
|
|
|
with pytest.raises(TimeoutError) as excinfo:
|
|
await executor._get_or_create_client(
|
|
_StubSDK, # type: ignore[arg-type]
|
|
session_key="test-session",
|
|
options=options,
|
|
model=None,
|
|
)
|
|
|
|
msg = str(excinfo.value)
|
|
# ``(no CLI stderr captured)`` here means the tee callback was never wired.
|
|
assert "Spawning claude CLI" in msg, msg
|
|
assert "hanging on auth refresh" in msg, msg
|
|
assert "after 0s" in msg
|
|
# ``_tee_stderr`` must be unwired in the finally so the buffer can be
|
|
# GC'd and post-connect stderr flows directly to the original callback.
|
|
assert options.stderr is None
|
|
|
|
|
|
@pytest.mark.parametrize("env_value", ["1", "true", "yes"])
|
|
def test_prepare_claude_cli_path_bypasses_wrapper_when_env_set(
|
|
monkeypatch, caplog, env_value: str
|
|
) -> None:
|
|
"""
|
|
``OMNIGENT_CLAUDE_SDK_NO_SANDBOX`` (any truthy value) must skip
|
|
``create_exec_launcher`` and hand back the raw CLI path. Used as a
|
|
diagnostic knob to isolate the sandbox as a cause of the silent
|
|
claude-sdk connect hang on the nightly Linux runner.
|
|
"""
|
|
from omnigent.inner.claude_sdk_executor import prepare_claude_cli_path
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
|
|
monkeypatch.setenv("OMNIGENT_CLAUDE_SDK_NO_SANDBOX", env_value)
|
|
|
|
def _fail_if_called(*args, **kwargs) -> str:
|
|
raise AssertionError("create_exec_launcher must not be called when bypass is enabled")
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.inner.claude_sdk_executor.create_exec_launcher",
|
|
_fail_if_called,
|
|
)
|
|
|
|
spec = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd="/tmp/work",
|
|
sandbox=OSEnvSandboxSpec(
|
|
type="linux_bwrap",
|
|
read_paths=["."],
|
|
write_paths=["."],
|
|
allow_network=True,
|
|
),
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="omnigent.inner.claude_sdk_executor"):
|
|
prepared = prepare_claude_cli_path("/usr/bin/claude", spec)
|
|
|
|
assert prepared.cli_path == "/usr/bin/claude"
|
|
# Bypass keeps native tools off so the diagnostic isolates the
|
|
# CLI-startup variable; matches the fallback shape used when the
|
|
# sandbox can't be applied for other reasons (line 569-574).
|
|
assert prepared.enable_native_tools is False
|
|
# The bypass must be loud in CI: a missing warning means a future
|
|
# diagnostic run could silently fall through without us noticing.
|
|
assert any("Sandbox bypass active" in record.getMessage() for record in caplog.records), [
|
|
r.getMessage() for r in caplog.records
|
|
]
|
|
|
|
|
|
def test_prepare_tight_cli_process_path_bypasses_wrapper_when_env_set(monkeypatch) -> None:
|
|
"""``prepare_tight_cli_process_path`` must also honor the bypass env."""
|
|
from omnigent.inner.claude_sdk_executor import prepare_tight_cli_process_path
|
|
|
|
monkeypatch.setenv("OMNIGENT_CLAUDE_SDK_NO_SANDBOX", "1")
|
|
|
|
def _fail_if_called(*args, **kwargs) -> str:
|
|
raise AssertionError("create_exec_launcher must not be called when bypass is enabled")
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.inner.claude_sdk_executor.create_exec_launcher",
|
|
_fail_if_called,
|
|
)
|
|
|
|
assert prepare_tight_cli_process_path("/usr/bin/claude") == "/usr/bin/claude"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _to_anthropic_content_blocks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _b64(text: str) -> str:
|
|
return base64.b64encode(text.encode()).decode()
|
|
|
|
|
|
def test_to_anthropic_content_blocks_pdf_uses_base64_source() -> None:
|
|
"""PDF input_file blocks must use ``source.type = "base64"`` — the
|
|
only MIME Anthropic accepts for the base64 document source shape."""
|
|
pdf_data = _b64(b"%PDF-1.4 fake pdf content".decode("latin-1"))
|
|
blocks = [{"type": "input_file", "file_data": f"data:application/pdf;base64,{pdf_data}"}]
|
|
result = _to_anthropic_content_blocks(blocks)
|
|
assert len(result) == 1
|
|
doc = result[0]
|
|
assert doc["type"] == "document"
|
|
assert doc["source"]["type"] == "base64"
|
|
assert doc["source"]["media_type"] == "application/pdf"
|
|
assert doc["source"]["data"] == pdf_data
|
|
|
|
|
|
def test_to_anthropic_content_blocks_markdown_uses_text_source() -> None:
|
|
"""Markdown input_file blocks must use ``source.type = "text"`` —
|
|
Anthropic rejects ``base64`` source with non-PDF media types."""
|
|
md_content = "# Hello\n\nThis is markdown."
|
|
md_b64 = _b64(md_content)
|
|
blocks = [{"type": "input_file", "file_data": f"data:text/markdown;base64,{md_b64}"}]
|
|
result = _to_anthropic_content_blocks(blocks)
|
|
assert len(result) == 1
|
|
doc = result[0]
|
|
assert doc["type"] == "document"
|
|
assert doc["source"]["type"] == "text"
|
|
assert doc["source"]["media_type"] == "text/plain"
|
|
assert doc["source"]["data"] == md_content
|
|
|
|
|
|
def test_to_anthropic_content_blocks_plain_text_uses_text_source() -> None:
|
|
"""``text/plain`` input_file blocks must also use ``source.type = "text"``."""
|
|
content = "just some plain text"
|
|
b64 = _b64(content)
|
|
blocks = [{"type": "input_file", "file_data": f"data:text/plain;base64,{b64}"}]
|
|
result = _to_anthropic_content_blocks(blocks)
|
|
assert len(result) == 1
|
|
doc = result[0]
|
|
assert doc["type"] == "document"
|
|
assert doc["source"]["type"] == "text"
|
|
assert doc["source"]["media_type"] == "text/plain"
|
|
assert doc["source"]["data"] == content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: connect-failure stderr surfacing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_or_create_client_surfaces_cli_stderr_on_connect_error() -> None:
|
|
"""A non-timeout connect failure includes captured CLI stderr."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _StubClient:
|
|
def __init__(self, options: object) -> None:
|
|
self._stderr_cb = getattr(options, "stderr", None)
|
|
self._query = None
|
|
self._transport = None
|
|
|
|
async def connect(self) -> None:
|
|
if self._stderr_cb is not None:
|
|
self._stderr_cb("unknown option '--no-session-persistence'\n")
|
|
raise RuntimeError("Command failed with exit code 1; Check stderr output for details")
|
|
|
|
async def disconnect(self) -> None:
|
|
pass
|
|
|
|
class _StubSDK:
|
|
ClaudeSDKClient = _StubClient
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
options = type("Opts", (), {"stderr": None})()
|
|
|
|
with pytest.raises(RuntimeError) as excinfo:
|
|
await executor._get_or_create_client(
|
|
_StubSDK, # type: ignore[arg-type]
|
|
session_key="test-session",
|
|
options=options,
|
|
model=None,
|
|
)
|
|
|
|
msg = str(excinfo.value)
|
|
assert "unknown option" in msg, (
|
|
f"Stderr not included in connect error.\nExpected 'unknown option' in: {msg!r}"
|
|
)
|
|
assert "Command failed" in msg, msg
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: TurnComplete.usage populated from ResultMessage.usage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_result_message_usage_populates_turn_complete_usage() -> None:
|
|
"""``ResultMessage.usage`` flows through to ``TurnComplete.usage``.
|
|
|
|
The claude-sdk executor must extract the provider-reported token counts
|
|
from ``ResultMessage.usage`` and forward them via ``TurnComplete.usage``
|
|
so the REPL context ring and ``/context`` command can display accurate
|
|
per-turn fill without falling back to a local ``count_tokens()`` estimate.
|
|
|
|
Regression guard: before this fix the executor yielded
|
|
``TurnComplete(response=..., usage=None)`` unconditionally, causing the
|
|
context ring to stop updating after the first turn (the idle-event fallback
|
|
only fired when ``host.tokens_used is None``, which was only true on the
|
|
first turn once the local estimate had been set).
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import ResultMessage as SDKResultMessage
|
|
from claude_agent_sdk.types import StreamEvent as SDKStreamEvent
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Sentinel:
|
|
pass
|
|
|
|
sdk_result = SDKResultMessage(
|
|
subtype="result",
|
|
session_id="s1",
|
|
result="Hello",
|
|
total_cost_usd=0.0,
|
|
duration_ms=100,
|
|
duration_api_ms=80,
|
|
is_error=False,
|
|
num_turns=1,
|
|
usage={
|
|
"input_tokens": 500,
|
|
"output_tokens": 200,
|
|
"cache_read_input_tokens": 50,
|
|
"cache_creation_input_tokens": 0,
|
|
},
|
|
)
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _Sentinel
|
|
UserMessage = _Sentinel
|
|
SystemMessage = _Sentinel
|
|
StreamEvent = SDKStreamEvent
|
|
ResultMessage = SDKResultMessage
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
messages = [sdk_result]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
|
|
turn = next(e for e in events if isinstance(e, TurnComplete))
|
|
|
|
# Provider-reported counts must appear verbatim — if this is None the
|
|
# REPL context ring will never update after the first turn for claude-sdk.
|
|
assert turn.usage is not None, (
|
|
"TurnComplete.usage is None — ResultMessage.usage was not captured. "
|
|
"The context ring and /context command will show stale data after "
|
|
"the first turn."
|
|
)
|
|
|
|
# input_tokens / output_tokens must come through from ResultMessage.usage
|
|
assert turn.usage["input_tokens"] == 500, (
|
|
f"input_tokens {turn.usage['input_tokens']} != 500 — "
|
|
"ResultMessage.usage['input_tokens'] was not forwarded."
|
|
)
|
|
assert turn.usage["output_tokens"] == 200, (
|
|
f"output_tokens {turn.usage['output_tokens']} != 200 — "
|
|
"ResultMessage.usage['output_tokens'] was not forwarded."
|
|
)
|
|
|
|
# total_tokens is computed as input + output so the toolbar ring can
|
|
# use usage["total_tokens"] without also needing individual fields.
|
|
assert turn.usage["total_tokens"] == 700, (
|
|
f"total_tokens {turn.usage['total_tokens']} != 700 (500+200) — "
|
|
"total_tokens must be computed as input_tokens + output_tokens."
|
|
)
|
|
|
|
# Cache fields pass through as-is so the server can persist them.
|
|
assert turn.usage.get("cache_read_input_tokens") == 50, (
|
|
f"cache_read_input_tokens {turn.usage.get('cache_read_input_tokens')} != 50 — "
|
|
"extra usage keys from ResultMessage.usage should be forwarded verbatim."
|
|
)
|
|
|
|
# context_tokens is the real prompt size = input + cache_creation +
|
|
# cache_read (500 + 0 + 50). The runner reads this to track context-window
|
|
# fill; without it the runner falls back to total_tokens (input + output,
|
|
# non-cached only), which under-counts because the bulk of a long
|
|
# conversation's prompt lives in cache_read_input_tokens. Note it differs
|
|
# from total_tokens here (550 != 700) precisely because total ignores the
|
|
# cached prompt while context includes it.
|
|
assert turn.usage["context_tokens"] == 550, (
|
|
f"context_tokens {turn.usage.get('context_tokens')} != 550 (500+0+50) — "
|
|
"context_tokens must sum input + cache_creation + cache_read so the "
|
|
"runner reports accurate, accumulating context-window fill."
|
|
)
|
|
|
|
# The resolved model is forwarded in usage so the server cost path can
|
|
# price the turn even when the agent spec pins no llm.model. The key must
|
|
# be present (here it's None — no model is configured in this unit test).
|
|
assert "model" in turn.usage, (
|
|
"TurnComplete.usage is missing the 'model' key — the server cost path "
|
|
"relies on it to price relay turns whose spec pins no llm.model."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_tokens_uses_last_call_not_cumulative_on_multi_iteration_turn() -> None:
|
|
"""``context_tokens`` must reflect the LAST API call, not the cumulative sum.
|
|
|
|
A single user turn can drive several internal API calls (the SDK's
|
|
tool-use loop). ``ResultMessage.usage`` reports the CUMULATIVE usage
|
|
across all of them, so its ``input_tokens`` / cache buckets are the
|
|
sum of every iteration's prompt. Since each iteration re-sends the
|
|
whole (growing) conversation, summing the prompt side K-fold
|
|
over-counts context — which surfaced as the toolbar context ring
|
|
spiking up during a tool-using turn and snapping back down on the
|
|
next plain turn.
|
|
|
|
Context-window FILL is a single prompt's size, not a billing sum: the
|
|
final call's prompt already contains the full conversation that carries
|
|
into the next turn. The executor captures each ``message_start``'s
|
|
per-call usage and uses the LAST one for ``context_tokens`` (mirroring
|
|
the openai-agents executor's ``raw_responses[-1]`` choice), while still
|
|
reporting the cumulative totals for billing.
|
|
|
|
Regression guard: before this fix ``context_tokens`` was
|
|
``cumulative_input + cumulative_cache_read`` (= 21100 here), which is
|
|
the K-fold over-count. The correct value is the last call's prompt
|
|
(= 10800).
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import ResultMessage as SDKResultMessage
|
|
from claude_agent_sdk.types import StreamEvent as SDKStreamEvent
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Sentinel:
|
|
pass
|
|
|
|
def _message_start(usage: dict[str, int]) -> SDKStreamEvent:
|
|
# One ``message_start`` opens one API call and carries that call's
|
|
# prompt-side usage. Two of them model a two-call (one tool-loop)
|
|
# turn; the executor must keep only the last for context.
|
|
return SDKStreamEvent(
|
|
uuid="u",
|
|
session_id="s1",
|
|
event={"type": "message_start", "message": {"usage": usage}},
|
|
)
|
|
|
|
# Call 1 (cold-ish): non-cached input 300 + 10000 read from cache.
|
|
first_call = _message_start(
|
|
{"input_tokens": 300, "cache_read_input_tokens": 10000, "cache_creation_input_tokens": 0}
|
|
)
|
|
# Call 2 (final, after the tool result is appended): the conversation
|
|
# grew, so this prompt is the real context = 500 + 10300 = 10800.
|
|
second_call = _message_start(
|
|
{"input_tokens": 500, "cache_read_input_tokens": 10300, "cache_creation_input_tokens": 0}
|
|
)
|
|
# ResultMessage carries the CUMULATIVE sum across both calls:
|
|
# input 800, cache_read 20300, output 250.
|
|
cumulative_result = SDKResultMessage(
|
|
subtype="result",
|
|
session_id="s1",
|
|
result="done",
|
|
total_cost_usd=0.0,
|
|
duration_ms=100,
|
|
duration_api_ms=80,
|
|
is_error=False,
|
|
num_turns=2,
|
|
usage={
|
|
"input_tokens": 800,
|
|
"output_tokens": 250,
|
|
"cache_read_input_tokens": 20300,
|
|
"cache_creation_input_tokens": 0,
|
|
},
|
|
)
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _Sentinel
|
|
UserMessage = _Sentinel
|
|
SystemMessage = _Sentinel
|
|
StreamEvent = SDKStreamEvent
|
|
ResultMessage = SDKResultMessage
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
messages = [first_call, second_call, cumulative_result]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
|
|
turn = next(e for e in events if isinstance(e, TurnComplete))
|
|
assert turn.usage is not None, "TurnComplete.usage is None on a multi-call turn."
|
|
|
|
# context_tokens = LAST call's prompt (500 + 0 + 10300), NOT the
|
|
# cumulative sum (800 + 20300 = 21100). This is the whole point of the
|
|
# fix: a tool-using turn must not inflate the context ring.
|
|
assert turn.usage["context_tokens"] == 10800, (
|
|
f"context_tokens {turn.usage.get('context_tokens')} != 10800 — must be the "
|
|
"LAST message_start call's prompt (input+cache_creation+cache_read), not the "
|
|
"cumulative ResultMessage sum (21100). Using the cumulative value over-counts "
|
|
"context K-fold on a K-call turn."
|
|
)
|
|
assert turn.usage["context_tokens"] != 21100, (
|
|
"context_tokens equals the cumulative over-count (21100) — the executor is "
|
|
"summing prompt usage across tool-loop iterations instead of taking the last call."
|
|
)
|
|
|
|
# Billing totals still come from the cumulative ResultMessage.usage so
|
|
# cost is charged for every call, not just the last one.
|
|
assert turn.usage["input_tokens"] == 800, (
|
|
f"input_tokens {turn.usage.get('input_tokens')} != 800 — billing input must "
|
|
"stay the cumulative cross-call sum from ResultMessage.usage."
|
|
)
|
|
assert turn.usage["output_tokens"] == 250, (
|
|
f"output_tokens {turn.usage.get('output_tokens')} != 250 — billing output must "
|
|
"stay the cumulative sum from ResultMessage.usage."
|
|
)
|
|
assert turn.usage["total_tokens"] == 1050, (
|
|
f"total_tokens {turn.usage.get('total_tokens')} != 1050 (800+250) — total must "
|
|
"be the cumulative input + output for billing."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_tokens_emitted_when_turn_ends_without_result_message() -> None:
|
|
"""A turn that never reaches ``ResultMessage`` still reports ``context_tokens``.
|
|
|
|
``context_tokens`` (context-window fill) is normally assembled in the
|
|
``ResultMessage`` branch at successful completion. But a turn can end the
|
|
stream without a ``ResultMessage`` — the CLI can close the stream early,
|
|
or the turn can be cut short before its final usage is reported. Before
|
|
this fix the executor yielded ``TurnComplete(usage=None)`` in that case,
|
|
so the occupancy meter froze at the previous successful turn's value and
|
|
showed a misleadingly low fill exactly when a session was in trouble
|
|
(#1533).
|
|
|
|
The per-call prompt size is already observed mid-turn from each
|
|
``message_start`` event, so when no ``ResultMessage`` arrives the executor
|
|
must fall back to that observed usage and still emit ``context_tokens``.
|
|
|
|
Regression guard: pre-fix ``turn.usage`` is ``None`` here because the
|
|
stream carries only a ``message_start`` and then stops.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import ResultMessage as SDKResultMessage
|
|
from claude_agent_sdk.types import StreamEvent as SDKStreamEvent
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Sentinel:
|
|
pass
|
|
|
|
# The turn opens one API call (carrying its prompt usage) and then the
|
|
# stream simply ends — no ResultMessage, mirroring an early CLI stream
|
|
# close or a turn cut short before final usage is reported.
|
|
message_start = SDKStreamEvent(
|
|
uuid="u1",
|
|
session_id="s1",
|
|
event={
|
|
"type": "message_start",
|
|
"message": {
|
|
"usage": {
|
|
"input_tokens": 400,
|
|
"cache_read_input_tokens": 9000,
|
|
"cache_creation_input_tokens": 100,
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _Sentinel
|
|
UserMessage = _Sentinel
|
|
SystemMessage = _Sentinel
|
|
StreamEvent = SDKStreamEvent
|
|
ResultMessage = SDKResultMessage
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
messages = [message_start] # note: no ResultMessage
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
|
|
turn = next(e for e in events if isinstance(e, TurnComplete))
|
|
assert turn.usage is not None, (
|
|
"TurnComplete.usage is None on a turn that ended without a ResultMessage — "
|
|
"the observed message_start prompt usage was discarded, so the occupancy "
|
|
"meter freezes at the last successful turn's value (#1533)."
|
|
)
|
|
|
|
# context_tokens = the observed prompt = input + cache_creation + cache_read
|
|
# (400 + 100 + 9000), so a failed/early-ending turn still refreshes fill.
|
|
assert turn.usage["context_tokens"] == 9500, (
|
|
f"context_tokens {turn.usage.get('context_tokens')} != 9500 (400+100+9000) — "
|
|
"an incomplete turn must report context_tokens from the last observed "
|
|
"message_start prompt so the occupancy meter does not freeze."
|
|
)
|
|
# output_tokens is unknown on an incomplete turn, so it is reported as 0
|
|
# rather than guessed; the meaningful field here is context_tokens.
|
|
assert turn.usage["output_tokens"] == 0, (
|
|
f"output_tokens {turn.usage.get('output_tokens')} != 0 — output is unknown "
|
|
"on an incomplete turn and must not be fabricated."
|
|
)
|
|
assert "model" in turn.usage, (
|
|
"TurnComplete.usage is missing the 'model' key on the incomplete-turn path."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assistant_message_model_flows_to_turn_usage() -> None:
|
|
"""The SDK's assistant-message model is forwarded in ``TurnComplete.usage``.
|
|
|
|
When the agent spec pins no model (a delegating supervisor on the gateway),
|
|
the resolved config model is ``None`` — but the Claude SDK reports the
|
|
concrete model on each ``AssistantMessage``. The executor captures it and
|
|
forwards it as ``usage["model"]`` so the server cost path can price the
|
|
turn. A regression here leaves cost unpriced ("—") for every unpinned
|
|
claude-sdk agent (the debbie/debby supervisors).
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import ResultMessage as SDKResultMessage
|
|
from claude_agent_sdk.types import StreamEvent as SDKStreamEvent
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _AsstMsg:
|
|
"""Minimal stand-in for the SDK AssistantMessage (carries model)."""
|
|
|
|
def __init__(self, content: list[object], model: str) -> None:
|
|
self.content = content
|
|
self.model = model
|
|
|
|
assistant = _AsstMsg(content=[], model="claude-opus-4-8")
|
|
sdk_result = SDKResultMessage(
|
|
subtype="result",
|
|
session_id="s1",
|
|
result="hi",
|
|
total_cost_usd=0.0,
|
|
duration_ms=1,
|
|
duration_api_ms=1,
|
|
is_error=False,
|
|
num_turns=1,
|
|
usage={"input_tokens": 100, "output_tokens": 50},
|
|
)
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _AsstMsg
|
|
UserMessage = type("_U", (), {})
|
|
SystemMessage = type("_S", (), {})
|
|
StreamEvent = SDKStreamEvent
|
|
ResultMessage = SDKResultMessage
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
messages = [assistant, sdk_result]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options: object) -> None:
|
|
self.options = options
|
|
|
|
async def connect(self) -> None:
|
|
return None
|
|
|
|
async def query(self, prompt: object, session_id: str = "default") -> None:
|
|
return None
|
|
|
|
async def receive_response(self): # type: ignore[no-untyped-def]
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self) -> None:
|
|
return None
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
|
|
turn = next(e for e in events if isinstance(e, TurnComplete))
|
|
assert turn.usage is not None
|
|
# No model is configured (no cfg.model / override), so a non-None model here
|
|
# can only be the one captured from the AssistantMessage.
|
|
assert turn.usage["model"] == "claude-opus-4-8", (
|
|
f"usage model {turn.usage.get('model')!r} != 'claude-opus-4-8' — the "
|
|
"assistant-message model was not captured/forwarded."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_result_message_usage_none_yields_turn_complete_without_usage() -> None:
|
|
"""When ``ResultMessage.usage`` is ``None``, ``TurnComplete.usage`` is ``None``.
|
|
|
|
The executor must not synthesize fake usage when the SDK doesn't report it
|
|
(e.g. Databricks gateway not returning usage). ``None`` usage causes the
|
|
REPL to fall back to its local ``count_tokens()`` estimate, which is correct
|
|
behaviour — no invented numbers should appear in the context ring.
|
|
|
|
Regression guard: if the usage-capture code unconditionally sets
|
|
``turn_usage = {}`` (or any non-None dict), the REPL would display
|
|
``0 / 200k (0%)`` instead of triggering the local estimate.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from claude_agent_sdk.types import (
|
|
ClaudeAgentOptions as SDKClaudeAgentOptions,
|
|
)
|
|
from claude_agent_sdk.types import ResultMessage as SDKResultMessage
|
|
from claude_agent_sdk.types import StreamEvent as SDKStreamEvent
|
|
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
class _Sentinel:
|
|
pass
|
|
|
|
sdk_result = SDKResultMessage(
|
|
subtype="result",
|
|
session_id="s1",
|
|
result="Hello",
|
|
total_cost_usd=0.0,
|
|
duration_ms=100,
|
|
duration_api_ms=80,
|
|
is_error=False,
|
|
num_turns=1,
|
|
usage=None, # SDK didn't report usage
|
|
)
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = _Sentinel
|
|
UserMessage = _Sentinel
|
|
SystemMessage = _Sentinel
|
|
StreamEvent = SDKStreamEvent
|
|
ResultMessage = SDKResultMessage
|
|
ClaudeAgentOptions = SDKClaudeAgentOptions
|
|
messages = [sdk_result]
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return None
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
return None
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
|
|
turn = next(e for e in events if isinstance(e, TurnComplete))
|
|
|
|
# usage=None triggers the REPL's local count_tokens() fallback,
|
|
# which is the right behaviour when the SDK doesn't report usage.
|
|
# A non-None empty dict {} would short-circuit the fallback with 0 tokens.
|
|
assert turn.usage is None, (
|
|
f"TurnComplete.usage should be None when ResultMessage.usage is None, "
|
|
f"got {turn.usage!r}. An empty dict would display 0 tokens in the ring."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: pre-execution TOOL_CALL policy gate (can_use_tool)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestToolCallPolicyGate(unittest.TestCase):
|
|
"""The ``can_use_tool`` gate that enforces TOOL_CALL policy on
|
|
connector-native MCP tools (``mcp__github__*`` etc.) before they run.
|
|
"""
|
|
|
|
def _make_executor(self, permission_mode="bypassPermissions"):
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
return ClaudeSDKExecutor(permission_mode=permission_mode)
|
|
|
|
@staticmethod
|
|
def _verdict(action, reason=None):
|
|
from omnigent.runtime.harnesses._scaffold import PolicyVerdictPayload
|
|
|
|
return PolicyVerdictPayload(action=action, reason=reason)
|
|
|
|
@staticmethod
|
|
def _perm_ctx():
|
|
return SimpleNamespace(tool_use_id="tu_1", agent_id=None, suggestions=[])
|
|
|
|
def test_connector_native_tool_triggers_tool_call_eval(self):
|
|
"""A connector-native tool name drives a PHASE_TOOL_CALL evaluation
|
|
with the SDK-native tool name and arguments."""
|
|
from claude_agent_sdk import PermissionResultAllow
|
|
|
|
async def _t():
|
|
executor = self._make_executor()
|
|
evaluator = AsyncMock(return_value=self._verdict("POLICY_ACTION_ALLOW"))
|
|
executor._policy_evaluator = evaluator
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_write",
|
|
{"title": "bug", "body": "x"},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
evaluator.assert_awaited_once()
|
|
phase, data = evaluator.await_args.args
|
|
self.assertEqual(phase, "PHASE_TOOL_CALL")
|
|
self.assertEqual(
|
|
data,
|
|
{
|
|
"name": "mcp__github__issue_write",
|
|
"arguments": {"title": "bug", "body": "x"},
|
|
},
|
|
)
|
|
self.assertIsInstance(result, PermissionResultAllow)
|
|
|
|
_run(_t())
|
|
|
|
def test_deny_verdict_blocks_execution(self):
|
|
"""A DENY verdict returns PermissionResultDeny carrying the
|
|
policy's reason and never reaches the elicitation handler."""
|
|
from claude_agent_sdk import PermissionResultDeny
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="default")
|
|
executor._policy_evaluator = AsyncMock(
|
|
return_value=self._verdict("POLICY_ACTION_DENY", reason="no writes to github")
|
|
)
|
|
elicit = AsyncMock(return_value=True)
|
|
executor._elicitation_handler = elicit
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_write",
|
|
{"title": "bug"},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
self.assertIsInstance(result, PermissionResultDeny)
|
|
self.assertEqual(result.message, "no writes to github")
|
|
# DENY short-circuits — no human prompt.
|
|
elicit.assert_not_awaited()
|
|
|
|
_run(_t())
|
|
|
|
def test_ask_verdict_prompts_even_under_bypass(self):
|
|
"""A raw ASK verdict is supported by routing to Omnigent
|
|
elicitation, even under bypassPermissions."""
|
|
from claude_agent_sdk import PermissionResultAllow
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="bypassPermissions")
|
|
executor._policy_evaluator = AsyncMock(
|
|
return_value=self._verdict("POLICY_ACTION_ASK", reason="approval required")
|
|
)
|
|
elicit = AsyncMock(return_value=True)
|
|
executor._elicitation_handler = elicit
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_write",
|
|
{"title": "bug"},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
self.assertIsInstance(result, PermissionResultAllow)
|
|
elicit.assert_awaited_once_with("mcp__github__issue_write", {"title": "bug"})
|
|
|
|
_run(_t())
|
|
|
|
def test_ask_verdict_denies_when_user_declines(self):
|
|
"""A declined raw ASK blocks execution with the policy reason."""
|
|
from claude_agent_sdk import PermissionResultDeny
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="bypassPermissions")
|
|
executor._policy_evaluator = AsyncMock(
|
|
return_value=self._verdict("POLICY_ACTION_ASK", reason="approval required")
|
|
)
|
|
executor._elicitation_handler = AsyncMock(return_value=False)
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_write",
|
|
{"title": "bug"},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
self.assertIsInstance(result, PermissionResultDeny)
|
|
self.assertEqual(result.message, "approval required")
|
|
|
|
_run(_t())
|
|
|
|
def test_ask_verdict_without_elicitation_handler_fails_closed(self):
|
|
"""If raw ASK reaches the callback but no handler is available,
|
|
the tool must not run."""
|
|
from claude_agent_sdk import PermissionResultDeny
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="bypassPermissions")
|
|
executor._policy_evaluator = AsyncMock(
|
|
return_value=self._verdict("POLICY_ACTION_ASK", reason="approval required")
|
|
)
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_write",
|
|
{"title": "bug"},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
self.assertIsInstance(result, PermissionResultDeny)
|
|
self.assertEqual(result.message, "approval required")
|
|
|
|
_run(_t())
|
|
|
|
def test_unspecified_verdict_falls_through(self):
|
|
"""UNSPECIFIED is a proto no-op verdict and should behave like no match."""
|
|
from claude_agent_sdk import PermissionResultAllow
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="bypassPermissions")
|
|
executor._policy_evaluator = AsyncMock(
|
|
return_value=self._verdict("POLICY_ACTION_UNSPECIFIED")
|
|
)
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_read",
|
|
{"number": 1},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
self.assertIsInstance(result, PermissionResultAllow)
|
|
|
|
_run(_t())
|
|
|
|
def test_unexpected_verdict_fails_closed(self):
|
|
"""Unknown policy actions should not silently allow a tool call."""
|
|
from claude_agent_sdk import PermissionResultDeny
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="bypassPermissions")
|
|
executor._policy_evaluator = AsyncMock(return_value=self._verdict("BOGUS"))
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_write",
|
|
{"title": "bug"},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
self.assertIsInstance(result, PermissionResultDeny)
|
|
self.assertIn("Unexpected Omnigent TOOL_CALL policy verdict", result.message)
|
|
|
|
_run(_t())
|
|
|
|
def test_allow_verdict_no_human_prompt_under_bypass(self):
|
|
"""ALLOW under bypassPermissions allows the call with no human
|
|
prompt, preserving bypass ergonomics."""
|
|
from claude_agent_sdk import PermissionResultAllow
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="bypassPermissions")
|
|
executor._policy_evaluator = AsyncMock(
|
|
return_value=self._verdict("POLICY_ACTION_ALLOW")
|
|
)
|
|
elicit = AsyncMock(return_value=True)
|
|
executor._elicitation_handler = elicit
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_read",
|
|
{"number": 1},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
self.assertIsInstance(result, PermissionResultAllow)
|
|
elicit.assert_not_awaited()
|
|
|
|
_run(_t())
|
|
|
|
def test_no_policy_evaluator_no_match_allows_silently(self):
|
|
"""With no policy evaluator wired (default ALLOW), the gate allows
|
|
with no evaluation and no human prompt under bypassPermissions."""
|
|
from claude_agent_sdk import PermissionResultAllow
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="bypassPermissions")
|
|
# No _policy_evaluator set, no elicitation handler.
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__atlassian__createJiraIssue",
|
|
{"summary": "x"},
|
|
self._perm_ctx(),
|
|
)
|
|
self.assertIsInstance(result, PermissionResultAllow)
|
|
|
|
_run(_t())
|
|
|
|
def test_omnigent_own_tool_skips_evaluation(self):
|
|
"""``mcp__omnigent__*`` tools are already TOOL_CALL-gated server-side
|
|
via the dispatch bridge / ProxyMcpManager, so the gate must NOT
|
|
evaluate them again (avoids double-evaluation)."""
|
|
from claude_agent_sdk import PermissionResultAllow
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="bypassPermissions")
|
|
evaluator = AsyncMock(return_value=self._verdict("POLICY_ACTION_ALLOW"))
|
|
executor._policy_evaluator = evaluator
|
|
|
|
result = await executor._can_use_tool_gate(
|
|
"mcp__omnigent__sys_os_read",
|
|
{"path": "/tmp/x"},
|
|
self._perm_ctx(),
|
|
)
|
|
|
|
self.assertIsInstance(result, PermissionResultAllow)
|
|
evaluator.assert_not_awaited()
|
|
|
|
_run(_t())
|
|
|
|
def test_non_bypass_runs_elicitation_after_policy_allow(self):
|
|
"""In a non-bypass mode, a policy ALLOW falls through to the
|
|
human-consent elicitation gate (pre-existing behavior)."""
|
|
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|
|
|
async def _t():
|
|
executor = self._make_executor(permission_mode="default")
|
|
executor._policy_evaluator = AsyncMock(
|
|
return_value=self._verdict("POLICY_ACTION_ALLOW")
|
|
)
|
|
|
|
# Human approves.
|
|
executor._elicitation_handler = AsyncMock(return_value=True)
|
|
approved = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_read", {}, self._perm_ctx()
|
|
)
|
|
self.assertIsInstance(approved, PermissionResultAllow)
|
|
|
|
# Human denies.
|
|
executor._elicitation_handler = AsyncMock(return_value=False)
|
|
denied = await executor._can_use_tool_gate(
|
|
"mcp__github__issue_read", {}, self._perm_ctx()
|
|
)
|
|
self.assertIsInstance(denied, PermissionResultDeny)
|
|
|
|
_run(_t())
|
|
|
|
def test_gate_installed_under_bypass_permissions(self):
|
|
"""run_turn installs the can_use_tool gate even under
|
|
bypassPermissions when a policy evaluator is wired — the
|
|
regression this feature fixes."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor(permission_mode="bypassPermissions")
|
|
executor._policy_evaluator = AsyncMock(
|
|
return_value=self._verdict("POLICY_ACTION_ALLOW")
|
|
)
|
|
|
|
captured = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["can_use_tool"] = options.can_use_tool
|
|
raise RuntimeError("stop after options build")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
# Bound-method identity differs per access; compare equality.
|
|
self.assertEqual(captured["can_use_tool"], executor._can_use_tool_gate)
|
|
self.assertIsNotNone(captured["can_use_tool"])
|
|
|
|
_run(_t())
|
|
|
|
def test_gate_not_installed_without_evaluator_or_handler(self):
|
|
"""With neither a policy evaluator nor an elicitation handler, no
|
|
can_use_tool callback is installed (unchanged baseline)."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor(permission_mode="bypassPermissions")
|
|
captured = {}
|
|
|
|
async def fake_get_or_create_client(sdk, *, session_key, options, model):
|
|
captured["can_use_tool"] = options.can_use_tool
|
|
raise RuntimeError("stop after options build")
|
|
|
|
with patch.object(
|
|
executor,
|
|
"_get_or_create_client",
|
|
side_effect=fake_get_or_create_client,
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
async for _ in executor.run_turn([{"role": "user", "content": "hi"}], [], ""):
|
|
pass
|
|
|
|
self.assertIsNone(captured["can_use_tool"])
|
|
|
|
_run(_t())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Compaction detection via PreCompact hook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_precompact_hook_emits_compaction_complete_with_session_messages() -> None:
|
|
"""When PreCompact fires and a ResultMessage carries a session_id,
|
|
CompactionComplete is emitted with compacted_messages read from
|
|
the CLI's session transcript."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.executor import CompactionComplete
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
self.content = []
|
|
self.model = "claude-test"
|
|
self.usage = type(
|
|
"U",
|
|
(),
|
|
{
|
|
"input_tokens": 500,
|
|
"output_tokens": 100,
|
|
},
|
|
)()
|
|
|
|
class _SystemMessage:
|
|
def __init__(self, subtype, data, hook_event_name=None):
|
|
self.subtype = subtype
|
|
self.data = data
|
|
self.hook_event_name = hook_event_name
|
|
|
|
class _HookEventMessage(_SystemMessage):
|
|
pass
|
|
|
|
class _FakeSessionMessage:
|
|
def __init__(self, type, message):
|
|
self.type = type
|
|
self.message = message
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = type("AssistantMessage", (), {})
|
|
UserMessage = type("UserMessage", (), {})
|
|
SystemMessage = _SystemMessage
|
|
ResultMessage = _ResultMessage
|
|
StreamEvent = type("StreamEvent", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
messages: list = []
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
_FakeSDK.messages = [
|
|
_HookEventMessage(
|
|
subtype="hook_started",
|
|
data={"hook_event": "PreCompact"},
|
|
hook_event_name="PreCompact",
|
|
),
|
|
_ResultMessage("claude-uuid-123", "compacted result"),
|
|
]
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
fake_session_msgs = [
|
|
_FakeSessionMessage("user", {"content": [{"type": "text", "text": "hi"}]}),
|
|
_FakeSessionMessage("assistant", {"content": [{"type": "text", "text": "compacted"}]}),
|
|
]
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with (
|
|
patch(
|
|
"omnigent.inner.claude_sdk_executor._ensure_sdk",
|
|
return_value=_FakeSDK,
|
|
),
|
|
patch(
|
|
"claude_agent_sdk.get_session_messages",
|
|
return_value=fake_session_msgs,
|
|
) as mock_get_msgs,
|
|
):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi", "session_id": "s1"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
|
|
compaction_events = [e for e in events if isinstance(e, CompactionComplete)]
|
|
assert len(compaction_events) == 1
|
|
ce = compaction_events[0]
|
|
assert ce.compacted_messages is not None
|
|
assert len(ce.compacted_messages) == 2
|
|
assert ce.compacted_messages[0]["role"] == "user"
|
|
assert ce.compacted_messages[1]["role"] == "assistant"
|
|
mock_get_msgs.assert_called_once_with("claude-uuid-123", directory=None)
|
|
# CompactionComplete before TurnComplete
|
|
turn_completes = [e for e in events if isinstance(e, TurnComplete)]
|
|
assert len(turn_completes) == 1
|
|
assert events.index(compaction_events[0]) < events.index(turn_completes[0])
|
|
|
|
_run(_t())
|
|
|
|
|
|
def test_no_precompact_no_compaction_event() -> None:
|
|
"""When no PreCompact hook fires, no CompactionComplete is yielded."""
|
|
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
|
from omnigent.inner.executor import CompactionComplete
|
|
|
|
class _ResultMessage:
|
|
def __init__(self, session_id, result):
|
|
self.session_id = session_id
|
|
self.result = result
|
|
self.content = []
|
|
self.model = "claude-test"
|
|
self.usage = type(
|
|
"U",
|
|
(),
|
|
{
|
|
"input_tokens": 500,
|
|
"output_tokens": 100,
|
|
},
|
|
)()
|
|
|
|
class _FakeSDK:
|
|
AssistantMessage = type("AssistantMessage", (), {})
|
|
UserMessage = type("UserMessage", (), {})
|
|
SystemMessage = type("SystemMessage", (), {})
|
|
ResultMessage = _ResultMessage
|
|
StreamEvent = type("StreamEvent", (), {})
|
|
ClaudeAgentOptions = type(
|
|
"ClaudeAgentOptions",
|
|
(),
|
|
{"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)},
|
|
)
|
|
messages: list = []
|
|
|
|
class ClaudeSDKClient:
|
|
def __init__(self, options):
|
|
self.options = options
|
|
|
|
async def connect(self):
|
|
return
|
|
|
|
async def query(self, prompt, session_id="default"):
|
|
_FakeSDK.messages = [_ResultMessage(session_id, "normal result")]
|
|
|
|
async def receive_response(self):
|
|
for message in _FakeSDK.messages:
|
|
yield message
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def _t():
|
|
executor = ClaudeSDKExecutor()
|
|
with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK):
|
|
events = [
|
|
e
|
|
async for e in executor.run_turn(
|
|
[{"role": "user", "content": "hi", "session_id": "s1"}],
|
|
[],
|
|
"",
|
|
)
|
|
]
|
|
|
|
compaction_events = [e for e in events if isinstance(e, CompactionComplete)]
|
|
assert len(compaction_events) == 0
|
|
|
|
_run(_t())
|