Files
turnstone/tests/test_sdk_server_attachments.py
Patrick Buckley 6cbd3eb2c1 feat: workstream attachments at creation time + SDK + UI parity (#362)
* feat: workstream attachments at creation time + SDK + UI parity

Closes the two big deferred items from PR #356: attaching files as part
of the initial workstream-creation request, and full SDK coverage of the
attachment surface.

Server: POST /v1/api/workstreams/new now accepts multipart/form-data
(meta JSON + 0..N file parts).  Files are validated and saved as pending
under the new ws; when initial_message is also set the create handler
reserves them onto that turn before the dispatch worker fires, mirroring
the /v1/api/send pattern.  Validation failure rolls back the workstream
via delete_workstream so we don't leak orphan rows or emit a phantom
ws_created/ws_closed pair on SSE.  JSON path is unchanged.

Console routing: route_create accepts multipart with ?ws_id=<hex> as a
query parameter (the console hashes the id before the body lands).
Added /v1/api/route/workstreams/{ws_id}/attachments POST/GET/DELETE +
.../{attachment_id}/content GET proxies that forward raw bytes and
preserve upstream headers (Content-Disposition, X-Content-Type-Options,
CSP sandbox).

Python + TypeScript SDKs: AttachmentUpload type, upload_attachment,
list_attachments, get_attachment_content, delete_attachment, and
send(attachment_ids=...).  create_workstream(attachments=...) sends
multipart and pre-generates a ws_id client-side so cluster routing
works.  SDKs reject attachments+target_node combinations since the
multipart route doesn't honor target_node.

Web UI: dashboard composer refactored to a single unified create flow.
Replaced the inconsistent split (Enter created+sent raw, "New Chat"
opened a modal) with one rich composer carrying a textarea, paperclip
+ chip strip, drag-drop, paste-image, and a collapsible Options panel
for model/judge_model/skill.  Submit button dynamically labels Create
vs Send.  New-workstream modal also gained the same paperclip + chip
strip + first-message field for the tab-bar + entry point.

Tests: 30 new tests across server multipart create, console route
multipart + attachment proxies, Python + TS SDK attachment surfaces,
plus regressions for the three review-flagged bugs (Content-Type
boundary preservation, attachments+target_node rejection, no phantom
ws_created on validation failure).

* fix: address Copilot review feedback on PR #362

- web_helpers: docstring now matches behaviour — read_multipart_create_or_400
  does enforce the optional max_per_file_bytes cap as defense-in-depth.
- app.js: drop the duplicated _formatAttachSize definition (one already
  exists earlier for pane chips); add a shared _isAttachmentAllowed helper
  that mirrors the server's classifier (png/jpeg/gif/webp images, text/*
  MIMEs, allowlisted application/* MIMEs, known text extensions) and call
  it from both _newWsAddFiles and _addDashboardFiles so unsupported files
  fail fast client-side instead of after a server roundtrip.
- app.js: dashboardSubmit catch now suppresses the redundant error toast
  on authFetch's "auth" Error and falls back to a generic message when
  err.message is undefined, instead of rendering "Connection error: undefined".
- SendResponse (Pydantic + TS): document and expose attached_ids,
  dropped_attachment_ids, priority, and msg_id so attachment-aware SDK
  callers can detect partial reservations and dequeue queued messages.
- test_server_attachments_on_create: drop the dual `import turnstone.server`
  + `from turnstone.server import` style — use monkeypatch.setattr by
  dotted path for module-level mutation and `from … import …` for the
  helpers, keeping a single import style.
2026-04-16 13:30:25 -07:00

230 lines
8.2 KiB
Python

"""Tests for the attachment surface of turnstone.sdk.server (async + sync).
Uses ``httpx.MockTransport`` to record what the SDK sends so we can
assert on multipart bodies, the auto-generated ws_id, etc.
"""
from __future__ import annotations
import json
import re
import httpx
import pytest
from turnstone.sdk._types import AttachmentUpload
from turnstone.sdk.server import AsyncTurnstoneServer
PNG_1x1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf"
b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82"
)
def _capturing_transport(response: httpx.Response) -> tuple[httpx.MockTransport, list]:
captured: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request)
return response
return httpx.MockTransport(handler), captured
# ---------------------------------------------------------------------------
# upload / list / get_content / delete
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_upload_attachment_sends_multipart():
response = httpx.Response(
200,
json={
"attachment_id": "att-1",
"filename": "tiny.png",
"mime_type": "image/png",
"size_bytes": len(PNG_1x1),
"kind": "image",
},
)
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
result = await client.upload_attachment("ws-X", "tiny.png", PNG_1x1, mime_type="image/png")
assert result.attachment_id == "att-1"
assert result.kind == "image"
assert len(captured) == 1
req = captured[0]
assert req.method == "POST"
assert req.url.path == "/v1/api/workstreams/ws-X/attachments"
ct = req.headers.get("content-type", "")
assert ct.startswith("multipart/form-data")
body = bytes(req.content)
assert b"tiny.png" in body
assert PNG_1x1 in body
@pytest.mark.anyio
async def test_list_attachments_returns_pending():
response = httpx.Response(
200,
json={
"attachments": [
{
"attachment_id": "att-1",
"filename": "a.txt",
"mime_type": "text/plain",
"size_bytes": 5,
"kind": "text",
}
]
},
)
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
result = await client.list_attachments("ws-X")
assert len(result.attachments) == 1
assert result.attachments[0].attachment_id == "att-1"
assert captured[0].method == "GET"
@pytest.mark.anyio
async def test_get_attachment_content_returns_bytes():
response = httpx.Response(
200,
content=b"hello world",
headers={"Content-Type": "text/plain; charset=utf-8"},
)
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
data = await client.get_attachment_content("ws-X", "att-1")
assert data == b"hello world"
assert captured[0].url.path == "/v1/api/workstreams/ws-X/attachments/att-1/content"
@pytest.mark.anyio
async def test_delete_attachment():
response = httpx.Response(200, json={"status": "deleted"})
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
result = await client.delete_attachment("ws-X", "att-1")
assert result.status == "deleted"
assert captured[0].method == "DELETE"
# ---------------------------------------------------------------------------
# send(attachment_ids=...)
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_send_with_attachment_ids():
response = httpx.Response(200, json={"status": "ok"})
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
await client.send("hi", "ws-X", attachment_ids=["a1", "a2"])
body = json.loads(bytes(captured[0].content))
assert body["attachment_ids"] == ["a1", "a2"]
assert body["message"] == "hi"
@pytest.mark.anyio
async def test_send_omits_attachment_ids_when_none():
response = httpx.Response(200, json={"status": "ok"})
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
await client.send("hi", "ws-X")
body = json.loads(bytes(captured[0].content))
assert "attachment_ids" not in body
# ---------------------------------------------------------------------------
# create_workstream(attachments=...)
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_create_workstream_with_attachments_sends_multipart():
response = httpx.Response(
200,
json={
"ws_id": "00ff" + "0" * 28,
"name": "demo",
"resumed": False,
"message_count": 0,
"attachment_ids": ["att-1"],
},
)
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.create_workstream(
name="demo",
initial_message="describe",
attachments=[AttachmentUpload(filename="hi.png", data=PNG_1x1, mime_type="image/png")],
)
assert resp.ws_id
assert resp.attachment_ids == ["att-1"]
req = captured[0]
assert req.method == "POST"
assert req.url.path == "/v1/api/workstreams/new"
ct = req.headers.get("content-type", "")
assert ct.startswith("multipart/form-data")
body = bytes(req.content)
# `meta` field carries the JSON metadata including the auto-generated ws_id
meta_match = re.search(rb'name="meta"\r\n\r\n(\{[^}]*\})', body)
assert meta_match, body
meta = json.loads(meta_match.group(1))
assert meta["name"] == "demo"
assert meta["initial_message"] == "describe"
assert re.fullmatch(r"[0-9a-f]{32}", meta["ws_id"])
# PNG bytes appear in the body as a file part
assert PNG_1x1 in body
@pytest.mark.anyio
async def test_create_workstream_caller_supplied_ws_id_used():
response = httpx.Response(
200,
json={
"ws_id": "deadbeef" * 4,
"name": "demo",
"resumed": False,
"message_count": 0,
"attachment_ids": [],
},
)
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
await client.create_workstream(
name="demo",
ws_id="deadbeef" * 4,
attachments=[AttachmentUpload(filename="a.txt", data=b"hi")],
)
body = bytes(captured[0].content)
meta_match = re.search(rb'name="meta"\r\n\r\n(\{[^}]*\})', body)
assert meta_match
meta = json.loads(meta_match.group(1))
assert meta["ws_id"] == "deadbeef" * 4
@pytest.mark.anyio
async def test_create_workstream_without_attachments_uses_json():
"""Back-compat: callers that don't pass attachments still get the JSON path."""
response = httpx.Response(200, json={"ws_id": "ws-json", "name": "j", "attachment_ids": []})
transport, captured = _capturing_transport(response)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
await client.create_workstream(name="j")
req = captured[0]
assert req.headers.get("content-type", "").startswith("application/json")