feat(sandbox): introduce sandbox operation timeout configuration and refactor execution handling

- Added `SANDBOX_OPERATION_TIMEOUT_SECONDS` to configuration for controlling sandbox operation timeouts.
- Updated various components to utilize the new timeout setting, ensuring consistent timeout behavior across different sandbox providers.
- Refactored execution logic in the sandbox tools to improve error handling and cleanup processes.
- Enhanced tests to validate the new timeout functionality and ensure proper execution flow.
This commit is contained in:
Anish Sarkar 2026-08-18 04:32:26 +05:30
parent deb67fbad7
commit 8ac898eccb
14 changed files with 417 additions and 49 deletions

View file

@ -368,6 +368,8 @@ STT_SERVICE=local/base
# only so many at once (over the cap the tool returns a retry-shortly error).
# SANDBOX_IDLE_TTL_SECONDS=900
# SANDBOX_MAX_SESSIONS_PER_WORKSPACE=2
# Maximum wall-clock budget for one sandbox operation.
# SANDBOX_OPERATION_TIMEOUT_SECONDS=200
# Largest file the agent may pull out of a sandbox into an artifact (30 MiB).
# ARTIFACT_MAX_FILE_BYTES=31457280
@ -391,8 +393,6 @@ STT_SERVICE=local/base
# OPENSANDBOX_SERVER_MEMORY_LIMIT=512m
# Tracks SURFSENSE_VERSION by default; set only to run your own sandbox image.
# SANDBOX_IMAGE=
# Creation waits on the image pull when the compose pull did not warm the cache.
# SANDBOX_REQUEST_TIMEOUT_SECONDS=120
# ------------------------------------------------------------------------------
# External API Keys (optional)

View file

@ -546,15 +546,15 @@ EMBEDDING_CACHE_ENABLED=false
# a reachable server (localhost:8080 for the compose one) or set this FALSE.
# SANDBOX_ENABLED=TRUE
# SANDBOX_PROVIDER=opensandbox
# SANDBOX_IDLE_TTL_SECONDS=900
# SANDBOX_MAX_SESSIONS_PER_WORKSPACE=2
# SANDBOX_IDLE_TTL_SECONDS=900
# SANDBOX_OPERATION_TIMEOUT_SECONDS=200
# ARTIFACT_MAX_FILE_BYTES=31457280
# OpenSandbox (self-hosted). Host-run backend against compose: localhost:8080.
# OPENSANDBOX_DOMAIN=opensandbox-server:8080
# OPENSANDBOX_API_KEY=surfsense-dev-sandbox
# SANDBOX_IMAGE=ghcr.io/modsetter/surfsense-sandbox:latest
# SANDBOX_REQUEST_TIMEOUT_SECONDS=120
# Daytona (cloud)
# DAYTONA_API_KEY=your-daytona-api-key

View file

