Files

379 lines
13 KiB
Python

"""Tests for `raycast_api.ai.chat.ChatAPI`.
Covers, in roughly increasing scope:
- `_build_body` produces fields in the right order with the right defaults
per `source`.
- Tool/message serialisation matches the wire shape from BUNDLE_NOTES §3.
- `UserPreferences.render()` matches the byte-exact preamble from the
real Raycast `Ya()` function.
- `complete(...)` accumulates deltas into a single `ChatResult` and
handles the streamed-arguments case (concatenating tool-call argument
fragments across chunks).
"""
from __future__ import annotations
import json
from typing import Any
import pytest
from aioresponses import aioresponses
from raycast_api.ai import (
ChatAPI,
ChatStreamChunk,
Message,
RemoteTool,
Source,
Tool,
ToolCall,
UserPreferences,
)
from raycast_api.client import Client
from raycast_api.config import Config
from raycast_api.signing_spec import RotRange, SigningSpec
REFERENCE_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"
DEVICE_ID = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099"
FIXED_TIMESTAMP = 1778858809
def _config() -> Config:
return Config(
signature_secret=REFERENCE_SECRET,
signing_spec=SigningSpec(
rot_fn_name="Sur",
signing_fn_name="Nkt",
rot_ranges=[
RotRange(start=65, end=90, shift=13),
RotRange(start=97, end=122, shift=13),
RotRange(start=48, end=57, shift=5),
],
),
app_version="0.60.1.0",
user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)",
bundle_hash="0" * 64,
launcher_hash="0" * 64,
)
def _client(**kwargs: Any) -> Client:
return Client(
config=_config(),
bearer_token="rca_test_token",
device_id=DEVICE_ID,
clock=lambda: FIXED_TIMESTAMP,
locale="en-GB",
**kwargs,
)
class TestUserPreferences:
def test_render_matches_real_client_wording(self) -> None:
"""The block must be byte-identical to what `Ya()` emits.
The captured request body in `request_simple.curl.txt` contains:
<user-preferences>\\n
The user has the following system preferences:\\n
- Locale: en-GB\\n
- Timezone: Europe/Warsaw\\n
- Current Date: 2026-05-15\\n
- Use the system preferences to format your answers accordingly\\n
</user-preferences>
Any deviation (spacing, line breaks, punctuation) breaks the
fingerprint match.
"""
prefs = UserPreferences(
locale="en-GB", timezone="Europe/Warsaw", current_date="2026-05-15"
)
rendered = prefs.render()
assert rendered == (
"<user-preferences>\n"
" The user has the following system preferences:\n"
" - Locale: en-GB\n"
" - Timezone: Europe/Warsaw\n"
" - Current Date: 2026-05-15\n"
" - Use the system preferences to format your answers accordingly\n"
"</user-preferences>"
)
def test_auto_picks_today_and_locale_argument(self) -> None:
import datetime
prefs = UserPreferences.auto(locale="ru-RU")
assert prefs.locale == "ru-RU"
assert prefs.current_date == datetime.date.today().isoformat()
assert prefs.timezone
class TestSerialisation:
def test_remote_tool_shape(self) -> None:
assert Tool.remote("web_search").to_wire() == {
"type": "remote_tool",
"name": "web_search",
}
assert Tool.remote(RemoteTool.SEARCH_IMAGES).to_wire() == {
"type": "remote_tool",
"name": "search_images",
}
def test_local_tool_shape(self) -> None:
t = Tool.local(
name="weather__get",
description="get weather",
parameters={"type": "object", "properties": {"city": {"type": "string"}}},
)
assert t.to_wire() == {
"type": "local_tool",
"function": {
"name": "weather__get",
"description": "get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
def test_user_message(self) -> None:
assert Message.user("hello").to_wire() == {
"role": "user",
"content": {"text": "hello"},
}
def test_assistant_with_tool_calls(self) -> None:
msg = Message.assistant(
text="",
tool_calls=[
ToolCall(
id="abc", name="coffee__caffeinate-for", arguments='{"minutes":5}'
)
],
extra_content={"google": {"thought_signature": "xyz"}},
)
assert msg.to_wire() == {
"role": "assistant",
"content": {"text": ""},
"tool_calls": [
{
"id": "abc",
"type": "function",
"function": {
"name": "coffee__caffeinate-for",
"arguments": '{"minutes":5}',
},
}
],
"extra_content": {"google": {"thought_signature": "xyz"}},
}
def test_tool_message_wraps_string_as_mcp_text(self) -> None:
msg = Message.tool(
tool_call_id="abc",
name="coffee__caffeinate-for",
result="Mac will stay awake for 5m",
)
assert msg.to_wire() == {
"role": "tool",
"content": {
"text": '[{"type":"text","text":"Mac will stay awake for 5m"}]'
},
"name": "coffee__caffeinate-for",
"tool_call_id": "abc",
}
class TestBuildBody:
def test_minimal_body_field_order(self) -> None:
"""First-turn body should serialise fields in the captured order."""
chat = ChatAPI(_client())
body = chat._build_body(
model="gemini-3.1-pro-preview",
provider="google",
messages=[Message.user("привет")],
source=Source.AI_CHAT,
buffer_id="8480fbbb-4592-4257-812d-f24a67da3c07",
message_id="2f138e1c-edcf-495b-915c-db5cbb154674",
locale="en-GB",
current_date="2026-05-15",
system_instructions="markdown",
additional_system_instructions="<user-preferences>\n The user has the following system preferences:\n - Locale: en-GB\n - Timezone: Europe/Warsaw\n - Current Date: 2026-05-15\n - Use the system preferences to format your answers accordingly\n</user-preferences>",
temperature=0,
reasoning_effort="high",
tools=[
Tool.remote(RemoteTool.WEB_SEARCH).to_wire(),
Tool.remote(RemoteTool.SEARCH_IMAGES).to_wire(),
Tool.remote(RemoteTool.READ_PAGE).to_wire(),
],
tool_choice="auto",
resume_from=None,
)
keys = list(body.keys())
assert keys == [
"system_instructions",
"additional_system_instructions",
"locale",
"temperature",
"current_date",
"message_id",
"reasoning_effort",
"messages",
"tools",
"tool_choice",
"source",
"model",
"provider",
"buffer_id",
]
def test_omits_optional_fields_when_none(self) -> None:
"""No tools → no tools / tool_choice in the body at all."""
chat = ChatAPI(_client())
body = chat._build_body(
model="m",
provider="p",
messages=[Message.user("hi")],
source=Source.AI_CHAT,
buffer_id="b",
message_id="m",
locale="en-US",
current_date=None,
system_instructions=None,
additional_system_instructions=None,
temperature=None,
reasoning_effort=None,
tools=None,
tool_choice=None,
resume_from=None,
)
assert "tools" not in body
assert "tool_choice" not in body
assert "temperature" not in body
assert "system_instructions" not in body
assert "additional_system_instructions" not in body
assert "reasoning_effort" not in body
assert "current_date" not in body
assert body["model"] == "m"
assert body["provider"] == "p"
assert body["buffer_id"] == "b"
assert body["source"] == "ai_chat"
def test_source_default_temperature_only_applies_when_unspecified(self) -> None:
"""Quick AI defaults to 0.2; passing temperature=0 overrides."""
chat = ChatAPI(_client())
from raycast_api.ai.chat import _SOURCE_DEFAULTS
defaults = _SOURCE_DEFAULTS[Source.QUICK_AI]
assert defaults["temperature"] == 0.2
assert defaults["system_instructions"] == "plain"
class TestComplete:
@pytest.mark.asyncio
async def test_complete_concatenates_streamed_tool_arguments(self) -> None:
"""If `arguments` arrives in multiple chunks, they're concatenated.
Constructs a synthetic SSE stream where the same tool_call id
appears across two chunks with partial `arguments` payloads.
"""
sse = (
b"id: 0\n"
b'data: {"text":"","tool_calls":[{"id":"tc1","name":"f","arguments":"{\\"a\\":"}]}\n\n'
b"id: 1\n"
b'data: {"text":"","tool_calls":[{"id":"tc1","arguments":"1}"}]}\n\n'
b"id: 2\n"
b'data: {"text":"","finish_reason":"STOP","usage":{"input_tokens":1,"output_tokens":1}}\n\n'
b'event: complete\ndata: {"complete":true}\n\n'
)
with aioresponses() as mocked:
mocked.post(
"https://backend.raycast.com/api/v1/ai/chat_completions",
status=200,
body=sse,
headers={"Content-Type": "text/event-stream"},
)
async with _client() as client:
result = await client.chat.complete(
model="m",
provider="p",
messages=[Message.user("x")],
user_preferences=False,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].id == "tc1"
assert result.tool_calls[0].name == "f"
assert result.tool_calls[0].arguments == '{"a":1}'
class TestSignedBytesMatch:
"""When we call `client.chat.stream`, the body bytes the request carries
must equal the bytes the Signer signed. `aioresponses` lets us capture
the outgoing body via a callback.
"""
@pytest.mark.asyncio
async def test_stream_post_body_matches_signed_bytes(self) -> None:
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["data"] = kwargs.get("data")
captured["headers"] = kwargs.get("headers")
from aioresponses import CallbackResult
return CallbackResult(
status=200,
body=b'event: complete\ndata: {"complete":true}\n\n',
headers={"Content-Type": "text/event-stream"},
)
with aioresponses() as mocked:
mocked.post(
"https://backend.raycast.com/api/v1/ai/chat_completions", callback=_cb
)
async with _client() as client:
async for _ in client.chat.stream(
model="m",
provider="p",
messages=[Message.user("hi")],
user_preferences=False,
buffer_id="b",
message_id="mid",
current_date="2026-05-15",
):
pass
body_bytes = captured["data"]
if hasattr(body_bytes, "_value"):
body_bytes = body_bytes._value
assert isinstance(body_bytes, (bytes, bytearray))
from raycast_api.signing import Signer
signer = Signer(spec=_config().signing_spec, secret=REFERENCE_SECRET)
expected_sig = signer.sign(
timestamp=str(FIXED_TIMESTAMP), device_id=DEVICE_ID, body=bytes(body_bytes)
)
assert captured["headers"]["X-Raycast-Signature-v2"] == expected_sig
parsed = json.loads(bytes(body_bytes))
assert parsed["model"] == "m"
assert parsed["provider"] == "p"
assert parsed["buffer_id"] == "b"
assert parsed["message_id"] == "mid"
assert parsed["source"] == "ai_chat"
assert parsed["system_instructions"] == "markdown"