mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
e010124008
Tool results only ever rendered as plain text in the transcript. This
adds the model-driven rich-preview lane every comparable surface has,
in turnstone's developer-tool idiom: a preview pane that opens BESIDE
the conversation, keyboard-operable, sandboxed, never replacing the
transcript that spawned it.
Backend
- New built-in open_preview(target, kind?, title?): resolves an http(s)
URL, a file path, or attachment:<id> to bytes; classifies into
web/pdf/image/table/text/markdown (magic bytes > MIME hint >
extension > UTF-8 fallback, legacy-charset pages transcoded); caps
size per kind; persists content-addressed with kind="preview" —
refcounted and GC'd with the workstream, skipped by trajectory
reconstruction so preview bytes can never materialize onto the wire.
URL targets gate like web_fetch (network egress); paths/attachments
run unprompted like read_file.
- New core.web.fetch_with_ssrf_guard: manual redirect walk that
SSRF-screens every hop BEFORE requesting it (follow_redirects=True
checked nothing between hops); adopted by both open_preview and
web_fetch. URL userinfo is stripped before the descriptor or the
stored bytes see it; <base href> is injected doctype-safely so
relative assets resolve without quirks mode.
- The preview descriptor rides the tool turn's meta side channel with
ONE shape on every boundary: the live tool_result SSE event, the
conversations.meta column, and the /history projection. Cancelled
batches commit an already-announced preview (blob + meta) instead of
stranding the open pane on a permanent 404.
- New GET {ws}/attachments/{id}/preview (read scope, same ownership
gate as /content) serves the STORED type with per-MIME hardening:
bare CSP sandbox for text/html (renderable, scriptless, opaque
origin), no CSP for application/pdf (Chromium's viewer refuses
sandboxed contexts), full default-src 'none' otherwise; filenames
fold to latin-1-safe ASCII. The console /node proxy now forwards
CSP/nosniff/disposition/cache-control instead of dropping them.
- History loads exclude preview blobs from the bulk content fetch at
the query (they were read and discarded on every load).
Frontend
- New "preview" pane type registered in the shared shell (server +
console): openPaneBeside placement, per-kind renderers — fully
sandboxed iframe for pages, browser PDF viewer, sortable tables
(CSV/TSV/JSON, ragged-file safe, 5k-row cap), rendered markdown,
text — plus back/forward history with arrow keys, reload persistence
via pane meta, and backoff auto-retry (0.9s..7.2s) bridging the gap
between the live descriptor and the batch fold that commits its blob.
- Tool results carrying a descriptor render a credential-redacted
preview chip (the reopen + replay affordance); live results auto-open
the pane only while the originating pane holds focus.
Docs: docs/tools.md + prompts/tools.md. Tests: policy unit tests, tool
prepare/exec (mocked fetch), serving route + proxy header pass-through,
storage exclusion on both backends, cancel-path commit, JS static
guards; a headless-Chrome harness drives the real module graph (32 DOM
assertions).
163 lines
5.9 KiB
Python
163 lines
5.9 KiB
Python
"""Tests for turnstone.core.tools — JSON auto-loading and schema validation."""
|
|
|
|
from turnstone.core.tools import (
|
|
_META,
|
|
PRIMARY_KEY_MAP,
|
|
TASK_AGENT_TOOLS,
|
|
TASK_AUTO_TOOLS,
|
|
TOOLS,
|
|
)
|
|
|
|
|
|
class TestToolsSchema:
|
|
def test_all_tools_have_function_type(self):
|
|
for tool in TOOLS:
|
|
assert tool["type"] == "function", f"Tool missing type='function': {tool}"
|
|
|
|
def test_all_tools_have_name(self):
|
|
for tool in TOOLS:
|
|
assert "name" in tool["function"], f"Tool missing name: {tool}"
|
|
assert isinstance(tool["function"]["name"], str)
|
|
|
|
def test_all_tools_have_description(self):
|
|
for tool in TOOLS:
|
|
assert "description" in tool["function"], f"Tool missing description: {tool}"
|
|
assert len(tool["function"]["description"]) > 0
|
|
|
|
def test_all_tools_have_parameters(self):
|
|
for tool in TOOLS:
|
|
params = tool["function"]["parameters"]
|
|
assert params["type"] == "object"
|
|
assert "properties" in params
|
|
|
|
def test_required_fields_exist_in_properties(self):
|
|
for tool in TOOLS:
|
|
func = tool["function"]
|
|
params = func["parameters"]
|
|
required = params.get("required", [])
|
|
properties = params["properties"]
|
|
for field in required:
|
|
assert field in properties, (
|
|
f"Tool '{func['name']}': required field '{field}' not in properties"
|
|
)
|
|
|
|
def test_tool_names_unique(self):
|
|
names = [t["function"]["name"] for t in TOOLS]
|
|
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
|
|
|
|
def test_task_agent_tools_subset(self):
|
|
tool_names = {t["function"]["name"] for t in TOOLS}
|
|
task_names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
|
|
assert task_names.issubset(tool_names), (
|
|
f"TASK_AGENT_TOOLS has names not in TOOLS: {task_names - tool_names}"
|
|
)
|
|
|
|
def test_task_agent_tools_not_empty(self):
|
|
assert len(TASK_AGENT_TOOLS) > 0
|
|
|
|
|
|
class TestToolsMetadata:
|
|
"""Validate the metadata extracted from JSON files."""
|
|
|
|
def test_tool_count(self):
|
|
# 17 interactive tools + 12 coordinator-only tools.
|
|
assert len(TOOLS) == 29
|
|
|
|
def test_task_agent_tools_count(self):
|
|
assert len(TASK_AGENT_TOOLS) == 11
|
|
|
|
def test_coordinator_tools_count(self):
|
|
from turnstone.core.tools import COORDINATOR_TOOLS
|
|
|
|
assert len(COORDINATOR_TOOLS) == 15
|
|
assert {t["function"]["name"] for t in COORDINATOR_TOOLS} == {
|
|
"spawn_workstream",
|
|
"spawn_batch",
|
|
"close_all_children",
|
|
"inspect_workstream",
|
|
"send_to_workstream",
|
|
"close_workstream",
|
|
"cancel_workstream",
|
|
"delete_workstream",
|
|
"list_workstreams",
|
|
"list_nodes",
|
|
"tasks",
|
|
"wait_for_workstream",
|
|
# ``memory`` is dual-kind (coordinator + interactive) so
|
|
# coords can persist orchestration context for their children
|
|
# via the new ``coordinator`` scope.
|
|
"memory",
|
|
# ``skills`` is dual-kind (replaces legacy ``skill`` +
|
|
# ``list_skills``). Read actions (find, get) auto-approve;
|
|
# write actions require operator approval + the
|
|
# ``model.skills.write`` permission. ``load`` errors on
|
|
# coord sessions — coords delegate skill assignment via
|
|
# ``spawn_workstream(skill=...)``.
|
|
"skills",
|
|
# ``notify`` is dual-kind so coordinators can post status
|
|
# updates at narrative beats (fan-out complete, batch
|
|
# failed, phase done) without spawning a child purely to
|
|
# ship a message. Routing logic is session-kind-agnostic.
|
|
"notify",
|
|
}
|
|
|
|
def test_auto_approve_sets_match(self):
|
|
expected = {
|
|
"read_file",
|
|
"search",
|
|
"diff_file",
|
|
"web_fetch",
|
|
"web_search",
|
|
"notify",
|
|
# Coordinator read-only tools (no-mutation, safe to auto-approve):
|
|
"inspect_workstream",
|
|
"list_workstreams",
|
|
"list_nodes",
|
|
"wait_for_workstream",
|
|
}
|
|
assert expected == TASK_AUTO_TOOLS
|
|
|
|
def test_primary_key_map(self):
|
|
expected = {
|
|
"bash": "command",
|
|
"read_file": "path",
|
|
"search": "query",
|
|
"write_file": "content",
|
|
"edit_file": "old_string",
|
|
"web_fetch": "url",
|
|
"web_search": "query",
|
|
"open_preview": "target",
|
|
"task_agent": "prompt",
|
|
"memory": "name",
|
|
"recall": "query",
|
|
"notify": "message",
|
|
"watch": "command",
|
|
"read_resource": "uri",
|
|
"use_prompt": "name",
|
|
"skills": "action",
|
|
"diff_file": "path_a",
|
|
# Coordinator tools:
|
|
"spawn_workstream": "initial_message",
|
|
"spawn_batch": "children",
|
|
"close_all_children": "reason",
|
|
"inspect_workstream": "ws_id",
|
|
"send_to_workstream": "message",
|
|
"close_workstream": "ws_id",
|
|
"cancel_workstream": "ws_id",
|
|
"delete_workstream": "ws_id",
|
|
"tasks": "action",
|
|
}
|
|
assert expected == PRIMARY_KEY_MAP
|
|
|
|
def test_no_metadata_in_function_dicts(self):
|
|
"""Ensure turnstone metadata keys are stripped from the OpenAI schema."""
|
|
meta_keys = {"task_agent", "coordinator", "auto_approve", "primary_key"}
|
|
for tool in TOOLS:
|
|
func = tool["function"]
|
|
leaked = meta_keys & set(func)
|
|
assert not leaked, f"Tool '{func['name']}' leaks metadata into function dict: {leaked}"
|
|
|
|
def test_meta_has_all_tools(self):
|
|
tool_names = {t["function"]["name"] for t in TOOLS}
|
|
assert set(_META.keys()) == tool_names
|