fix(close): require non-empty body, restore CloseWorkstreamRequest

Copilot caught three real issues in PR #422 review, all clustered
around the close request body contract:

1. The interactive close handler runs with
   ``supports_close_reason=True``, which calls
   ``read_json_or_400(request)`` — an empty / non-JSON body returns
   ``400 {"error": "Invalid JSON body"}``. The previous SDK fix
   sent NO body via ``json_body=None``, which would 400 against a
   real server. The mock-transport test silently masked it because
   the mock answered without inspecting the body.
2. The doc said the body was empty (or ``{}``), with no mention
   of the optional ``reason`` field, its 512-byte cap, or the
   credential-redaction guard.
3. The Pydantic schema for close was deleted outright; OpenAPI
   and SDKs lost their typed shape for the optional ``reason``.

Changes:

- ``turnstone/api/server_schemas.py``: reintroduce
  ``CloseWorkstreamRequest`` with a single optional
  ``reason: str | None = None`` field. Docstring documents the
  must-be-valid-JSON contract and notes that coord ignores the body
  (``supports_close_reason=False``).
- ``turnstone/api/server_spec.py``: re-import the schema, point the
  close ``EndpointSpec`` at it via ``request_model=``, restore the
  ``_ALL_MODELS`` entry. OpenAPI JSON regenerated.
- ``turnstone/sdk/server.py``: ``close_workstream`` (sync + async)
  gains an optional ``reason: str | None = None`` parameter and
  always sends ``json_body={}`` (or ``{"reason": ...}``) so the
  body is never empty. Adds a regression test
  (``test_close_workstream_sends_valid_json_body``) that inspects the
  raw transport content rather than relying on a path-keyed mock —
  the kind of check that would have caught this bug pre-merge.
- ``sdk/typescript/src/server.ts``: ``closeWorkstream`` gains an
  optional ``opts.reason`` parameter; reintroduce
  ``CloseWorkstreamRequest`` interface in ``types.ts`` and re-export
  from ``index.ts``.
- ``docs/api-reference.md``: close section documents the JSON-body
  requirement, the ``reason`` field, the 512-byte cap, the
  multibyte-safe behavior, the credential-redaction guard, and the
  non-string-coercion path.
- ``CHANGELOG.md``: amend the 1.5.0 BREAKING block to reflect the
  schema reintroduction (slim form, ``reason`` optional) instead of
  the prior "removed outright" claim.