@ -16,6 +16,7 @@ from langchain.tools import ToolRuntime
from app.agents.chat.multi_agent_chat.shared.state.filesystem_state import (
SurfSenseFilesystemState,
)
from app.config import config as app_config
from app.sandbox import ExecResult, SandboxUnavailableError, get_registry
if TYPE_CHECKING:
@ -23,7 +24,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
MAX_EXECUTE_TIMEOUT = 300
MAX_EXECUTE_TIMEOUT = app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
async def execute_in_sandbox(
@ -34,8 +35,9 @@ async def execute_in_sandbox(
) -> str:
"""Top-level entry: run *command* as Python, retrying once."""
assert mw._thread_id is not None
effective_timeout = timeout or MAX_EXECUTE_TIMEOUT
try:
return _format(await _run(mw, command, timeout))
return _format(await _run(mw, command, effective_timeout))
except SandboxUnavailableError as err:
return f"Error: {err}"
except TimeoutError:
@ -43,7 +45,7 @@ async def execute_in_sandbox(
# the kernel until the sandbox expires. Upgrade path is an interrupt
# call on the execution id once the SDK exposes one for kernel runs.
return (
f"Error: execution exceeded {timeout or MAX_EXECUTE_TIMEOUT}s and was "
f"Error: execution exceeded {effective_timeout}s and was "
"abandoned. The interpreter may still be busy; simplify the code."
)
except Exception as first_err:
@ -57,14 +59,14 @@ async def execute_in_sandbox(
# may have a wedged kernel, and reconnecting would inherit it.
registry = await get_registry()
await registry.terminate(mw._thread_id)
return _format(await _run(mw, command, timeout))
return _format(await _run(mw, command, effective_timeout))
except Exception:
logger.exception("Sandbox retry also failed for thread %s", mw._thread_id)
return "Error: Code execution is temporarily unavailable. Please try again."
async def _run(
mw: SurfSenseFilesystemMiddleware, code: str, timeout: int | None
mw: SurfSenseFilesystemMiddleware, code: str, timeout: int
) -> ExecResult:
registry = await get_registry()
# Without a workspace every such thread would share one cap bucket and
@ -73,7 +75,7 @@ async def _run(
session = await registry.get_session(mw._thread_id, workspace_id)
return await asyncio.wait_for(
session.execute(code, language="python"),
timeout=timeout or MAX_EXECUTE_TIMEOUT,
timeout=timeout,
)

View file

@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import logging
import shlex
import uuid
from typing import Literal
@ -11,11 +13,15 @@ from langchain_core.tools import BaseTool, tool
from app.capabilities.core import ActivityDescriptor
from app.config import config as app_config
from app.sandbox import SandboxSession, get_registry
from app.sandbox import ExecResult, SandboxSession, get_registry
from .thread_resolver import resolve_root_thread_id
_MAX_CONTEXT_CHARS = 16_000
_PROCESS_TERMINATION_GRACE_SECONDS = 5
_TRANSPORT_GRACE_SECONDS = 5
logger = logging.getLogger(__name__)
async def _get_session(workspace_id: int, runtime: ToolRuntime) -> SandboxSession:
@ -30,6 +36,57 @@ def _result_text(output: str, exit_code: int, *, full_output_path: str | None) -
return output + suffix
async def _run_python_script(
session: SandboxSession,
code: str,
) -> ExecResult:
"""Materialize agent-authored code and run it as one bounded process."""
script_path = f"/tmp/.surfsense-exec-{uuid.uuid4().hex}.py"
quoted_script = shlex.quote(script_path)
operation_timeout = app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
process_timeout = max(
1,
operation_timeout
- _PROCESS_TERMINATION_GRACE_SECONDS
- _TRANSPORT_GRACE_SECONDS,
)
await session.write_file(script_path, code.encode())
command = (
f"script={quoted_script}; "
"""trap 'rm -f -- "$script"' EXIT; """
"cd -- /workspace && "
"timeout --signal=TERM "
f"--kill-after={_PROCESS_TERMINATION_GRACE_SECONDS}s {process_timeout}s "
'python3 "$script"'
)
try:
async with asyncio.timeout(operation_timeout):
result = await session.run_command(command)
except TimeoutError:
raise TimeoutError(
f"Sandbox Python execution exceeded {operation_timeout} seconds"
) from None
finally:
# The shell trap handles normal completion and provider-stream failures
# after process exit. This fallback covers failures before the shell starts.
try:
async with asyncio.timeout(
min(_PROCESS_TERMINATION_GRACE_SECONDS, operation_timeout)
):
await session.run_command(f"rm -f -- {quoted_script}")
except Exception:
logger.warning("Could not remove temporary sandbox script", exc_info=True)
if result.exit_code == 124:
detail = f"Python execution exceeded {process_timeout} seconds"
return ExecResult(
output=f"{result.output}\n{detail}".lstrip(),
exit_code=result.exit_code,
truncated=result.truncated,
)
return result
def create_sandbox_tools(*, workspace_id: int) -> list[BaseTool]:
"""Build the provider-agnostic authoring tools."""
@ -41,13 +98,13 @@ def create_sandbox_tools(*, workspace_id: int) -> list[BaseTool]:
) -> str:
"""Run Python or a Bash command in the sandbox.
Write multi-step work to a source file and run that file: only some
providers keep interpreter state between calls. Long output is
truncated here and written in full to the returned sandbox path.
Each Python call runs as a fresh process; carry state between calls in
files, not interpreter variables. Long output is truncated here and
written in full to the returned sandbox path.
"""
session = await _get_session(workspace_id, runtime)
result = (
await session.execute(code_or_command, language="python")
await _run_python_script(session, code_or_command)
if language == "python"
else await session.run_command(code_or_command)
)

View file

@ -572,8 +572,8 @@ class Config:
# Creation blocks server-side while the image is pulled onto the host
# daemon, which the SDK's 30s default turns into an error rather than a slow
# first request. Compose pre-pulls; this covers hosts that did not.
SANDBOX_REQUEST_TIMEOUT_SECONDS = int(
os.getenv("SANDBOX_REQUEST_TIMEOUT_SECONDS", "120")
SANDBOX_OPERATION_TIMEOUT_SECONDS = int(
os.getenv("SANDBOX_OPERATION_TIMEOUT_SECONDS", "200")
)
SANDBOX_MAX_SESSIONS_PER_WORKSPACE = int(
os.getenv("SANDBOX_MAX_SESSIONS_PER_WORKSPACE", "2")

View file

@ -29,16 +29,11 @@ from ..protocol import ExecResult
logger = logging.getLogger(__name__)
THREAD_LABEL_KEY = "surfsense_thread"
_DEFAULT_TIMEOUT = 300
_START_TIMEOUT = 60
def _wrap_as_python(code: str) -> str:
"""Wrap code in a unique-sentinel heredoc.
Daytona exposes commands, not a kernel, so Python arrives as a shell
command and state does not carry across calls.
"""
"""Wrap code in a unique-sentinel heredoc for Daytona's command API."""
sentinel = f"_PYEOF_{secrets.token_hex(8)}"
return f"python3 << '{sentinel}'\n{code}\n{sentinel}"
@ -58,7 +53,10 @@ class DaytonaSession:
async def run_command(self, command: str) -> ExecResult:
def _run() -> ExecResult:
result = self._sandbox.process.exec(command, timeout=_DEFAULT_TIMEOUT)
result = self._sandbox.process.exec(
command,
timeout=app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS,
)
return ExecResult(
output=result.result or "",
exit_code=result.exit_code or 0,

View file

@ -27,9 +27,8 @@ logger = logging.getLogger(__name__)
THREAD_METADATA_KEY = "surfsense_thread"
# The image's entrypoint is inherited, but OpenSandbox only starts the Jupyter
# kernel when it is passed explicitly; PYTHON_VERSION selects which interpreter
# the kernel binds to (see docker/sandbox/Dockerfile).
# OpenSandbox requires the image service entrypoint explicitly; PYTHON_VERSION
# selects the image's Python runtime (see docker/sandbox/Dockerfile).
_ENTRYPOINT = ["/opt/code-interpreter/code-interpreter.sh"]
_ENV = {"PYTHON_VERSION": "3.12"}
_RUNNING_STATES = {"RUNNING", "PENDING"}
@ -68,12 +67,7 @@ def _raise_normalized(
def _to_result(execution) -> ExecResult:
"""Flatten an SDK Execution into the protocol shape.
`exit_code` is only populated for commands; kernel executions report failure
through `error`, so an errored run has to be mapped to a non-zero code for
callers that only check `ok`.
"""
"""Flatten an SDK execution into the provider-neutral result shape."""
if execution.error is not None:
return ExecResult(output=str(execution), exit_code=1)
return ExecResult(output=str(execution), exit_code=execution.exit_code or 0)
@ -95,8 +89,6 @@ class OpenSandboxSession:
return self._sandbox.id
async def _get_interpreter(self) -> CodeInterpreter:
# Created lazily and once: the kernel's cold start is ~5 s, and its
# whole value is that later executions share its process state.
async with self._interpreter_mu:
if self._interpreter is None:
self._interpreter = await CodeInterpreter.create(sandbox=self._sandbox)
@ -164,7 +156,7 @@ class OpenSandboxProvider:
# blocks on the server pulling the image when it is not already on
# the host daemon.
request_timeout=timedelta(
seconds=app_config.SANDBOX_REQUEST_TIMEOUT_SECONDS
seconds=app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
),
)
self._ttl = app_config.SANDBOX_IDLE_TTL_SECONDS

View file

@ -10,6 +10,9 @@ from pathlib import Path
import pytest
from app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.tools.sandbox import (
_run_python_script,
)
from app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.tools.save_artifact import (
_read_artifact_file,
)
@ -48,7 +51,7 @@ pytestmark = [
)
],
)
async def test_opensandbox_persistent_kernel_binary_io_and_terminate(
async def test_opensandbox_one_shot_python_binary_io_and_terminate(
monkeypatch, skill, prompt, expected_mime, expected_evidence_steps
):
monkeypatch.setattr(app_config, "OPENSANDBOX_DOMAIN", "localhost:8080")
@ -62,9 +65,10 @@ async def test_opensandbox_persistent_kernel_binary_io_and_terminate(
session = await provider.get_or_create_session(thread_id)
try:
evidence: list[str] = []
first = await session.execute("contract_value = 41\nprint(contract_value)")
second = await session.execute("print(contract_value + 1)")
pdf = await session.execute(
first = await _run_python_script(session, "print(41)")
second = await _run_python_script(session, "print(42)")
pdf = await _run_python_script(
session,
"""
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
@ -144,7 +148,8 @@ Packer.toBuffer(doc).then((buffer) => fs.writeFileSync("/tmp/report.docx", buffe
"""
generated = await session.run_command(f"node -e {shlex.quote(javascript)}")
else:
generated = await session.execute(
generated = await _run_python_script(
session,
"""
import base64
from io import BytesIO

View file

@ -4,6 +4,7 @@ from unittest.mock import Mock
import pytest
from daytona import Daytona, SandboxState
from app.config import config as app_config
from app.sandbox.providers.daytona import (
THREAD_LABEL_KEY,
DaytonaProvider,
@ -33,6 +34,10 @@ async def test_daytona_session_maps_command_and_binary_file_operations():
assert result.ok
assert result.output == "ok"
assert "python3 <<" in sandbox.process.exec.call_args.args[0]
assert (
sandbox.process.exec.call_args.kwargs["timeout"]
== app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
)
assert downloaded == b"\x00binary"
sandbox.fs.upload_file.assert_called_once_with(b"new", "/workspace/file.bin")
client.delete.assert_called_once_with(sandbox)

View file

@ -498,13 +498,131 @@ async def test_load_artifact_for_revision_writes_primary_and_markdown(monkeypatc
assert resolved_backends == ["azure"]
async def test_execute_python_uses_unique_one_shot_scripts_and_cleans_up(monkeypatch):
commands: list[str] = []
session = FakeSandboxSession({})
async def run_command(command: str) -> ExecResult:
commands.append(command)
return ExecResult("created", 0)
session.run_command = run_command # type: ignore[method-assign]
ids = iter(("first", "second"))
monkeypatch.setattr(
sandbox_tools.uuid,
"uuid4",
lambda: SimpleNamespace(hex=next(ids)),
)
async def get_session(*_args):
return session
monkeypatch.setattr(sandbox_tools, "_get_session", get_session)
tool = next(
tool
for tool in sandbox_tools.create_sandbox_tools(workspace_id=3)
if tool.name == "execute"
)
first = await tool.coroutine(
code_or_command="print('first')", language="python", runtime=_runtime()
)
second = await tool.coroutine(
code_or_command="print('second')", language="python", runtime=_runtime()
)
assert "created" in first
assert "created" in second
assert session.writes == {
"/tmp/.surfsense-exec-first.py": b"print('first')",
"/tmp/.surfsense-exec-second.py": b"print('second')",
}
execution_commands = [command for command in commands if command.startswith("script=")]
assert len(execution_commands) == 2
assert "/tmp/.surfsense-exec-first.py" in execution_commands[0]
assert "/tmp/.surfsense-exec-second.py" in execution_commands[1]
assert all("cd -- /workspace" in command for command in execution_commands)
assert all(
"timeout --signal=TERM --kill-after=5s" in command
for command in execution_commands
)
assert [command for command in commands if command.startswith("rm -f --")] == [
"rm -f -- /tmp/.surfsense-exec-first.py",
"rm -f -- /tmp/.surfsense-exec-second.py",
]
async def test_execute_python_cleans_up_when_command_fails(monkeypatch):
commands: list[str] = []
session = FakeSandboxSession({})
async def run_command(command: str) -> ExecResult:
commands.append(command)
if command.startswith("script="):
raise RuntimeError("provider failed")
return ExecResult("", 0)
session.run_command = run_command # type: ignore[method-assign]
monkeypatch.setattr(
sandbox_tools.uuid,
"uuid4",
lambda: SimpleNamespace(hex="failed"),
)
with pytest.raises(RuntimeError, match="provider failed"):
await sandbox_tools._run_python_script(session, "raise RuntimeError")
assert commands[-1] == "rm -f -- /tmp/.surfsense-exec-failed.py"
async def test_execute_python_returns_before_provider_stream_can_wedge(monkeypatch):
session = FakeSandboxSession({})
async def run_command(command: str) -> ExecResult:
if command.startswith("rm -f --"):
return ExecResult("", 0)
await asyncio.sleep(1)
return ExecResult("", 0)
session.run_command = run_command # type: ignore[method-assign]
monkeypatch.setattr(
sandbox_tools.app_config, "SANDBOX_OPERATION_TIMEOUT_SECONDS", 0.01
)
with pytest.raises(TimeoutError, match="exceeded"):
await sandbox_tools._run_python_script(session, "print('never returned')")
async def test_execute_python_reports_process_timeout(monkeypatch):
session = FakeSandboxSession({})
async def run_command(command: str) -> ExecResult:
return (
ExecResult("partial output", 124)
if command.startswith("script=")
else ExecResult("", 0)
)
session.run_command = run_command # type: ignore[method-assign]
monkeypatch.setattr(
sandbox_tools.app_config, "SANDBOX_OPERATION_TIMEOUT_SECONDS", 27
)
result = await sandbox_tools._run_python_script(session, "while True: pass")
assert result.exit_code == 124
assert result.output == "partial output\nPython execution exceeded 17 seconds"
async def test_execute_truncates_and_preserves_full_output(monkeypatch):
session = _sandbox({})
session = FakeSandboxSession({})
async def execute(code: str, language: str = "python") -> ExecResult:
return ExecResult("x" * (sandbox_tools._MAX_CONTEXT_CHARS + 1), 0)
async def run_command(command: str) -> ExecResult:
if command.startswith("script="):
return ExecResult("x" * (sandbox_tools._MAX_CONTEXT_CHARS + 1), 0)
return ExecResult("", 0)
session.execute = execute # type: ignore[attr-defined]
session.run_command = run_command # type: ignore[method-assign]
async def get_session(*_args):
return session
@ -522,7 +640,35 @@ async def test_execute_truncates_and_preserves_full_output(monkeypatch):
assert "output truncated" in result
assert "Full output:" in result
assert next(iter(session.writes.values())).endswith(b"x")
output = next(
data
for path, data in session.writes.items()
if path.startswith("/tmp/surfsense-output-")
)
assert output.endswith(b"x")
async def test_execute_bash_remains_a_direct_command(monkeypatch):
session = FakeSandboxSession(
{}, command_handler=lambda command: ExecResult(command, 0)
)
async def get_session(*_args):
return session
monkeypatch.setattr(sandbox_tools, "_get_session", get_session)
tool = next(
tool
for tool in sandbox_tools.create_sandbox_tools(workspace_id=3)
if tool.name == "execute"
)
result = await tool.coroutine(
code_or_command="printf done", language="bash", runtime=_runtime()
)
assert session.commands == ["printf done"]
assert result.startswith("printf done")
async def test_load_artifact_instructions_uses_the_structured_format(monkeypatch):

View file

@ -10,6 +10,7 @@ import pytest
from app.agents.chat.multi_agent_chat.shared.middleware.filesystem.tools.execute_code import (
helpers,
)
from app.config import config as app_config
from app.sandbox import ExecResult, SandboxUnavailableError
@ -52,6 +53,13 @@ def _install(monkeypatch, registry: FakeRegistry) -> None:
monkeypatch.setattr(helpers, "get_registry", _get_registry)
def test_execute_code_uses_the_shared_sandbox_operation_budget():
assert (
helpers.MAX_EXECUTE_TIMEOUT
== app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
)
async def test_successful_run_reports_exit_code(monkeypatch, middleware):
registry = FakeRegistry(FakeSession([ExecResult(output="42", exit_code=0)]))
_install(monkeypatch, registry)

View file

@ -1,9 +1,11 @@
from datetime import timedelta
from types import SimpleNamespace
import pytest
from opensandbox.exceptions import SandboxApiException
from app.sandbox.providers.opensandbox import OpenSandboxSession
from app.config import config as app_config
from app.sandbox.providers.opensandbox import OpenSandboxProvider, OpenSandboxSession
class _Files:
@ -19,6 +21,14 @@ def _session(exc: Exception) -> OpenSandboxSession:
return OpenSandboxSession(sandbox, ttl_seconds=900)
def test_provider_uses_the_shared_sandbox_operation_budget(monkeypatch) -> None:
monkeypatch.setattr(app_config, "SANDBOX_OPERATION_TIMEOUT_SECONDS", 37)
provider = OpenSandboxProvider()
assert provider._config.request_timeout == timedelta(seconds=37)
async def test_read_file_normalizes_provider_404() -> None:
session = _session(
SandboxApiException(

View file

@ -130,9 +130,9 @@ export const TraceItemRow: FC<{
"after:pointer-events-none after:absolute after:top-6 after:bottom-1 after:left-[7.5px]",
"after:w-px after:bg-muted-foreground/20 last:after:hidden",
status === "running" && "text-foreground",
(status === "completed" || status === "reasoning") && "text-muted-foreground",
(status === "completed" || status === "reasoning" || status === "error") &&
"text-muted-foreground",
status === "awaiting_approval" && "text-muted-foreground",
status === "error" && "text-destructive",
(status === "cancelled" || status === "interrupted") && "text-muted-foreground"
)}
>

View file

@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import test from "node:test";
import { QueryClient } from "@tanstack/react-query";
import {
artifactImageBlobQueryKey,
artifactListQueryKey,
artifactManifestQueryKey,
invalidatePublishedArtifact,
} from "@/features/artifacts/artifact-query";
import {
collectArtifacts,
enrichArtifactRows,
} from "@/features/chat-artifacts/lib/collect-artifacts";
import { buildTurnRenderItems } from "@/features/chat-messages/timeline/grouping";
const bodyTools = new Set(["save_artifact"]);
test("only a successful save is rendered as a product body card", () => {
for (const result of [
undefined,
{ status: "failed", artifact_id: 12 },
{ status: "saved", artifact_id: null },
]) {
const [item] = buildTurnRenderItems({
parts: [
{
type: "tool-call",
toolName: "save_artifact",
toolCallId: "save-1",
result,
},
],
bodyToolNames: bodyTools,
showReasoning: true,
threadRunning: false,
});
assert.equal(item?.kind, "segment");
}
const [saved] = buildTurnRenderItems({
parts: [
{
type: "tool-call",
toolName: "save_artifact",
toolCallId: "save-2",
result: { status: "saved", artifact_id: 12 },
},
],
bodyToolNames: bodyTools,
showReasoning: true,
threadRunning: false,
});
assert.equal(saved?.kind, "body-tool");
});
test("revisions share one sidebar row with latest metadata and card target", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "save_artifact",
toolCallId: "old-card",
args: { title: "Old title" },
result: {
status: "saved",
artifact_id: 42,
title: "Old title",
files: [{ role: "primary", filename: "old.docx" }],
},
},
],
},
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "save_artifact",
toolCallId: "failed-card",
args: { title: "Broken revision" },
result: { status: "failed", artifact_id: 42, error: "boom" },
},
{
type: "tool-call",
toolName: "save_artifact",
toolCallId: "latest-card",
args: { title: "New title" },
result: {
status: "saved",
artifact_id: 42,
title: "New title",
files: [{ role: "primary", filename: "new.pdf" }],
},
},
],
},
] as never;
const candidates = collectArtifacts(messages);
assert.equal(candidates.length, 1);
assert.equal(candidates[0]?.toolCallId, "latest-card");
const [row] = enrichArtifactRows(candidates, [
{
artifact_id: 42,
document_id: 99,
title: "Canonical latest title",
format: "pdf",
generation: 2,
indexing_status: "ready",
created_at: "2026-08-18T00:00:00Z",
updated_at: "2026-08-18T00:01:00Z",
thread_id: 7,
},
]);
assert.equal(row?.title, "Canonical latest title");
assert.equal(row?.format, "pdf");
assert.equal(row?.toolCallId, "latest-card");
});
test("publication clears revision-sensitive caches and invalidates lists", async () => {
const client = new QueryClient();
const workspaceId = 3;
const artifactId = 42;
const manifestKey = artifactManifestQueryKey(workspaceId, artifactId);
const listKey = artifactListQueryKey(workspaceId);
const threadListKey = artifactListQueryKey(workspaceId, 7);
const libraryKey = ["artifacts-library", workspaceId] as const;
const imageKey = artifactImageBlobQueryKey(workspaceId, artifactId, null, 100);
for (const key of [manifestKey, listKey, threadListKey, libraryKey, imageKey]) {
client.setQueryData(key, { cached: true });
}
await invalidatePublishedArtifact(client, workspaceId, artifactId);
assert.equal(client.getQueryData(manifestKey), undefined);
assert.equal(client.getQueryData(imageKey), undefined);
assert.equal(client.getQueryState(listKey)?.isInvalidated, true);
assert.equal(client.getQueryState(threadListKey)?.isInvalidated, true);
assert.equal(client.getQueryState(libraryKey)?.isInvalidated, true);
});