Files
2026-07-13 13:12:33 +08:00

1261 lines
43 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import json
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any
import pytest
from opensquilla.engine.runtime import TurnRunner
from opensquilla.engine.types import ArtifactEvent, DoneEvent, TextDeltaEvent, ToolUseStartEvent
from opensquilla.gateway.config import AttachmentsConfig, GatewayConfig, SquillaRouterConfig
from opensquilla.provider import DoneEvent as ProviderDone
from opensquilla.provider import Message, ModelInfo
from opensquilla.provider import TextDeltaEvent as ProviderText
from opensquilla.provider import ToolUseEndEvent as ProviderToolUseEnd
from opensquilla.provider import ToolUseStartEvent as ProviderToolUseStart
from opensquilla.session.manager import SessionManager
from opensquilla.session.storage import SessionStorage
from opensquilla.tools.builtin import filesystem
from opensquilla.tools.builtin import patch as patch_tools
from opensquilla.tools.registry import ToolRegistry, ToolSpec
from opensquilla.tools.types import CallerKind, ToolContext, ToolError, current_tool_context
class _ArtifactProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(tool_use_id="tool-1", tool_name="make_file")
yield ProviderToolUseEnd(
tool_use_id="tool-1",
tool_name="make_file",
arguments={},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="done")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _BlockingAfterArtifactProvider(_ArtifactProvider):
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
async for event in super()._stream(call_number):
yield event
return
await asyncio.Event().wait()
class _PostPublishToolLoopProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
self.tools_seen: list[bool] = []
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
self.tools_seen.append(bool(tools))
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(
tool_use_id="publish-1",
tool_name="publish_artifact",
)
yield ProviderToolUseEnd(
tool_use_id="publish-1",
tool_name="publish_artifact",
arguments={"path": "report.pptx"},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderToolUseStart(tool_use_id="qa-1", tool_name="qa_check")
yield ProviderToolUseEnd(
tool_use_id="qa-1",
tool_name="qa_check",
arguments={"path": "report.pptx"},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _SelectorClone:
current_config = SimpleNamespace(model="test/model")
def __init__(self, provider: _ArtifactProvider) -> None:
self.provider = provider
def override_model(self, model: str) -> None:
self.current_config = SimpleNamespace(model=model)
self.provider.model = model
def resolve(self) -> _ArtifactProvider:
return self.provider
class _ProviderSelector:
def __init__(self, provider: _ArtifactProvider) -> None:
self.provider = provider
def clone(self) -> _SelectorClone:
return _SelectorClone(self.provider)
class _FailedPublishProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(
tool_use_id="publish-1",
tool_name="publish_artifact",
)
yield ProviderToolUseEnd(
tool_use_id="publish-1",
tool_name="publish_artifact",
arguments={"path": "missing-report.pptx"},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="Report file is ready for download.")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _OmittedPublishProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(
tool_use_id="write-1",
tool_name="write_file",
)
yield ProviderToolUseEnd(
tool_use_id="write-1",
tool_name="write_file",
arguments={
"path": "manual-big-write.html",
"content": "<!doctype html><title>Manual</title>",
},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="Created manual-big-write.html for you.")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _OmittedPatchPublishProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(
tool_use_id="patch-1",
tool_name="apply_patch",
)
yield ProviderToolUseEnd(
tool_use_id="patch-1",
tool_name="apply_patch",
arguments={
"patch": (
"*** Begin Patch\n"
"*** Add File: patched.html\n"
"+<!doctype html><title>Patched</title>\n"
"*** End Patch\n"
),
},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="Created patched.html.")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _EditedConfigProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(
tool_use_id="edit-1",
tool_name="edit_file",
)
yield ProviderToolUseEnd(
tool_use_id="edit-1",
tool_name="edit_file",
arguments={
"path": "config.json",
"old_text": "\"enabled\": false",
"new_text": "\"enabled\": true",
},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="Updated config.json.")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _MixedSizeOmittedPublishProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
for index, payload in enumerate(
(
{"path": "small.html", "content": "<title>ok</title>"},
{"path": "large.html", "content": "<title>" + ("x" * 80) + "</title>"},
),
start=1,
):
yield ProviderToolUseStart(
tool_use_id=f"write-{index}",
tool_name="write_file",
)
yield ProviderToolUseEnd(
tool_use_id=f"write-{index}",
tool_name="write_file",
arguments=payload,
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="Created small.html and large.html.")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _MemoryJsonWriteProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(
tool_use_id="write-1",
tool_name="write_file",
)
yield ProviderToolUseEnd(
tool_use_id="write-1",
tool_name="write_file",
arguments={
"path": "memory/cache.json",
"content": "{\"state\":\"internal\"}",
},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="Updated memory/cache.json.")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _SameContentOmittedPublishProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
for index, path in enumerate(("first.html", "second.html"), start=1):
yield ProviderToolUseStart(
tool_use_id=f"write-{index}",
tool_name="write_file",
)
yield ProviderToolUseEnd(
tool_use_id=f"write-{index}",
tool_name="write_file",
arguments={
"path": path,
"content": "<!doctype html><title>Same</title>",
},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="Created first.html and second.html.")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _PartialOmittedPublishProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
self.model = "test/model"
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(tool_use_id="make-1", tool_name="make_file")
yield ProviderToolUseEnd(
tool_use_id="make-1",
tool_name="make_file",
arguments={},
)
yield ProviderToolUseStart(
tool_use_id="write-1",
tool_name="write_file",
)
yield ProviderToolUseEnd(
tool_use_id="write-1",
tool_name="write_file",
arguments={
"path": "second.html",
"content": "<!doctype html><title>Second</title>",
},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderText(text="Created runtime.txt and second.html.")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
def _registry() -> ToolRegistry:
registry = ToolRegistry()
async def make_file() -> str:
ctx = current_tool_context.get()
assert ctx is not None
ctx.published_artifacts.append(
{
"id": "art-runtime",
"kind": "artifact_ref",
"name": "runtime.txt",
"mime": "text/plain",
"size": 4,
"sha256": "b" * 64,
"session_id": ctx.artifact_session_id,
"session_key": ctx.session_key,
"source": "make_file",
"created_at": "2026-05-06T12:00:00Z",
"download_url": (
"/api/v1/artifacts/art-runtime"
"?sessionKey=agent%3Amain%3Awebchat%3Aartifact-runtime"
),
}
)
return "published"
registry.register(
ToolSpec(name="make_file", description="Make a file", parameters={}),
make_file,
)
return registry
def _registry_with_write_file() -> ToolRegistry:
registry = _registry()
write_file = filesystem.write_file.__wrapped__.__wrapped__ # type: ignore[attr-defined]
registry.register(
ToolSpec(
name="write_file",
description="Write a file",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
),
write_file,
)
return registry
def _apply_patch_registry() -> ToolRegistry:
registry = ToolRegistry()
apply_patch = patch_tools.apply_patch.__wrapped__.__wrapped__ # type: ignore[attr-defined]
registry.register(
ToolSpec(
name="apply_patch",
description="Apply a patch",
parameters={
"type": "object",
"properties": {
"patch": {"type": "string"},
},
"required": ["patch"],
},
),
apply_patch,
)
return registry
def _edit_file_registry() -> ToolRegistry:
registry = ToolRegistry()
edit_file = filesystem.edit_file.__wrapped__.__wrapped__ # type: ignore[attr-defined]
registry.register(
ToolSpec(
name="edit_file",
description="Edit a file",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"},
},
"required": ["path", "old_text", "new_text"],
},
),
edit_file,
)
return registry
def _write_file_registry() -> ToolRegistry:
registry = ToolRegistry()
write_file = filesystem.write_file.__wrapped__.__wrapped__ # type: ignore[attr-defined]
registry.register(
ToolSpec(
name="write_file",
description="Write a file",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
),
write_file,
)
return registry
def _failed_publish_registry() -> ToolRegistry:
registry = ToolRegistry()
async def publish_artifact(path: str) -> str:
raise ToolError(f"artifact file not found: {path}")
registry.register(
ToolSpec(
name="publish_artifact",
description="Publish a generated artifact",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
),
publish_artifact,
)
return registry
def _publish_then_forbidden_tool_registry() -> tuple[ToolRegistry, list[str]]:
registry = ToolRegistry()
forbidden_calls: list[str] = []
async def publish_artifact(path: str) -> str:
ctx = current_tool_context.get()
assert ctx is not None
ctx.published_artifacts.append(
{
"id": "art-published",
"kind": "artifact_ref",
"name": path,
"mime": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"size": 8,
"sha256": "c" * 64,
"session_id": ctx.artifact_session_id,
"session_key": ctx.session_key,
"source": "publish_artifact",
"created_at": "2026-05-06T12:00:00Z",
"download_url": "/api/v1/artifacts/art-published",
}
)
return json.dumps({"status": "published", "artifact": {"name": path}})
async def qa_check(path: str) -> str:
forbidden_calls.append(path)
return "qa done"
registry.register(
ToolSpec(
name="publish_artifact",
description="Publish a generated artifact",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
),
publish_artifact,
)
registry.register(
ToolSpec(
name="qa_check",
description="QA check",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
),
qa_check,
)
return registry, forbidden_calls
@pytest.mark.asyncio
async def test_turn_runner_streams_artifact_event_and_persists_history(tmp_path) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-runtime"
session = await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_ArtifactProvider()),
tool_registry=_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path),
)
try:
events = [
event
async for event in runner.run(
"make it",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert len(artifact_events) == 1
assert artifact_events[0].id == "art-runtime"
assert artifact_events[0].session_id == session.session_id
assert artifact_events[0].session_key == ""
assert artifact_events[0].download_url == "/api/v1/artifacts/art-runtime"
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
payload = json.loads(assistant.content)
assert payload["text"] == "done"
assert payload["artifacts"][0]["id"] == "art-runtime"
assert payload["artifacts"][0]["session_id"] == session.session_id
assert "session_key" not in payload["artifacts"][0]
assert "sessionKey" not in assistant.content
class _HistoryCapture:
def __init__(self) -> None:
self.history = []
def set_history(self, history) -> None:
self.history = history
history_capture = _HistoryCapture()
await runner._load_history(agent=history_capture, session_key=session_key)
assert "[generated artifact omitted: runtime.txt (text/plain)]" in str(
history_capture.history[-1].content
)
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_cancel_after_artifact_persists_recoverable_delivery_text(
tmp_path,
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-cancelled"
session = await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_BlockingAfterArtifactProvider()),
tool_registry=_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path),
)
artifact_seen = asyncio.Event()
async def _consume() -> None:
async for event in runner.run(
"make it",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
):
if isinstance(event, ArtifactEvent):
artifact_seen.set()
task = asyncio.create_task(_consume())
try:
await asyncio.wait_for(artifact_seen.wait(), timeout=2.0)
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
payload = json.loads(assistant.content)
assert payload["artifacts"][0]["id"] == "art-runtime"
assert payload["artifacts"][0]["session_id"] == session.session_id
assert "The generated file was delivered" in payload["text"]
assert "[interrupted]" not in payload["text"]
finally:
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_suppresses_tools_after_successful_publish_artifact(
tmp_path,
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-stop"
session = await manager.create(session_key)
provider = _PostPublishToolLoopProvider()
registry, forbidden_calls = _publish_then_forbidden_tool_registry()
runner = TurnRunner(
provider_selector=_ProviderSelector(provider),
tool_registry=registry,
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path),
)
try:
events = [
event
async for event in runner.run(
"make ppt",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
done = next(event for event in events if isinstance(event, DoneEvent))
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
tool_starts = [event for event in events if isinstance(event, ToolUseStartEvent)]
assert provider.calls == 1
assert provider.tools_seen == [True]
assert forbidden_calls == []
assert [event.tool_name for event in tool_starts] == ["publish_artifact"]
assert artifact_events[0].id == "art-published"
assert artifact_events[0].session_id == session.session_id
assert "The generated file is ready" in done.text
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
payload = json.loads(assistant.content)
assert payload["artifacts"][0]["id"] == "art-published"
assert "The generated file is ready" in payload["text"]
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_auto_publishes_deliverable_file_when_model_omits_publish(
tmp_path,
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-omitted"
session = await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_OmittedPublishProvider()),
tool_registry=_write_file_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path / "workspace"),
allowed_tools={"write_file"},
elevated="full",
)
try:
events = [
event
async for event in runner.run(
"make an html page",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert len(artifact_events) == 1
assert artifact_events[0].name == "manual-big-write.html"
assert artifact_events[0].mime == "text/html"
assert artifact_events[0].session_id == session.session_id
assert artifact_events[0].download_url == (
f"/api/v1/artifacts/{artifact_events[0].id}"
)
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
payload = json.loads(assistant.content)
assert payload["text"] == "Created manual-big-write.html for you."
assert payload["artifacts"][0]["name"] == "manual-big-write.html"
assert payload["artifacts"][0]["source"] == "auto_publish_omitted"
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_does_not_auto_publish_edited_config_json(tmp_path) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-edit-config"
await manager.create(session_key)
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "config.json").write_text("{\"enabled\": false}\n", encoding="utf-8")
runner = TurnRunner(
provider_selector=_ProviderSelector(_EditedConfigProvider()),
tool_registry=_edit_file_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(workspace),
allowed_tools={"edit_file"},
elevated="full",
)
try:
events = [
event
async for event in runner.run(
"update config",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert artifact_events == []
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
assert assistant.content == "Updated config.json."
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_auto_publishes_deliverable_file_created_by_apply_patch(
tmp_path,
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-patch-omitted"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_OmittedPatchPublishProvider()),
tool_registry=_apply_patch_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path / "workspace"),
allowed_tools={"apply_patch"},
elevated="full",
)
try:
events = [
event
async for event in runner.run(
"make an html page with a patch",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert [event.name for event in artifact_events] == ["patched.html"]
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
payload = json.loads(assistant.content)
assert payload["artifacts"][0]["name"] == "patched.html"
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_marks_partial_omitted_artifact_delivery_failure(
tmp_path,
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-partial-failure"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_MixedSizeOmittedPublishProvider()),
tool_registry=_write_file_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(
media_root=str(tmp_path / "media"),
artifact_max_bytes=40,
),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path / "workspace"),
allowed_tools={"write_file"},
elevated="full",
)
try:
events = [
event
async for event in runner.run(
"make two html pages",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert [event.name for event in artifact_events] == ["small.html"]
text_deltas = [event.text for event in events if isinstance(event, TextDeltaEvent)]
done = next(event for event in events if isinstance(event, DoneEvent))
assert any("File delivery failed:" in text for text in text_deltas)
assert "File delivery failed:" in done.text
assert "some generated files were attached" in done.text
assert "no downloadable file was attached" not in done.text
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
payload = json.loads(assistant.content)
assert payload["artifacts"][0]["name"] == "small.html"
assert "File delivery failed:" in payload["text"]
assert "some generated files were attached" in payload["text"]
assert "no downloadable file was attached" not in payload["text"]
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_auto_publishes_same_content_deliverables_by_name(
tmp_path,
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-same-content"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_SameContentOmittedPublishProvider()),
tool_registry=_write_file_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path / "workspace"),
allowed_tools={"write_file"},
elevated="full",
)
try:
events = [
event
async for event in runner.run(
"make matching html files",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert [event.name for event in artifact_events] == ["first.html", "second.html"]
assert artifact_events[0].sha256 == artifact_events[1].sha256
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
payload = json.loads(assistant.content)
assert [artifact["name"] for artifact in payload["artifacts"]] == [
"first.html",
"second.html",
]
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_auto_publishes_omitted_deliverable_after_existing_artifact(
tmp_path,
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-partial-omitted"
session = await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_PartialOmittedPublishProvider()),
tool_registry=_registry_with_write_file(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path / "workspace"),
allowed_tools={"make_file", "write_file"},
elevated="full",
)
try:
events = [
event
async for event in runner.run(
"make two files",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert [event.name for event in artifact_events] == ["runtime.txt", "second.html"]
assert artifact_events[0].id == "art-runtime"
assert artifact_events[1].session_id == session.session_id
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
payload = json.loads(assistant.content)
assert [artifact["name"] for artifact in payload["artifacts"]] == [
"runtime.txt",
"second.html",
]
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_does_not_auto_publish_memory_json_write(tmp_path) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-memory-json"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_MemoryJsonWriteProvider()),
tool_registry=_write_file_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path / "workspace"),
allowed_tools={"write_file"},
elevated="full",
)
try:
events = [
event
async for event in runner.run(
"update internal memory",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert artifact_events == []
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
assert assistant.content == "Updated memory/cache.json."
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_marks_failed_omitted_artifact_delivery_in_final_text(
tmp_path,
) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-omitted-too-large"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_OmittedPublishProvider()),
tool_registry=_write_file_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(
media_root=str(tmp_path / "media"),
artifact_max_bytes=1,
),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path / "workspace"),
allowed_tools={"write_file"},
elevated="full",
)
try:
events = [
event
async for event in runner.run(
"make an html page",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert artifact_events == []
text_deltas = [event.text for event in events if isinstance(event, TextDeltaEvent)]
done = next(event for event in events if isinstance(event, DoneEvent))
assert any("File delivery failed:" in text for text in text_deltas)
assert "File delivery failed:" in done.text
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
assert "File delivery failed:" in assistant.content
assert "artifacts" not in assistant.content
finally:
await storage.close()
@pytest.mark.asyncio
async def test_turn_runner_marks_failed_artifact_delivery_in_final_text(tmp_path) -> None:
storage = SessionStorage(":memory:")
await storage.connect()
manager = SessionManager(storage)
session_key = "agent:main:webchat:artifact-failed"
await manager.create(session_key)
runner = TurnRunner(
provider_selector=_ProviderSelector(_FailedPublishProvider()),
tool_registry=_failed_publish_registry(),
session_manager=manager,
config=GatewayConfig(
attachments=AttachmentsConfig(media_root=str(tmp_path / "media")),
squilla_router=SquillaRouterConfig(enabled=False),
),
)
tool_context = ToolContext(
is_owner=True,
caller_kind=CallerKind.WEB,
workspace_dir=str(tmp_path),
)
try:
events = [
event
async for event in runner.run(
"make report",
session_key,
tool_context=tool_context,
history_has_persisted_user=False,
no_memory_capture=True,
)
]
text_deltas = [event.text for event in events if isinstance(event, TextDeltaEvent)]
done = next(event for event in events if isinstance(event, DoneEvent))
assert any("File delivery failed:" in text for text in text_deltas)
assert "File delivery failed:" in done.text
assert "Ask me to resend the file after I correct the generated file path." in done.text
assert "publish_artifact" not in done.text
assert "active workspace" not in done.text
assert "missing-report.pptx" not in done.text
transcript = await manager.get_transcript(session_key)
assistant = [entry for entry in transcript if entry.role == "assistant"][-1]
assert "Report file is ready for download." in assistant.content
assert "File delivery failed:" in assistant.content
assert "artifacts" not in assistant.content
finally:
await storage.close()