mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
6cbd3eb2c1
* 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.
230 lines
8.2 KiB
Python
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")
|