992 lines
33 KiB
Python
992 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from opensquilla.artifacts import (
|
|
DEFAULT_ARTIFACT_DISK_BUDGET_BYTES,
|
|
DEFAULT_ARTIFACT_MAX_BYTES,
|
|
INSTALLER_ARTIFACT_MAX_BYTES,
|
|
ArtifactBudgetError,
|
|
ArtifactIntegrityError,
|
|
ArtifactNotFoundError,
|
|
ArtifactStore,
|
|
artifact_marker,
|
|
artifact_payload,
|
|
strip_artifact_markers_from_text,
|
|
)
|
|
from opensquilla.tools.builtin.artifacts import publish_artifact
|
|
from opensquilla.tools.types import CallerKind, ToolContext, ToolError, current_tool_context
|
|
|
|
|
|
def test_artifact_store_round_trips_metadata_and_bytes(tmp_path: Path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
|
|
ref = store.publish_bytes(
|
|
b"hello\n",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="report.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
)
|
|
path = store.path_for(ref)
|
|
|
|
assert ref.kind == "artifact_ref"
|
|
assert ref.name == "report.txt"
|
|
assert ref.size == 6
|
|
assert ref.download_url == "/api/v1/artifacts/" + ref.id
|
|
assert path.read_bytes() == b"hello\n"
|
|
|
|
resolved_ref, resolved_path = store.resolve_for_download(ref.id, session_id="session-1")
|
|
assert resolved_ref == ref
|
|
assert resolved_path == path
|
|
|
|
|
|
def test_artifact_store_finds_existing_session_deliverable_by_name_and_sha(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"pptx bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="brief.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
source="create_pptx",
|
|
)
|
|
|
|
found = store.find_existing_ref(
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
sha256=ref.sha256,
|
|
name="brief.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
)
|
|
|
|
assert found == ref
|
|
assert (
|
|
store.find_existing_ref(
|
|
session_id="session-2",
|
|
session_key="agent:main:webchat:session-2",
|
|
sha256=ref.sha256,
|
|
name="brief.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_artifact_store_skips_existing_deliverable_with_bad_material(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"pptx bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="brief.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
source="create_pptx",
|
|
)
|
|
store.path_for(ref).write_bytes(b"corrupt")
|
|
|
|
assert (
|
|
store.find_existing_ref(
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
sha256=ref.sha256,
|
|
name="brief.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_artifact_store_uses_short_material_paths_for_uuid_sessions(tmp_path: Path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
long_root = tmp_path / ("deep-root-" + ("x" * 80))
|
|
store = ArtifactStore(long_root)
|
|
session_id = "532d5065-abce-499f-97b0-bbf2a067d5ab"
|
|
|
|
ref = store.publish_bytes(
|
|
b"pptx",
|
|
session_id=session_id,
|
|
session_key="agent:main:webchat:default",
|
|
name="北京2027房价预测分析报告.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
material_path = store.path_for(ref)
|
|
assert material_path.name == "data"
|
|
assert session_id not in str(material_path)
|
|
assert len(str(material_path)) < 260
|
|
resolved_ref, resolved_path = store.resolve_for_download(ref.id, session_id=session_id)
|
|
assert resolved_ref == ref
|
|
assert resolved_path == material_path
|
|
|
|
|
|
def test_artifact_store_resolves_legacy_short_material_paths(tmp_path: Path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
session_id = "532d5065-abce-499f-97b0-bbf2a067d5ab"
|
|
|
|
ref = store.publish_bytes(
|
|
b"pptx",
|
|
session_id=session_id,
|
|
session_key="agent:main:webchat:default",
|
|
name="brief.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
current_dir = store.path_for(ref).parent
|
|
legacy_session_token = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16]
|
|
legacy_artifact_token = hashlib.sha256(ref.id.encode("utf-8")).hexdigest()[:16]
|
|
legacy_dir = tmp_path / "artifacts" / "s" / legacy_session_token / legacy_artifact_token
|
|
legacy_dir.parent.mkdir(parents=True)
|
|
current_dir.rename(legacy_dir)
|
|
|
|
resolved_ref, resolved_path = store.resolve_for_download(ref.id, session_id=session_id)
|
|
|
|
assert resolved_ref == ref
|
|
assert resolved_path == legacy_dir / "data"
|
|
|
|
|
|
def test_artifact_store_resolves_legacy_short_thumbnail_paths(tmp_path: Path) -> None:
|
|
from PIL import Image
|
|
|
|
store = ArtifactStore(tmp_path)
|
|
session_id = "532d5065-abce-499f-97b0-bbf2a067d5ab"
|
|
out = io.BytesIO()
|
|
Image.new("RGB", (8, 8), color="red").save(out, format="PNG")
|
|
|
|
ref = store.publish_bytes(
|
|
out.getvalue(),
|
|
session_id=session_id,
|
|
session_key="agent:main:webchat:default",
|
|
name="chart.png",
|
|
mime="image/png",
|
|
source="publish_artifact",
|
|
)
|
|
assert ref.has_thumbnail is True
|
|
|
|
current_dir = store.path_for(ref).parent
|
|
legacy_session_token = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16]
|
|
legacy_artifact_token = hashlib.sha256(ref.id.encode("utf-8")).hexdigest()[:16]
|
|
legacy_dir = tmp_path / "artifacts" / "s" / legacy_session_token / legacy_artifact_token
|
|
legacy_dir.parent.mkdir(parents=True)
|
|
current_dir.rename(legacy_dir)
|
|
|
|
thumbnail = store.resolve_thumbnail_for_download(ref.id, session_id=session_id)
|
|
|
|
assert thumbnail is not None
|
|
resolved_ref, thumbnail_path = thumbnail
|
|
assert resolved_ref == ref
|
|
assert thumbnail_path == legacy_dir / "thumb.webp"
|
|
|
|
|
|
def test_artifact_payload_omits_session_key_and_query_token(tmp_path: Path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"hello\n",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="report.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
payload = artifact_payload(ref)
|
|
|
|
assert "session_key" not in payload
|
|
assert "sessionKey" not in json.dumps(payload)
|
|
assert payload["download_url"] == f"/api/v1/artifacts/{ref.id}"
|
|
|
|
|
|
def test_artifact_payload_keeps_thumbnail_url_across_persist_and_replay() -> None:
|
|
# Live event carries the internal has_thumbnail boolean; the public payload
|
|
# exposes only the reconstructed thumbnail_url string.
|
|
live = artifact_payload(
|
|
SimpleNamespace(
|
|
id="art-bmYMIceM2Ddx3rkFM4BOmZ7A",
|
|
kind="artifact_ref",
|
|
sha256="a" * 64,
|
|
name="chart.png",
|
|
mime="image/png",
|
|
size=954199,
|
|
session_id="session-1",
|
|
source="publish_artifact",
|
|
created_at="2026-06-13T00:00:00Z",
|
|
store="artifacts",
|
|
download_url="/api/v1/artifacts/art-bmYMIceM2Ddx3rkFM4BOmZ7A",
|
|
has_thumbnail=True,
|
|
)
|
|
)
|
|
assert "has_thumbnail" not in live
|
|
assert live["thumbnail_url"] == "/api/v1/artifacts/art-bmYMIceM2Ddx3rkFM4BOmZ7A?variant=thumb"
|
|
|
|
# Replaying the persisted public payload (which no longer carries the boolean)
|
|
# must rebuild the same thumbnail_url instead of falling back to the full file.
|
|
persisted = json.loads(json.dumps(live))
|
|
replayed = artifact_payload(persisted)
|
|
assert replayed["thumbnail_url"] == live["thumbnail_url"]
|
|
|
|
|
|
def test_artifact_payload_omits_thumbnail_url_without_thumbnail() -> None:
|
|
no_thumb = artifact_payload(
|
|
SimpleNamespace(
|
|
id="art-NoThumbXXXXXXXXXXXXXXXXX",
|
|
kind="artifact_ref",
|
|
sha256="b" * 64,
|
|
name="doc.pdf",
|
|
mime="application/pdf",
|
|
size=1000,
|
|
session_id="session-1",
|
|
source="publish_artifact",
|
|
created_at="2026-06-13T00:00:00Z",
|
|
store="artifacts",
|
|
download_url="/api/v1/artifacts/art-NoThumbXXXXXXXXXXXXXXXXX",
|
|
has_thumbnail=False,
|
|
)
|
|
)
|
|
assert "thumbnail_url" not in no_thumb
|
|
replayed = artifact_payload(json.loads(json.dumps(no_thumb)))
|
|
assert "thumbnail_url" not in replayed
|
|
|
|
|
|
def test_artifact_store_preserves_unicode_filename_and_normalizes_mime_params(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
|
|
ref = store.publish_bytes(
|
|
b"hello\n",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="记忆修补师.txt",
|
|
mime="text/plain; charset=utf-8",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
assert ref.name == "记忆修补师.txt"
|
|
assert ref.mime == "text/plain"
|
|
|
|
|
|
def test_artifact_store_rejects_hash_mismatch(tmp_path: Path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"hello",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="report.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
store.path_for(ref).write_bytes(b"tampered")
|
|
|
|
with pytest.raises(ArtifactIntegrityError):
|
|
store.resolve_for_download(ref.id, session_id="session-1")
|
|
|
|
|
|
def test_artifact_store_enforces_per_file_and_disk_budgets(tmp_path: Path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
|
|
with pytest.raises(ArtifactBudgetError):
|
|
store.publish_bytes(
|
|
b"abcdef",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="too-big.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
max_bytes=5,
|
|
)
|
|
|
|
assert not list((tmp_path / "artifacts").rglob("too-big.txt"))
|
|
|
|
store.publish_bytes(
|
|
b"abc",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="ok.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
disk_budget_bytes=6,
|
|
)
|
|
with pytest.raises(ArtifactBudgetError):
|
|
store.publish_bytes(
|
|
b"defg",
|
|
session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
name="over-budget.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
disk_budget_bytes=6,
|
|
)
|
|
|
|
|
|
def test_artifact_budget_defaults_are_open_source_sized() -> None:
|
|
assert DEFAULT_ARTIFACT_MAX_BYTES == 30 * 1024 * 1024
|
|
assert DEFAULT_ARTIFACT_DISK_BUDGET_BYTES == 512 * 1024 * 1024
|
|
assert INSTALLER_ARTIFACT_MAX_BYTES == DEFAULT_ARTIFACT_DISK_BUDGET_BYTES
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_allows_workspace_file_only(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "report.txt"
|
|
output.write_text("ready", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.WEB,
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await publish_artifact(path="report.txt", name="final.txt", mime="text/plain")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["status"] == "published"
|
|
assert payload["artifact"]["name"] == "final.txt"
|
|
assert payload["artifact"]["mime"] == "text/plain"
|
|
assert payload["artifact"]["session_id"] == "session-1"
|
|
assert "session_key" not in payload["artifact"]
|
|
assert "sessionKey" not in json.dumps(payload["artifact"])
|
|
# The LLM-facing artifact has no URL — models tend to fabricate a host
|
|
# when shown a relative URL ending in /api/v1/artifacts/...
|
|
assert "download_url" not in payload["artifact"]
|
|
assert payload["artifact"]["workspace_path"] == "report.txt"
|
|
assert payload["artifact"]["local_path"] == str(output.resolve())
|
|
assert "note" in payload
|
|
assert "local_path" in payload["note"]
|
|
assert "final response" in payload["note"]
|
|
assert "Do not run more tools" in payload["note"]
|
|
# The frontend event path still gets the full payload (with download_url).
|
|
assert len(ctx.published_artifacts) == 1
|
|
full_artifact = ctx.published_artifacts[0]
|
|
assert full_artifact["download_url"] == f"/api/v1/artifacts/{full_artifact['id']}"
|
|
llm_artifact = {
|
|
k: v
|
|
for k, v in payload["artifact"].items()
|
|
if k not in {"workspace_path", "local_path"}
|
|
}
|
|
assert {k: v for k, v in full_artifact.items() if k != "download_url"} == llm_artifact
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_allows_large_installer_artifact(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "OpenSquilla-0.4.0-arm64.dmg"
|
|
with output.open("wb") as handle:
|
|
handle.seek(DEFAULT_ARTIFACT_MAX_BYTES + 1)
|
|
handle.write(b"x")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.WEB,
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await publish_artifact(path=output.name)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["status"] == "published"
|
|
assert payload["artifact"]["name"] == output.name
|
|
assert payload["artifact"]["size"] > DEFAULT_ARTIFACT_MAX_BYTES
|
|
assert payload["artifact"]["mime"] == "application/x-apple-diskimage"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_preserves_source_extension_for_display_name(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "generated-chart.png"
|
|
output.write_bytes(b"\x89PNG\r\n\x1a\nimage bytes")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.WEB,
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await publish_artifact(
|
|
path="generated-chart.png",
|
|
name="Friendly Chart",
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result)
|
|
|
|
assert payload["status"] == "published"
|
|
assert payload["artifact"]["name"] == "Friendly Chart.png"
|
|
assert payload["artifact"]["mime"] == "image/png"
|
|
assert ctx.published_artifacts[0]["name"] == "Friendly Chart.png"
|
|
assert ctx.published_artifacts[0]["mime"] == "image/png"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_keeps_download_name_mime_when_source_is_generic(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "payload.bin"
|
|
output.write_bytes(b"image bytes")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.WEB,
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await publish_artifact(
|
|
path="payload.bin",
|
|
name="Friendly Chart.png",
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result)
|
|
|
|
assert payload["artifact"]["name"] == "Friendly Chart.png"
|
|
assert payload["artifact"]["mime"] == "image/png"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_hides_local_path_from_non_owner_channel(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "report.txt"
|
|
output.write_text("ready", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
channel_kind="feishu",
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:feishu:direct:u1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await publish_artifact(path="report.txt", name="final.txt", mime="text/plain")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["status"] == "published"
|
|
assert "download_url" not in payload["artifact"]
|
|
assert "local_path" not in payload["artifact"]
|
|
assert "workspace_path" not in payload["artifact"]
|
|
assert "local_path" not in payload["note"]
|
|
assert "final response" in payload["note"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_accepts_workspace_alias(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "paper.pdf"
|
|
output.write_bytes(b"%PDF-1.5\nready")
|
|
ctx = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await publish_artifact(
|
|
path="/workspace/paper.pdf",
|
|
name="paper.pdf",
|
|
mime="application/pdf",
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["status"] == "published"
|
|
assert payload["artifact"]["name"] == "paper.pdf"
|
|
assert len(ctx.published_artifacts) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_is_idempotent_for_existing_turn_artifact(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "generated-image.png"
|
|
output.write_bytes(b"\x89PNG\r\n\x1a\nsame image")
|
|
ctx = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:feishu:direct:u1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
first = json.loads(
|
|
await publish_artifact(
|
|
path="generated-image.png",
|
|
name="generated-image.png",
|
|
mime="image/png",
|
|
)
|
|
)
|
|
second = json.loads(
|
|
await publish_artifact(
|
|
path="generated-image.png",
|
|
name="OpenSquilla-Mascot.png",
|
|
mime="image/png",
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert first["status"] == "published"
|
|
assert second["status"] == "already_published"
|
|
assert second["artifact"]["id"] == first["artifact"]["id"]
|
|
assert second["artifact"]["name"] == "generated-image.png"
|
|
assert "already registered" in second["note"]
|
|
assert len(ctx.published_artifacts) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_reuses_existing_session_deliverable_across_contexts(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "brief.pptx"
|
|
output.write_bytes(b"pptx bytes")
|
|
media_root = tmp_path / "media"
|
|
|
|
ctx1 = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(media_root),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
token = current_tool_context.set(ctx1)
|
|
try:
|
|
first = json.loads(
|
|
await publish_artifact(
|
|
path="brief.pptx",
|
|
name="brief.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
ctx2 = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(media_root),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
token = current_tool_context.set(ctx2)
|
|
try:
|
|
second = json.loads(
|
|
await publish_artifact(
|
|
path="brief.pptx",
|
|
name="brief.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert first["status"] == "published"
|
|
assert second["status"] == "already_published"
|
|
assert second["artifact"]["id"] == first["artifact"]["id"]
|
|
assert len(ctx1.published_artifacts) == 1
|
|
assert len(ctx2.published_artifacts) == 1
|
|
assert ctx2.published_artifacts[0]["id"] == first["artifact"]["id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_republishes_changed_bytes_at_same_path(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "report.txt"
|
|
output.write_text("first", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
first = json.loads(await publish_artifact(path="report.txt", mime="text/plain"))
|
|
output.write_text("second", encoding="utf-8")
|
|
second = json.loads(await publish_artifact(path="report.txt", mime="text/plain"))
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert first["status"] == "published"
|
|
assert second["status"] == "published"
|
|
assert second["artifact"]["id"] != first["artifact"]["id"]
|
|
assert len(ctx.published_artifacts) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_reports_storage_write_failure(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
output = workspace / "report.txt"
|
|
output.write_text("ready", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
def fail_publish_file(*args: object, **kwargs: object) -> None:
|
|
raise FileNotFoundError("media temp path unavailable")
|
|
|
|
monkeypatch.setattr(ArtifactStore, "publish_file", fail_publish_file)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
with pytest.raises(ToolError, match="artifact storage path is unavailable"):
|
|
await publish_artifact(path="report.txt", name="final.txt", mime="text/plain")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_missing_file_reports_workspace_candidates(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
reports = workspace / "reports"
|
|
reports.mkdir(parents=True)
|
|
candidate = reports / "AI Agent Comparison 2026.pptx"
|
|
candidate.write_bytes(b"pptx")
|
|
ctx = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
with pytest.raises(ToolError) as exc_info:
|
|
await publish_artifact(path="AI_Agent_Comparison_2026.pptx")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
message = str(exc_info.value)
|
|
assert "artifact file not found" in message
|
|
assert f"active workspace: {workspace.resolve()}" in message
|
|
assert "resolved path:" in message
|
|
assert "candidate files:" in message
|
|
assert "reports/AI Agent Comparison 2026.pptx" in message.replace("\\", "/")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_rejects_foreign_posix_target_with_workspace_hint(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import opensquilla.tools.builtin.artifacts as artifacts_module
|
|
|
|
monkeypatch.setattr(artifacts_module, "os", SimpleNamespace(name="nt"), raising=False)
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
actual = workspace / "report.pptx"
|
|
actual.write_bytes(b"pptx")
|
|
ctx = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
with pytest.raises(ToolError) as exc_info:
|
|
await publish_artifact(path="/Users/a1/Desktop/report.pptx")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
message = str(exc_info.value)
|
|
assert "foreign_host_path" in message
|
|
assert "requested path is from another host/platform" in message
|
|
assert "report.pptx" in message
|
|
assert "D:\\Users" not in message
|
|
assert not ctx.published_artifacts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_artifact_tool_rejects_missing_workspace_and_escape(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_text("no", encoding="utf-8")
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
)
|
|
try:
|
|
with pytest.raises(ToolError):
|
|
await publish_artifact(path=str(outside))
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
workspace_dir=str(workspace),
|
|
artifact_media_root=str(tmp_path / "media"),
|
|
artifact_session_id="session-1",
|
|
session_key="agent:main:webchat:session-1",
|
|
)
|
|
)
|
|
try:
|
|
with pytest.raises(ToolError):
|
|
await publish_artifact(path="../outside.txt")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
|
|
def test_copy_session_artifacts_rebinds_to_child_and_preserves_isolation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"deliverable bytes",
|
|
session_id="parent-1",
|
|
session_key="agent:main:webchat:parent-1",
|
|
name="report.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
# Before the copy the child cannot see the parent's artifact.
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
store.resolve_for_download(ref.id, session_id="child-1")
|
|
|
|
copied = store.copy_session_artifacts(
|
|
source_session_id="parent-1",
|
|
target_session_id="child-1",
|
|
target_session_key="agent:main:webchat:child-1",
|
|
)
|
|
assert copied == 1
|
|
|
|
child_ref, child_path = store.resolve_for_download(ref.id, session_id="child-1")
|
|
assert child_path.read_bytes() == b"deliverable bytes"
|
|
assert child_ref.id == ref.id # stable id keeps the transcript/URL linkage valid
|
|
assert child_ref.session_id == "child-1"
|
|
assert child_ref.session_key == "agent:main:webchat:child-1"
|
|
assert child_ref.sha256 == ref.sha256
|
|
|
|
# The parent still owns its copy and an unrelated session stays blocked.
|
|
parent_ref, _ = store.resolve_for_download(ref.id, session_id="parent-1")
|
|
assert parent_ref.session_id == "parent-1"
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
store.resolve_for_download(ref.id, session_id="stranger")
|
|
|
|
# Re-copying is idempotent: nothing new is materialized.
|
|
assert (
|
|
store.copy_session_artifacts(
|
|
source_session_id="parent-1",
|
|
target_session_id="child-1",
|
|
target_session_key="agent:main:webchat:child-1",
|
|
)
|
|
== 0
|
|
)
|
|
|
|
|
|
def test_copy_session_artifacts_carries_thumbnail(tmp_path: Path) -> None:
|
|
from PIL import Image
|
|
|
|
store = ArtifactStore(tmp_path)
|
|
out = io.BytesIO()
|
|
Image.new("RGB", (8, 8), color="red").save(out, format="PNG")
|
|
ref = store.publish_bytes(
|
|
out.getvalue(),
|
|
session_id="parent-1",
|
|
session_key="agent:main:webchat:parent-1",
|
|
name="chart.png",
|
|
mime="image/png",
|
|
source="publish_artifact",
|
|
)
|
|
assert ref.has_thumbnail is True
|
|
|
|
store.copy_session_artifacts(
|
|
source_session_id="parent-1",
|
|
target_session_id="child-1",
|
|
target_session_key="agent:main:webchat:child-1",
|
|
)
|
|
|
|
thumbnail = store.resolve_thumbnail_for_download(ref.id, session_id="child-1")
|
|
assert thumbnail is not None
|
|
_, thumb_path = thumbnail
|
|
assert thumb_path.exists()
|
|
|
|
|
|
def test_copy_session_artifacts_reads_legacy_short_layout(tmp_path: Path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
session_id = "532d5065-abce-499f-97b0-bbf2a067d5ab"
|
|
ref = store.publish_bytes(
|
|
b"legacy material",
|
|
session_id=session_id,
|
|
session_key="agent:main:webchat:legacy",
|
|
name="old.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
# Relocate the artifact into the 16-char legacy session/artifact layout.
|
|
current_dir = store.path_for(ref).parent
|
|
legacy_session_token = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16]
|
|
legacy_artifact_token = hashlib.sha256(ref.id.encode("utf-8")).hexdigest()[:16]
|
|
legacy_dir = tmp_path / "artifacts" / "s" / legacy_session_token / legacy_artifact_token
|
|
legacy_dir.parent.mkdir(parents=True)
|
|
current_dir.rename(legacy_dir)
|
|
|
|
copied = store.copy_session_artifacts(
|
|
source_session_id=session_id,
|
|
target_session_id="child-1",
|
|
target_session_key="agent:main:webchat:child-1",
|
|
)
|
|
assert copied == 1
|
|
_, child_path = store.resolve_for_download(ref.id, session_id="child-1")
|
|
assert child_path.read_bytes() == b"legacy material"
|
|
|
|
|
|
def test_copy_session_artifacts_reads_legacy_plain_layout(tmp_path: Path) -> None:
|
|
from opensquilla.artifacts import _safe_token
|
|
|
|
store = ArtifactStore(tmp_path)
|
|
session_id = "plain-session"
|
|
ref = store.publish_bytes(
|
|
b"plain layout material",
|
|
session_id=session_id,
|
|
session_key="agent:main:webchat:plain",
|
|
name="legacy.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
# Relocate into the oldest "plain" layout where the material file is named by the
|
|
# sha (not "data"): artifacts/<safe_token(session)>/<artifact-id>/<sha256>.
|
|
current_dir = store.path_for(ref).parent
|
|
plain_dir = tmp_path / "artifacts" / _safe_token(session_id) / ref.id
|
|
plain_dir.mkdir(parents=True)
|
|
(current_dir / "data").rename(plain_dir / ref.sha256)
|
|
(current_dir / "meta.json").rename(plain_dir / "meta.json")
|
|
|
|
copied = store.copy_session_artifacts(
|
|
source_session_id=session_id,
|
|
target_session_id="child-1",
|
|
target_session_key="agent:main:webchat:child-1",
|
|
)
|
|
assert copied == 1
|
|
_, child_path = store.resolve_for_download(ref.id, session_id="child-1")
|
|
assert child_path.read_bytes() == b"plain layout material"
|
|
|
|
|
|
def test_copy_session_artifacts_skips_artifact_with_missing_material(tmp_path: Path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
good = store.publish_bytes(
|
|
b"good bytes",
|
|
session_id="parent-1",
|
|
session_key="agent:main:webchat:parent-1",
|
|
name="good.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
)
|
|
bad = store.publish_bytes(
|
|
b"vanishing bytes",
|
|
session_id="parent-1",
|
|
session_key="agent:main:webchat:parent-1",
|
|
name="bad.txt",
|
|
mime="text/plain",
|
|
source="publish_artifact",
|
|
)
|
|
# Drop the bad artifact's material, leaving its meta.json behind.
|
|
store.path_for(bad).unlink()
|
|
|
|
copied = store.copy_session_artifacts(
|
|
source_session_id="parent-1",
|
|
target_session_id="child-1",
|
|
target_session_key="agent:main:webchat:child-1",
|
|
)
|
|
assert copied == 1 # only the artifact with intact material is carried
|
|
_, child_path = store.resolve_for_download(good.id, session_id="child-1")
|
|
assert child_path.read_bytes() == b"good bytes"
|
|
with pytest.raises(ArtifactNotFoundError):
|
|
store.resolve_for_download(bad.id, session_id="child-1")
|
|
|
|
|
|
def test_strip_artifact_markers_preserves_surrounding_whitespace() -> None:
|
|
marker = "[generated artifact omitted: report.html (text/html)]"
|
|
|
|
assert strip_artifact_markers_from_text(f"line1\n{marker}\nline2") in (
|
|
"line1\nline2",
|
|
"line1\n\nline2",
|
|
)
|
|
assert (
|
|
strip_artifact_markers_from_text(f"Here is the summary. {marker} Let me know.")
|
|
== "Here is the summary. Let me know."
|
|
)
|
|
|
|
|
|
def test_strip_artifact_markers_handles_bracket_in_name() -> None:
|
|
marker = artifact_marker({"name": "weird].html", "mime": "text/html"})
|
|
assert marker == "[generated artifact omitted: weird].html (text/html)]"
|
|
|
|
cleaned = strip_artifact_markers_from_text(f"Done!\n{marker}\nAnything else?")
|
|
assert "]" not in cleaned
|
|
assert ".html" not in cleaned
|