4558 tests passing under ``-m "not live"`` (was 4557 — +1 from the
regression test). ruff + mypy clean.
This commit is contained in:
Patrick Buckley
2026-04-26 22:08:16 -07:00
parent fa09f7c9c0
commit 4000ae240c
10 changed files with 121 additions and 8 deletions
+3 -2
View File
@@ -38,8 +38,9 @@ Three release tracks are maintained:
Calls to the old URLs return **404** on 1.5.0+. Bodies on the new
URLs no longer carry ``ws_id`` (the path provides it); the
``SendRequest`` / ``ApproveRequest`` / ``CancelRequest`` Pydantic
schemas drop the field, and ``CloseWorkstreamRequest`` is removed
outright (its only field was ``ws_id``).
schemas drop the field, and ``CloseWorkstreamRequest`` slims to a
single optional ``reason`` field (the body is still required to be
valid JSON — send ``{}`` when omitting all fields).
``/v1/api/plan`` and ``/v1/api/command`` are unaffected and remain
body-keyed in this release. The bundled web UI, channel adapters,
+15 -1
View File
@@ -919,7 +919,21 @@ closed.
|-----------|--------|----------|------------------------|
| `ws_id` | string | yes | Workstream ID to close |
The body is empty (or `{}`).
**Request body:**
The body must be valid JSON. If you are not supplying any optional
fields, send `{}` — an empty / non-JSON body is rejected with a
`400`.
| Field | Type | Required | Description |
|----------|--------|----------|----------------------------------------------------------|
| `reason` | string | no | Optional close reason persisted to `workstream_config`. |
The `reason` is capped at **512 UTF-8 bytes** (multibyte-safe — the
cap holds for CJK and emoji payloads), and the output guard's
credential-redaction pass strips secrets before the value is
persisted. A non-string `reason` is silently coerced to empty and
the close proceeds without writing the field.
**Response (success):**
+30
View File
@@ -127,6 +127,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CloseWorkstreamRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
@@ -2215,6 +2225,26 @@
"title": "CreateWorkstreamResponse",
"type": "object"
},
"CloseWorkstreamRequest": {
"description": "Body for ``POST /v1/api/workstreams/{ws_id}/close``.\n\nThe body must be valid JSON; send ``{}`` when omitting all\nfields. Pre-1.5 the model also carried a body-keyed ``ws_id``;\n1.5 moved that to the path so the body shrinks to the optional\n``reason``. Coord ignores the body entirely (its close handler\nis wired ``supports_close_reason=False``).",
"properties": {
"reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional close reason persisted to ``workstream_config`` for postmortem. Capped at 512 UTF-8 bytes server-side; credential-redaction is applied via the output guard.",
"title": "Reason"
}
},
"title": "CloseWorkstreamRequest",
"type": "object"
},
"ListWorkstreamsResponse": {
"description": "Response body for ``GET /v1/api/workstreams`` on either kind.\n\nTop-level key is ``workstreams`` regardless of the kind serving\nthe request \u2014 pre-lift coord returned ``{\"coordinators\": [...]}``;\nconvergence lifted both kinds onto the same shape. Coord SDK /\nfrontend consumers branching on ``data.coordinators`` swap to\n``data.workstreams``.",
"properties": {
+1
View File
@@ -84,6 +84,7 @@ export type {
CommandRequest,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
CloseWorkstreamRequest,
WorkstreamInfo,
ListWorkstreamsResponse,
DashboardWorkstream,
+7 -2
View File
@@ -93,11 +93,16 @@ export class TurnstoneServer extends BaseClient {
});
}
async closeWorkstream(wsId: string): Promise<StatusResponse> {
async closeWorkstream(
wsId: string,
opts?: { reason?: string },
): Promise<StatusResponse> {
const body: Record<string, unknown> = {};
if (opts?.reason !== undefined) body.reason = opts.reason;
return this.request(
"POST",
`/v1/api/workstreams/${encodeURIComponent(wsId)}/close`,
{ json: {} },
{ json: body },
);
}
+9
View File
@@ -161,6 +161,15 @@ export interface CreateWorkstreamResponse {
attachment_ids?: string[];
}
export interface CloseWorkstreamRequest {
/**
* Optional close reason persisted to `workstream_config` for
* postmortem. Capped at 512 UTF-8 bytes server-side; credential
* redaction is applied via the output guard.
*/
reason?: string;
}
export interface WorkstreamInfo {
// Renamed `id` → `ws_id` and added kind/parent_ws_id/user_id in
// the Stage 2 list-verb lift. Pre-1.5 readers branching on
+26
View File
@@ -107,6 +107,32 @@ async def test_close_workstream():
assert resp.status == "ok"
@pytest.mark.anyio
async def test_close_workstream_sends_valid_json_body():
"""The interactive close handler reads the body via
``read_json_or_400`` (``supports_close_reason=True``), so a missing
or non-JSON body 400s. Regression-lock that the SDK never sends
an empty body. ``request.json()`` raises ``ValueError`` on empty
bytes; this handler asserts the SDK actually transmitted a JSON
object."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["content"] = bytes(request.content)
captured["body"] = json.loads(request.content) if request.content else None
return httpx.Response(200, json={"status": "ok"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
# Default call (no reason) — body must still be valid JSON.
await client.close_workstream("ws1")
assert captured["body"] == {}
# With reason — field round-trips.
await client.close_workstream("ws1", reason="task complete")
assert captured["body"] == {"reason": "task complete"}
# ---------------------------------------------------------------------------
# Chat interaction
# ---------------------------------------------------------------------------
+20
View File
@@ -176,6 +176,26 @@ class CreateWorkstreamResponse(BaseModel):
)
class CloseWorkstreamRequest(BaseModel):
"""Body for ``POST /v1/api/workstreams/{ws_id}/close``.
The body must be valid JSON; send ``{}`` when omitting all
fields. Pre-1.5 the model also carried a body-keyed ``ws_id``;
1.5 moved that to the path so the body shrinks to the optional
``reason``. Coord ignores the body entirely (its close handler
is wired ``supports_close_reason=False``).
"""
reason: str | None = Field(
default=None,
description=(
"Optional close reason persisted to ``workstream_config`` "
"for postmortem. Capped at 512 UTF-8 bytes server-side; "
"credential-redaction is applied via the output guard."
),
)
# ---------------------------------------------------------------------------
# List / dashboard
# ---------------------------------------------------------------------------
+3
View File
@@ -22,6 +22,7 @@ from turnstone.api.server_schemas import (
ApproveRequest,
AvailableModelInfo,
CancelRequest,
CloseWorkstreamRequest,
CommandRequest,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
@@ -84,6 +85,7 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
"/v1/api/workstreams/{ws_id}/close",
"POST",
"Close a workstream",
request_model=CloseWorkstreamRequest,
response_model=StatusResponse,
error_codes=[400, 404],
tags=["Workstreams"],
@@ -447,6 +449,7 @@ _ALL_MODELS: list[type[BaseModel]] = [
CancelRequest,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
CloseWorkstreamRequest,
ListWorkstreamsResponse,
WorkstreamDetailResponse,
WorkstreamHistoryResponse,
+7 -3
View File
@@ -180,10 +180,14 @@ class AsyncTurnstoneServer(_BaseClient):
response_model=CreateWorkstreamResponse,
)
async def close_workstream(self, ws_id: str) -> StatusResponse:
async def close_workstream(self, ws_id: str, *, reason: str | None = None) -> StatusResponse:
body: dict[str, Any] = {}
if reason is not None:
body["reason"] = reason
return await self._request(
"POST",
f"/v1/api/workstreams/{ws_id}/close",
json_body=body,
response_model=StatusResponse,
)
@@ -618,8 +622,8 @@ class TurnstoneServer:
)
)
def close_workstream(self, ws_id: str) -> StatusResponse:
return self._runner.run(self._async.close_workstream(ws_id))
def close_workstream(self, ws_id: str, *, reason: str | None = None) -> StatusResponse:
return self._runner.run(self._async.close_workstream(ws_id, reason=reason))
# -- chat interaction ----------------------------------------------------