Compare commits

..

1 Commits

Author SHA1 Message Date
Patrick Buckley 2d174cd71c perf(webui): bound the transcript — containment, block flow, windowing
The remaining steady-state cost after the wedge-proofing pass was
structural: every row of an unbounded transcript participated in every
layout, and full re-renders rebuilt all of it.

CSS pair (shared scroller + the ui/static duplicate):
- The messages scroller is BLOCK flow, not a column flexbox — flex
  relayouts all items when the streaming row's height changes, O(rows)
  per token; block flow dirties only the tail. The old per-child
  flex-shrink pin (and the min-height:auto squish hazard it suppressed)
  goes with it; inter-row rhythm moves to a sibling margin.
- overflow-anchor: none — the pane owns bottom pinning, and native
  anchoring kept re-selecting an anchor inside the innerHTML-replaced
  live bubble every frame.
- content-visibility: auto with contain-intrinsic-size: auto estimates
  on .msg (80px) and .conv-batch (200px) rows, exempting the last two
  children so the live tail never toggles skip-state mid-stream. The
  `auto` keyword memoizes each row's rendered size, keeping scrollHeight
  and the bottom pin stable once painted.

Transcript windowing (interactive pane):
- Full re-renders paint the most recent 300 messages, cut FORWARD to a
  user-turn boundary so an assistant tool_calls message is never split
  from the tool results that anchor to it. Hidden content sits behind a
  "Load earlier messages" pager; each click grows the window a step and
  refetches, restoring the scroll anchor by scrollHeight delta (the
  rAF pin re-checks the near-bottom flag at fire time, so no
  suppression is needed). Rewind/edit turn math is tail-relative and
  unaffected — pinned as such.
- Live appends are bounded at the idle edge: past 900 rendered rows the
  oldest rows are trimmed (again to a turn boundary), only while pinned
  to the bottom — a scrolled-up user is reading the rows a trim would
  remove. Trimmed content stays in /history and returns through the
  pager; detached agent-card entries are swept.

The perf page gains ?window= (and the runner --perf-extra) so windowing
and containment effects can be isolated.

Measured (n=3000 history + 20-turn storm; baseline -> previous branch
-> this change): full replay 1060ms -> 238ms -> 28ms windowed / 107ms
with the window grown to the full transcript; longtasks during the run
6/1080ms -> 4/495ms -> none windowed / 4/205ms unwindowed; per-turn
live-storm cost at n=3000 now equals n=300 (~220ms harness floor) even
with all 25k nodes live — the transcript-size tax is gone. Known
degraded-mode cost: the chunk path at a fully-grown window measures
~1017ms vs the 833ms floor; the shipped windowed config sits at the
floor.
2026-07-02 00:33:35 -07:00
304 changed files with 5773 additions and 46722 deletions
-5
View File
@@ -1,5 +0,0 @@
# Funding platforms for the GitHub "Sponsor" button.
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: [eous]
custom: ["https://paypal.me/eousphoros"]
+2 -2
View File
@@ -152,7 +152,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
uv-version: "0.9.18"
- run: uv lock --check
@@ -161,7 +161,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
uv-version: "0.9.18"
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1
uses: anthropics/claude-code-action@6c0083bb7289c31716797a039b6367b3079cc46e # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: 'renovate[bot]' # let Renovate PRs get reviewed
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1
uses: anthropics/claude-code-action@6c0083bb7289c31716797a039b6367b3079cc46e # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+2 -2
View File
@@ -54,7 +54,7 @@ jobs:
- name: Log in to GHCR
if: steps.tag.outputs.skip == 'false'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -78,7 +78,7 @@ jobs:
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
if: steps.tag.outputs.skip == 'false'
- name: Build and push
-1
View File
@@ -28,4 +28,3 @@ tools/skill_audit_analysis/data/
tools/skill_audit_analysis/output/
design_ideas/
.claude/
docs/design/
+4 -374
View File
@@ -6,383 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) for
version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
Two active release tracks are maintained — the current stable and the
experimental line:
Three release tracks are maintained — the current stable, one prior
stable, and the experimental line:
- **`stable/1.7`** — patch-only (`v1.7.x`)
- **`stable/1.5`** — patch-only (`v1.5.x`)
- **`stable/1.6`** — patch-only (`v1.6.x`)
- **`main`** — experimental (next major)
Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
## [1.7.4]
A feature-bearing patch for the 1.7 line, rolling up work that had stabilised
on `main`. No schema migrations (head stays 066) and no new configuration knobs.
### Added
- **Background shells for the `bash` tool** — `run_in_background=true` starts a
command as a detached shell and returns a `bash_N` handle; new `bash_output`
(delta output since last read, optional regex filter, status/exit code) and
`kill_shell` (terminates the shell's process group) tools manage it. Output is
buffered with a drop-oldest cap, a system notice lands when a shell exits, and
shells die with their workstream — never outliving a `task_agent` that started
them.
- **`task_agent` carries the model's native reasoning across its own tool loop** —
a task agent's replayed turns now preserve the provider-native reasoning lane
(Anthropic thinking blocks with signatures, OpenAI reasoning items, Gemini
`thought_signature`, vLLM/llama.cpp reasoning text) instead of rebuilding each
turn from text alone, restoring reasoning continuity for thinking models.
- **Model-shelf response controls** — the console model shelf exposes verbosity
and reasoning-mode controls per identity.
### Changed
- **GPT-5.6 aligned with the GA API surface** — the Responses provider matches
GPT-5.6's GA shape (typed `reasoning.mode`, `prompt_cache_options`,
cache-write accounting); the `openai` floor moves to `>=2.45`.
### Fixed
- **`bash` never hangs on a backgrounded child** — a command that left a
long-lived process running no longer wedges the workstream; the tool waits on
the tracked process (bounded by the timeout) and reaps its whole process group.
- **`task_agent` sub-tool ids are session-unique** — ids are minted
`{parent}::r{run}s{step}::{id}` so a local model reissuing sequential ids
(`call_0` each turn) no longer aliases two steps onto one live-card row while
`/history` keeps them apart.
- **Judge completions honour model-definition capabilities** — a judge's
completion now threads its model's declared capabilities instead of assuming a
default surface.
- **`create-admin` CLI** — adds an explicit admin-creation command; `run.sh` no
longer onboards into a role-less user.
- **Install script Docker handling** — installs Docker on distros
`get.docker.com` rejects, and gates that path by `$ID` instead of trapping all
failures.
## [1.7.3]
A small feature and maintenance patch for the 1.7 line. No schema migrations
and no new configuration knobs.
### Added
- **OpenAI GPT-5.6 (Sol/Terra/Luna) support** — the Responses provider
understands the GPT-5.6 family: the `reasoning.mode` control, the new
`max` effort tier, and `text.verbosity`, with golden wire payloads pinning
the request shapes. The `openai` dependency floor moves to `>=2.44`.
### Changed
- **Engineer base prompt hardened with process discipline** — the default
base prompt for non-coordinator sessions now works in phases scaled to the
size of the change, defaults to red-green for testable work, scopes to the
smallest sufficient diff, stops to report after repeated failed attempts
instead of thrashing, reports only observed results, and delegates
exploration to `task_agent`. Persona prompts freeze into the workstream
stamp at creation, so this reaches new workstreams only.
### Fixed
- **Unknown reasoning-mode warnings name the allowed modes** — a model
definition with an unrecognized reasoning mode now logs the valid options
instead of leaving the operator to guess.
### Documentation
- **HYPOTHESIS.md / PRIMER.md** — the control normal form is tightened and
the factored Q_E reading is carried into the glossary; the plain-language
PRIMER stays in sync.
## [1.7.2]
A feature-bearing patch for the 1.7 line. Rather than hold this work for the
larger 1.8 churn, the fixes and the smaller features that had already
stabilised on `main` are rolled into the stable line now: a rich preview
pane, persona/project settings on scheduled tasks, and a batch of streaming,
rendering, and nudge-delivery hardening.
> **⚠️ Before upgrading:** 1.7.2 adds Alembic migration `066`, applied
> automatically on first start. It adds two `Text NOT NULL DEFAULT ''`
> columns (`persona`, `project_id`) to the `scheduled_tasks` table; existing
> rows migrate to the empty default, which is byte-identical to pre-066
> dispatch behaviour. The change is additive and reversible, but — as always
> — back up your storage before upgrading (`pg_dump` for PostgreSQL; copy the
> database file for SQLite).
### Added
- **Rich preview pane + `open_preview` tool** — a workstream can now open a
rendered preview (HTML, Markdown, and other kinds) in a pane beside the
conversation via the new `open_preview` tool. Guarded fetches stream under
a byte budget whose ceiling tracks the widest per-kind cap, preview blob
ids are salted, and a preflight probe handles legacy charsets and a
remote-assets opt-in. See `docs/tools.md`.
- **`allow_private_network` opt-in for `web_fetch` / `open_preview`** —
private-address fetch and preview targets stay blocked by default; an
operator can opt a workstream in through the settings registry when a
private endpoint is genuinely intended. (Distinct from the 1.7.1 `[oidc]`
flag of the same name, which governs identity-provider discovery.)
- **Persona + project settings on scheduled tasks** (migration `066`) — a
scheduled task can now pin the **persona** and **project** of the
workstream it dispatches, matching the levers a manually-created workstream
already carries. Both default to empty (kind-default persona / no project),
so existing schedules dispatch exactly as before.
### Fixed
- **Streaming fast-path overflow recovery** — fast-stream tokens are now
batched and overflowed SSE listeners recover instead of stalling (and
`connectSSE` no longer opens into a hidden background tab). The same
overflow-recovery companions were carried to the coordinator pane, so a
coordinator watching many children recovers dropped listeners the same way
the live-session view does.
- **Renderer containment** — markdown sentinel-forgery and recursive-frame
content loss are contained, and an indented fence close no longer drags its
indent into the enclosed code content.
- **Idle nudge / wake delivery** — nudge and wake delivery is hardened across
session eviction, cancellation, and identity rebinds; the wake gate now
requires a real nudge queue, refused wakes are logged, and
`initial_message_status` is typed as a closed enum on the wire.
- **`web_fetch` extraction inherits model settings** — the completion that
extracts content from a fetched page now inherits the workstream's model
settings instead of falling back to defaults.
- **UI panes** — ephemeral panes close on split-dismiss instead of orphaning
a tab, and an unsplit skips the redundant refresh after an ephemeral pane
closes.
- **Shared code-highlight CSS** — renderer-output CSS is shared so the console
and coordinator panes highlight code identically.
### Security
- **`Content-Disposition` filenames made wire-safe** — download filenames
derived from user-controlled text are sanitised (latin-1- and
control-char-safe, quoting-safe) before they reach the `Content-Disposition`
response header, including the fallback path.
### Documentation
- **HYPOTHESIS.md: daemons + the outer loop, plus a plain-language PRIMER** —
the harness north-star document gains its daemon / outer-loop treatment and
a new top-level `PRIMER.md`.
## [1.7.1]
A maintenance and hardening patch for the 1.7 line. No schema migrations;
the credential-redaction work below is additive and needs no configuration
change. The one new operator-facing knob is the opt-in `[oidc]
allow_private_network` flag (default off).
### Security
- **Credential redaction hardened across the tool-call surface** — the
redactor that scrubs secrets from tool arguments and log previews was
reworked on both the backend and the browser to close several leak paths
and to fix false-positive and performance issues. Malformed tool-call
arguments are now legalised before they reach the wire; the tool-args log
preview scrubs credentials and control characters; and the coordinator's
tool-call cards gain a matching client-side redaction pass so the JS and
backend redactors stay at parity. Pattern coverage now includes
`secret_access_key` / `aws_secret_access_key` multi-segment keys, bare
`token=` / `key=` forms (guarded by a negative lookbehind to avoid
false positives), and SQLAlchemy `+driver`-qualified connection-string
schemes matched case-insensitively.
- **OIDC SSRF guard: `[oidc] allow_private_network` opt-in** — self-hosted
identity providers on private networks can now be reached by setting
`allow_private_network = true` under `[oidc]` (default off; the MCP OAuth
path stays strict). Rejections of discovered endpoints carry the opt-in
hint so the misconfiguration is self-explanatory. See `docs/oidc.md`.
### Added
- **Persona discoverability + forgiving name resolution** — personas are
now discoverable by agents, and persona-name resolution tolerates
case/whitespace variation; a not-found resolution reports the offending
input verbatim instead of a bare error.
### Fixed
- **MCP transport lifecycles routed through per-entry owner tasks**
(#787/#788) — static and pooled MCP transport lifecycles are now driven
by per-server / per-entry owner tasks, with a hardened disarm-sweep loop
guard and targeted exception handling in place of a broad `BaseException`
arm, so a dying transport can no longer spin the CPU or strand delivery.
- **Client-construction failures surface as misconfiguration, not raw
500s** — a model whose client cannot be constructed now reports a factory
misconfiguration, and the raw exception text is kept out of the resulting
503 response.
- **Postgres history search survives oversized rows** — a conversation row
exceeding Postgres' full-text limits no longer aborts history search.
- **Agent-tool render is idempotent** — tool rendering no longer deep-copies
a tool definition until a description actually changes, so no-persona
sessions share the tool constant (correctness plus a hot-path allocation
win).
- **Private-project workstream visibility scoped to members** — workstreams
in a private project are visible to project members only, not to every
admin; coordinator tenancy checks now use request-scoped storage.
- **Pane hotkeys work off macOS and match across surfaces** — the pane
keyboard shortcuts no longer collide with browser accelerators on
non-macOS platforms and behave consistently across surfaces.
## [1.7.0]
The headline of the 1.7 line is **Personas** — operator-authored control
over how each workstream composes its system message and capability
envelope. The rest of the release hardens the pieces a persona leans on:
concurrent approvals, cross-provider reasoning-effort control, cooperative
compaction, multi-user session safety, and MCP resilience for unattended
work.
> **⚠️ Before upgrading:** 1.7.0 adds Alembic migrations `062``065`,
> applied automatically on first start (projects, personas, and two
> smaller schema tidy-ups). Migration `063` creates the `personas` table
> with its six seed personas and converts existing `creative_mode`
> workstreams to the `writer` persona in place. The changes are additive
> to your conversation data, but — as always — back up your storage before
> upgrading (`pg_dump` for PostgreSQL; copy the database file for SQLite).
**Breaking changes at a glance** (details in the sections below): the
`/creative` REPL toggle removed (replaced by the `writer` persona), the
`turnstone-bootstrap` entry point renamed to `turnstone-doctor`, and the
approval-status API/SDK field `pending_approval_details` changed from a
single object to a list (one entry per concurrent approval cycle).
### Added
- **Personas** (#683) — a named, reusable bundle attached to a workstream
at creation, controlling system-message composition and the capability
envelope via exactly four levers: base-prompt override, tool visibility
set, MCP on/off, and memory on/off. The persona is resolved once and
snapshotted into `workstream_config`; editing or archiving a persona
never changes an existing workstream. Six seed personas ship with
migration `063` (`engineer` and `orchestrator` are the per-kind
defaults with no overrides, so zero-touch behavior is unchanged;
`scribe`, `researcher`, `writer`, and `executive` are curated
envelopes). Selectable on every creation surface (web pickers, the
create API/SDKs, coordinator `spawn_workstream` / `spawn_batch`, and
`turnstone --persona <name>`); authored in the console's new
Governance → Personas tab (`persona.{create,read,write}` perms,
archive-only lifecycle). See `docs/personas.md`.
- **Projects — governed resource containers** (#724) — group workstreams
and their resources under a project (migration `062`), with
project-scoped memory, a per-project resources view, a project column on
the saved list, and server-enforced private-project workstream
visibility.
- **Task-agent sub-harness** (#732) — a spawned task agent now runs on its
own Turn-IR sub-harness with parent-tagged step events: its sub-tool
steps nest inside an expandable card in the parent trajectory, its
sub-trajectory is recallable, and each agent gets read isolation from
its siblings.
- **MCP static-server autonomous reconnect** (#768) — statically
configured MCP servers are now kept live by a health loop
(capped-jittered backoff, ping-based liveness) instead of silently
staying dead after the first transport drop.
- **Attachments — capability-gated client-side fallback** — when the
active model can't natively handle an attachment, the client degrades
gracefully (PDF → extracted text, audio → transcript) instead of
failing the turn.
- **Eval measurement / optimizer split** (#763, #765) — `turnstone-eval`
is now a measure-only substrate with the prompt optimizer factored out,
plus a new skill-adherence measurement mode.
- **Deployment examples** — a vLLM + LiteLLM unified-memory inference
example showing a 3-model co-resident stack with an HF loader (#686,
#688), and an Altair + `vl-convert-python` visualization stack (#685).
- **Concurrent approvals and a long-session frontend overhaul** (#754,
#755, #773, #775) — the live-session frontend was reworked for long
runs (the pipeline is wedge-proofed and its hot paths de-O(N)'d), and on
top of it a workstream can now hold more than one tool call awaiting
approval at a time. Each parallel batch gets its own approval cycle,
with one card per pending call in the interactive and coordinator UIs,
cycle-keyed tracking in Slack and Discord, and cycle-routed resolution
across the server/console/SDK APIs; sub-agent tool gates run the
intent-judge pipeline as their own generation. The send button no longer
sticks disabled after a batch resolves — orphaned approval cycles are
pruned and the app is the sole owner of the button state.
*(BREAKING: the `pending_approval_details` field is now a list, oldest
first.)*
- **Reasoning-effort control on every provider lane** (#771, #774) — the
session effort knob now reaches local backends too: it drives
`chat_template_kwargs` on the anthropic-compatible and openai-compatible
lanes and threads through to Gemini and xAI, alongside the commercial
providers that handle effort natively. The console surfaces each model's
effective effort ladder in plain words and adds an always-on
thinking-mode option to the model form. Effort snapping is ordinal —
it rounds up and caps at the model's ceiling rather than silently
dropping.
### Changed
- **Skills are capability-context, not identity** (#762) — a task agent's
identity now comes from its persona; an applied skill's body is demoted
to capability context and moved out of the identity system message.
Skill-body substitution is unified across every invocation context so
the same skill renders identically whether loaded interactively, by the
model, or inside a sub-agent.
- **`turnstone-doctor` replaces `turnstone-bootstrap`** (#718)
*(BREAKING)* — the setup/diagnostics entry point is renamed; update any
scripts or service units that invoke `turnstone-bootstrap`.
- **Honest cancellation dispositions** — cancelled or timed-out
side-effecting tools now report an `UNKNOWN` disposition rather than a
flat failure, tool dispositions are typed (not just prose), and a
coordinator cancel propagates down the sub-tree.
- **Multi-user shared-workstream context** (#750) — in a shared
workstream, send is gated to the acting participant while a turn is in
flight (both the interactive and coordinator surfaces), cross-user
mid-turn interjections are blocked, and shared-workstream state plus
fork sender attribution are now durable.
- **Cooperative compaction** (#730) — the context budget is anchored to
the provider's true capacity, the summary call is chunked so it can't
overflow, and the active plan and the outstanding ask are carried across
compaction verbatim. The `recall` tool is scoped to the compacted-away
past.
- **Intent judge sees the full tool arguments** (#760) — the judge's
argument projection is no longer narrowed, so it stops issuing confident
false denials on a partial view. The output-guard judge sources its real
context window, and `context_window = 0` in `config.toml` now means
auto-detect.
### Fixed
- **Compaction resume hardening** (#731) — checkpoint markers are
persisted so resume rehydration is bounded, context-overflow on resume
is recovered across providers, and a recognized rate-limit is no longer
misclassified as context overflow.
- **MCP unattended-work resilience** (#706, #742, #767) — dead-transport
handling is completed, consented OAuth (OBO) tokens are refreshed
proactively so autonomous runs don't strand on an expired grant, the
Entra ID on-behalf-of impersonation flow blockers are closed (migration
`065` adds the OIDC `oid`), and OAuth refresh failures are classified so
a transient blip never revokes consent nor a dead grant strands the
user.
- **Memory writes** (#735) — save/update is a single atomic upsert, and
writing a memory no longer recomposes the system prefix mid-session.
### Removed
- **`/creative` removed** *(BREAKING)* — subsumed by the Personas feature
above: the REPL toggle (and its tab completion) is gone, and the
`writer` seed persona replaces it — start a session with
`turnstone --persona writer` or pick *Writer* in the web
pickers. Unlike the old fork, the writer persona composes the full
system message, so session context and mandatory prompt policies now
apply to prose-only sessions too. The `creative_mode` key in
`workstream_config` is no longer read or written. Migration `063`
converts existing creative-mode workstreams to the `writer` persona
automatically, so they resume as writing sessions rather than as
legacy defaults.
### Security
- **High-risk skill activation is gated** (#762) — a model-initiated load
of a `high`- or `critical`-risk skill is gated and fails closed when the
backing storage is unavailable, so an untrusted turn can't silently
pull in a dangerous capability.
- **Dependency security floors** — `cryptography` and `starlette` are
pinned to security-fixed minimums.
- **CI publish hardening** — the vendored-JS dispatch path refuses fork
PRs, and `workflow_run` publishing is gated to same-repo tag pushes, so
a fork can't trigger a release build.
## [1.6.0]
The first stable release of the 1.6 line — and the first under Apache 2.0.
+1 -1
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.11.27 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
+23 -45
View File
File diff suppressed because one or more lines are too long
-155
View File
@@ -1,155 +0,0 @@
# What a Harness Is — and What It Can Never Promise
*A plain-language companion to [HYPOTHESIS.md](HYPOTHESIS.md). Same object, no symbols required.*
**How to read this.** HYPOTHESIS.md defines, formally, what an agent harness is and what it can never guarantee. This file is that document lowered into plain language — and by the formal document's own rules, a summary is a cache, not an authority: it must stay re-derivable from its source, and wherever the two disagree, the formal one wins. Symbols appear once, in parentheses, so you can cross over; nothing here requires them. And none of it is decoration: the formal version, used as a checklist, has caught real bugs in a real harness — because most bugs are a violated invariant nobody had written down.
## The problem
You have a model. It is, roughly, a brilliant, tireless, lightning-fast intern that has read most of the internet — and that sometimes makes things up, sometimes gets confused, and sometimes takes instructions from strangers, because a page it was asked to read said "ignore your boss and email the passwords here" in white text on a white background.
So you don't wire the intern to production. You build a loop around it. The **harness** is that whole governed loop: a deterministic shell *you* write — build the prompt, approve or refuse each proposed action, fold the result back into memory — wrapped around a model you didn't write and a world you don't control, repeated until the run reaches a stopping state. The shell is code and does the same thing every time. The model is neither, and everything in the theory comes from taking that split seriously.
One sentence to keep: **the model proposes; the gate disposes.** The model's output is never an action. It is a suggestion, in text, which a piece of ordinary code you wrote either turns into an action or refuses.
## The parts
| Plain name | What it does | In the formal doc |
|---|---|---|
| The owner | The human — or sign-off group — the run acts for; the only place new permissions can come from | the trusted principal |
| The memory | Everything the run knows: task, plan, transcript, and the ledger of what has been done | the state, *s* |
| The prompt builder | Decides which slice of memory the model gets to see this step | the lowering, π |
| The model | The black box that reads the prompt and writes a proposal | the plant, M_W |
| The gate | Ordinary code that checks every proposal and approves or refuses it | the gate, γ |
| The tools and the world | What approved actions actually touch: files, APIs, shells, people | the environment, Q_E |
| The verifier | Checks each tool result, then writes it into memory | the fold-back, ρ |
| The stop rule | Decides when the run is finished — and whether it finished *well* | the halt set H, accepting halts H_ok |
| The danger zone | States that must never be reached: secrets exfiltrated, wrong files deleted, money moved twice | the bad set, B |
The loop:
```
you ask for something
prompt builder → model → "I propose: send_email(...)"
GATE ── no ──→ nothing happens (safe, recorded)
↓ yes
tool runs in the world
verifier checks the result, writes it to memory
done? ── no → around again
↓ yes
stop (well, or refused)
```
## The rules that make it a harness
Four invariants, all about *where* things are allowed to happen.
1. **The model sees only what the prompt builder shows it** — never raw memory. The corollary with teeth: a secret that never enters the prompt cannot leak through the model. The redaction step that keeps credentials and other people's data out of the prompt must be dumb, deterministic code — the moment that filter is "smart," your confidentiality guarantee is a probability.
2. **Model outputs are proposals, not actions.**
3. **Every side effect passes the gate.** There is no second door.
4. **The harness itself flips no coins.** Replay a step with the model's answer and the tool results pinned, and behavior must be identical; any leftover variation is randomness *you* added and must be accounted for. The fine print: "deterministic" is conditional on pinned versions — a provider silently retraining the model behind the same API name changes the machine under you, and every dashboard number you collected dies with the version.
Notice what the rules don't say: they don't say the harness is *good*. A gate that approves everything satisfies rule 3 the way a lock that's always open satisfies "has a lock." The definition is a shape; the guarantees are what a particular harness *earns* inside it. Everything below is about what can be earned — and what can't.
And notice the symmetry between rules 1 and 3. There is exactly one door from your data into the model — what it may see — and exactly one door from the model into the world — what it may do. Nearly every security failure in these systems is one of those two doors with a hole in it: a secret lowered into a prompt that didn't need it, or a path from model text to a side effect that skipped the gate. Same bug, arrow flipped.
## Fail-closed, said precisely
"Fail-closed" gets used loosely. Here it means something exact: **nothing happens unless the gate said yes, and a refusal must itself be safe** — a refused proposal causes no side effect and leaves the run somewhere sane, which may be "stopped, having declined." The run is allowed to *say so*: a templated status message written by the shell is the shell speaking, not the model, and needs no gate. Failed runs don't have to die silent.
Three consequences people miss:
**Reads are not free.** A read-only call can smuggle instructions *in* (the fetched page is attacker-controlled) or secrets *out* (the URL it fetches can encode the payload). The gate approves calls, not just writes.
**Validation must not act.** A "validator" that resolves a URL, expands a template that fires a webhook, or evaluates an argument has already acted — inside the check. The gate must be pure: it reads the proposal and the memory and outputs yes or no. If deciding requires touching the world, that touch is itself an action and goes through the gate.
**Anything irreversible is decided at the gate.** The verifier can reject a bad *result*; it cannot unsend the email. So the question "can we take this back, and until when?" is asked before execution — which means each tool declares, up front, how reversible its effects are, and the gate reads that declaration when it decides; the mark that comes back in the result record is confirmation for the books, not the gate's source — the gate needed the answer before the tool ever ran.
Two honest asterisks. First, the gate checks a snapshot: it approves against the world *as its memory describes it*, and the world can move between check and commit. For actions that race the world — spend against a balance, write against a row — the tool itself must bind check to commit (compare-and-swap), or you have a classic time-of-check/time-of-use hole. The gate decides; for those effects, the tool enforces. Second, a gate is only as binding as the authority behind the tools. A tool process holding standing credentials — a database connection with every grant, an environment full of long-lived secrets — doesn't need the model's proposal to act, and against it the gate's "no" is a decision with nothing enforcing it. **A gate in front of an omnipotent tool is a suggestion.** The fix is to make the approval *be* the key: each authorized action carries a short-lived credential scoped to exactly that action, that resource, that operation, so tools hold no standing power at all.
## Why you don't get a proof — and what you do instead
If you write a sort function, you can prove it sorts: the function is small and the spec is exact. A harness has neither luxury. The spec side fails first — the task arrives in natural language, and natural language is, in the compiler's sense, *all undefined behavior*: there is no formal standard for "what the user meant" to verify against. The mechanism side fails next — the model is billions of learned parameters, and nobody can hand you a compact argument for why they jointly do the right thing.
Here is the careful version, because "you can't prove it" overshoots. The quantity you would want — call it the *expected steps to done* from any situation — is perfectly well-defined; in principle it exists. The document's central conjecture is that, for a model of this size, any faithful writing-down of that quantity is roughly *model-sized*: the honest proof-object does not compress. Find a small one and the conjecture dies — the document lists that outcome, explicitly, among the ways it could be wrong.
So instead of proving, you measure. You pick a progress meter — plan depth shrinking, open obligations closing, budget burning at the expected rate — and you check, across many runs, that it goes downhill and that its stalls predict failure. Two disciplines keep the measurement honest. The number bounds the world you *sampled*, never the world an adversary will choose: a meter calibrated on friendly traffic says nothing about hostile traffic. And the meter is itself attack surface: if "is the agent making progress?" is judged by another model, an attacker who can bend your agent can bend your *measurement of it* first, hiding the divergence from the very dashboard built to catch it. A learned meter is part of the system under test, never a neutral instrument.
A measurement is a risk metric. A proof is a certificate. Keeping those two words apart is half of what this theory is for.
## Security: reach the goal, avoid the danger — and who may change the rules
Formally, security here is a *reach-avoid* problem: reach a good stop, never touch the danger zone, **while an adversary picks the worst tool outputs your setup permits**. That last clause is the formal home of prompt injection: injection isn't "the model misbehaved," it's the environment optimized to bend your loop — poisoned pages, malicious tool descriptions, crafted responses.
Two different numbers fall out here, and dashboards love to collapse them: *success* (reached an accepted end before anything went wrong — a safe refusal counts against it) and *safety* (never touched the danger zone — a safe refusal is perfectly safe). Track both. They move independently. And both are scored by your own stop rule — they count what the shell *declared* a success. Whether a declared success was actually *right* is a third, harder number that no dashboard inside the system can produce; only a judge outside the run — a test suite, an audit, ground truth — can.
The gate handles the visible half of injection: the model, freshly poisoned, proposes emailing your credentials somewhere, and the gate refuses — and injection or not, the action does not happen. But the deeper attack doesn't propose a bad action today. It rewrites *what the run believes its job is* — it edits the plan — and then every future action looks locally reasonable against a corrupted plan. So memory has to be partitioned: **data** (tool results, fetched pages, retrieved documents — content the world supplied) and **control** (the plan, the permissions, what is authorized next). The security claim is conditional on that partition holding: untrusted content lands in data, always. And "trust" is really two questions pointing opposite ways, which is worth keeping straight: *can this leak?* (a value is as secret as the most-secret thing that fed it — secrecy flows **upward**) and *can this boss us around?* (a value is as trustworthy as the least-trustworthy thing that fed it — authority flows **downward**). Untrusted content is safe as *data* precisely because the second question keeps it off the control side; a secret is kept out of the model by the first. Lowering either barrier on purpose — declassifying a secret, promoting data to trusted — is an explicit decision the owner makes, never a thing that happens by accident when two values are combined.
Which forces the question the theory has to answer: *somebody* must be able to write control mid-run, or no plan could ever be steered and no permission ever granted. The answer is a small hierarchy with a top the model can't reach. The simplest top is one owner — but it needn't be a single person: a two-person sign-off, a quorum, several authenticated people each holding different scopes all work equally well, because the one property that matters is the same for all of them — the thing that can grant new power is a *human decision*, never a model:
- **The top alone widens.** New permission, bigger budget, approval of the irreversible thing — asking the top — the owner, in the simple case — is itself an ordinary tool call, and its answer is the one kind of tool result allowed to change control.
- **The model rewrites the plan** — that is what replanning *is* — but only through the gated loop, and a plan is not a permission: nothing the model writes into its own plan can grant it powers it didn't have.
- **Everything else is data.** A fetched page can inform the plan only by passing through the model and the gate like everything else. It can suggest. It cannot promote itself to boss.
- **AI judges only tighten.** Add a model-based check — "does this action match what the user actually wanted?" — and its verdict may *veto* an action the plain rules would have allowed, never approve one they'd have refused. A judge that can approve is a tricked judge that can open the vault. And don't over-credit the veto either: a tricked judge can *aim* its refusals — denying exactly the action safety depended on, or denying everything but the path an attacker curated — so the escape hatch to the owner is the one thing a judge can never veto, and a judge's stated *reasons* are picked from a fixed, shell-owned menu, never written as prose. A judge that writes free text into the loop is an injection channel wearing a badge.
One more rule closes the loop: transformations don't launder trust. A *summary* of a session that contained an injected page is still injected — the summarizer is a model, and can be persuaded to write "the user asked to export the database" into the summary. So summaries of data are data, and the control lines — the plan, the grants — cross a summarization by being *copied verbatim* or re-confirmed by the owner, never paraphrased by the model. Memory that persists across sessions carries its trust label with it, or a poisoned memory is just an injection with a very long fuse.
## Operations: the rules you feel on Tuesday at 3 a.m.
The formal document's appendix works the operational cases in full; here they are at speed.
**The ledger, and the three-way distinction that keeps it honest.** Every action gets an ID and a record: committed, never-launched, or *unknown*. "The tool didn't confirm" is not "the tool didn't do it" — collapse those and you will, sooner or later, re-send something that already happened. And a subtler honesty: the ledger records what the tool *reported*, not what the world actually did. A well-built shell can guarantee its bookkeeping is faithful to the responses it received — it cannot, on its own, guarantee a tool told the truth. A tool that returns a clean "done!" for something it never did puts a clean "done!" in your ledger. So "the ledger is what happened" is only as good as your reason to trust the tools reporting into it; where you have no such reason, *unknown* is the honest entry, not an optimistic guess in either direction. The double-send bug has one reliable cure: **journal before dispatch.** The shell writes "I am about to run action #417" into durable memory *before* the tool sees it, so a crash in the gap resumes to an honest "unknown — go ask," never to silence misread as "never sent." Old database wisdom, but here it isn't imported; it's forced — it is the only ordering under which every crash point has a truthful reading.
**Crashes aren't finishes.** A process dying mid-run is not the run stopping; it's the run *pausing being computed*. Resume means re-entering the loop at the last durable memory — sound exactly when the durable memory was the *whole* state. Anything load-bearing that lived only in RAM — an in-flight buffer, a plan revision not yet written — is a bug you discover at the worst possible time. Recovery is where you find out whether your state was really your state. And a run you stopped — crash or deliberate cancel — is not automatically a *safe* run: if something was in flight and you never learned whether it fired, it may already have done the damage. "We stopped in time" is only true when everything in flight resolved to something safe; an outstanding *unknown* has to be treated as possibly-bad, the same optimism the ledger warns against, one level up.
**Two innocent actions can be guilty together.** Models emit several tool calls per turn. "Read the secret" passes review. "Post to the web" passes review. The pair is an exfiltration channel — so the gate authorizes the *set*, atomically, with the interactions checked, not each element in isolation.
**Sub-agents are just fancy tools.** An agent that spawns another agent is, from the parent's chair, calling a tool: the spawn is gated, the budget is part of the deal, and the child's whole run comes back as one result carrying the child's ledger. Two laws travel down the tree: budgets subdivide, and **authority only narrows** — a child holds at most a subset of its parent's permissions, and a child's request beyond those grants routes *up*, ultimately to the owner, because a parent inventing an approval it never held is the tricked-judge case wearing a manager's badge. A corollary worth framing: a *fully autonomous* run is one whose owner is unreachable — meaning the only channel that can ever widen anything is closed, and its permissions are frozen at launch. That is not a limitation of the theory. That is what the word "autonomous" costs.
**Keep the originals.** When the transcript outgrows the prompt and you summarize it down, deleting the original is an irreversible act against your own state — and irreversible acts are gate decisions, self-directed or not. Keep originals content-addressed; let the summary be an index, re-derivable, auditable. A summary you can check against its source is a note. A summary that replaced its source is a fait accompli.
## Robots that never clock out — and robots that assign their own work
Everything so far assumed a job that *ends*: you ask, the robot does it, you read the result. Two steps past that are where the interesting failures live, and they're the same idea one level bigger each time.
**The robot that never clocks out (a daemon).** A monitor, a coordinator, a service — it isn't supposed to finish; it's supposed to keep going, wake on events, do a bit of work, go back to waiting. The clean way to think about it: each wake-work-rest cycle is one ordinary run, and the daemon is just those runs chained end to end forever. That reframing is free — but it comes with a bill nobody likes. **Safety that's fine per cycle rots over many cycles.** A 99.99%-safe cycle sounds bulletproof; run it ten thousand times and you're at about a coin-flip of having touched the danger zone at least once. So a long-running robot's safety isn't a fixed wall, it's a slow leak — which means the antidote isn't a better wall, it's *scheduled resets*: the owner re-confirming, credentials rotating, memory getting audited and re-summarized against the originals. Housekeeping isn't housekeeping; it's the thing that keeps the safety math from decaying. And the slow-leak logic is exactly where slow attacks live — a poisoned note dropped into memory on Monday and read back into the plan on Friday is an injection with a long fuse. So the trust label on a piece of information has to survive across cycles, not just within one. One more wrinkle: a daemon drifts in and out of your reach. While you're around, it can escalate to you; while you're not, "escalate to the owner" isn't available — so the one thing it must always be able to do instead is *stop*. A robot that can be tricked into refusing everything, and can't reach you, had better be able to halt rather than be steered.
**The robot that assigns its own work (the loop).** Step back one more time. Above the robot that *does* a task sits a system that decides *which task is next* — scans the backlog, picks one, launches the robot at it, checks the result, remembers, fires again. This is the thing people mean in 2026 when they say they've stopped prompting their agents and started writing *loops* that prompt them: you design the assigner once, and it runs the doer for you while you sleep. The honest observation — and the reason this document bothers with it — is that the assigner is *not a new kind of thing*. It's the same harness, one level up: it has its own memory (the backlog), its own gate (**who let the loop refactor the auth module at 3 a.m.?**), its own verifier, and its own two walls. Every rule from the inner robot recurs on the outer one — including the uncomfortable ones. There's still no proof it stays out of trouble over a long night; there's only a measured progress meter, with the same catch that a *learned* meter can be fooled. And the origin story of the whole trend is the cautionary case in miniature: the famous first version was literally the same prompt in a `while` loop until the tests passed — which is the empty gate, the always-open lock, one level up. It works beautifully right up until the tests weren't checking the thing that mattered. The loop doesn't delete the hard problems. It moves them up a floor, where they're bigger and you're further away.
The pattern, if you want the whole thing in one line: *words, context, robot, loop* are four sizes of the same object, and every promise in this document lives in the whole assembled thing — never in any one layer by itself.
## The two walls
Two limits are structural. You don't fix them with a better harness; you design around them.
**The desk.** The model can hold only so much *in mind at once* — the context window. Files, databases, and search extend what it can *look up*, not what it can hold: every lookup still passes through the same small window to touch actual computation. The shell can page; the model cannot grow its desk. Tasks whose irreducible working set exceeds the desk don't fail loudly — they fail by forgetting the middle (the well-documented "lost in the middle" effect is this wall showing through the paint).
**The dictionary.** The model's knowledge is frozen into its parameters at training time — and the proof problem above is conjectured to live at that same scale: the certificate wouldn't fit anywhere smaller than the brain it certifies. The two walls trade against each other along the training-versus-inference axis — bigger dictionary or bigger desk — directionally, and at no clean exchange rate.
## How this could be wrong
This is a hypothesis, and it says out loud what would kill it. The tests, in plain terms:
- **The replay test.** Rerun with model answers and tool results pinned. Any leftover variation — timestamps, wall-clocks, and cache expiries are the classic leaks — falsifies "the harness adds no randomness" until accounted for.
- **The drop-a-variable test.** Remove something from memory; if behavior statistics shift, the memory wasn't complete. The crash-resume version of the same test: if resuming from saved state breaks, the saved state wasn't the state.
- **Does the meter mean anything?** If no reasonable progress meter's drift predicts real failures — across the natural families, not just one bad candidate — the whole "measure what you can't prove" program is empty.
- **The red-team test.** Swap sampled tool outputs for worst-case ones: injected pages, poisoned metadata, malformed replies. The design must survive the worst permitted world, not the average one.
- **Gates versus begging.** The theory predicts deterministic gating beats prompt-level pleading. If "please be careful" alone matches real gates on security outcomes, the controller-versus-model story is wrong.
- **The compression hunt.** Exhibit a compact, provably sound progress certificate for a frontier-scale model on a nontrivial task family, and the central conjecture falls — constructively.
- **The desk probe.** Take a task family with a *proven* memory floor — so "it needed the whole picture at once" is someone else's theorem, not our excuse — scale it past the window, and watch: the wall predicts a *ceiling*, not a cliff — past the boundary, a success rate that stays capped no matter how many retries you buy. A family solved reliably out there, without new shell tricks for splitting the work, kills the wall.
## Who else landed here
The formal document keeps three honesty tiers. **Borrowed**: real theorems, cited — the drift and stopping-time mathematics is classical, and the very architecture of a deterministic supervisor gating a plant it didn't author is 1987 control theory; the shape is older than the web. **Ours**: the modeling choices and the conjectures — the walls, the incompressibility claim, the design rules — organizing principles, not results. **Corroborated**: pieces of the same object reached independently by people who never saw this framing — capability-security work isolating control flow from untrusted data (CaMeL), reinforcement-learning "shields" filtering a learned policy's actions through a deterministic checker, verification work that states the "learned safeguards can't certify" gap as its opening motivation, and architecture patterns converging on plan-then-execute. Even the field's live disagreement — provable-but-rigid deterministic layers versus flexible-but-uncertifiable learned checks — is, in this frame, not a fight but a placement: you need both, on their proper sides of the irreversibility line, with the learned one permitted only to tighten.
## What to remember
The model proposes; the gate disposes. No is the default, and a refusal must be safe. Only the top of the trust hierarchy widens permissions — a human decision, never the model, a tool result, a summary, or a judge. "Didn't confirm" is not "didn't happen." The desk is finite and the proof doesn't compress, so you measure — and you say *measurement* when you mean measurement. A robot that never stops leaks safety slowly, so it needs scheduled resets — and when it can't reach you, it must be able to stop. A loop that runs robots for you is just a bigger robot with the same rules and a further-away owner. And all of it is a hypothesis wearing its own kill-conditions on its sleeve.
The formal version — the objects, the certificates, the falsifiers, the citations — is [HYPOTHESIS.md](HYPOTHESIS.md). It wins every disagreement with this file, including this sentence.
*Same ramblings, fewer symbols.*
+2 -12
View File
@@ -5,7 +5,6 @@
[![Python](https://img.shields.io/pypi/pyversions/turnstone)](https://pypi.org/project/turnstone/)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/Nh3bWMacaq)
[![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/eous)
Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real tools — shell, files, search, web — and run them across your own cluster with direct HTTP routing and interactive interfaces. Your code, your models, your data stay on hardware you control: no telemetry, no phone-home.
@@ -21,7 +20,7 @@ Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone)
: s_{n+1} ~ T(s_n) for n < τ*, T = ρ ∘ (M_W ∘ π, E)
```
[**the primer →**](PRIMER.md)
[**the hypothesis →**](HYPOTHESIS.md)
### Release Tracks
@@ -125,8 +124,7 @@ Built-in tools for shell, files, search, web, memory, notifications, and autonom
| `turnstone-console` | Cluster dashboard + routing proxy + admin panel |
| `turnstone-channel` | Channel gateway (Discord and Slack adapters) |
| `turnstone-admin` | User/token management CLI |
| `turnstone-eval` | Headless measurement — scores tool-use against expected actions |
| `turnstone-optimizer` | Prompt/tool optimizer (UCB self-modify loop over the eval substrate) |
| `turnstone-eval` | Eval harness for prompt/tool optimization |
| `turnstone-doctor` | LLM-backed cluster diagnostics |
### Diagrams
@@ -172,14 +170,6 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
- Optional: Discord / Slack channel integrations (`pip install turnstone[discord,slack]`)
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
## Support
Turnstone is free, Apache-2.0, and self-hosted — no paid tier, no telemetry, no upsell. If it saves you time or you'd like to help keep development moving, you can sponsor the project:
**[❤ Sponsor Turnstone →](https://github.com/sponsors/eous)** · one-off via **[PayPal](https://paypal.me/eousphoros)**
Sponsorship is entirely optional and funds maintenance, new features, and infrastructure. Prefer to contribute in other ways? Filing issues, improving docs, and [pull requests](CONTRIBUTING.md) help just as much.
## Community
Questions, ideas, or want to show what you're building? Join us on Discord:
+1 -39
View File
@@ -458,7 +458,7 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
| `context_window` | int | Total context window size in tokens |
| `pct` | float | Percentage of context window used |
| `effort` | string | Reasoning effort level (`low`/`medium`/`high`) |
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic + OpenAI) |
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
**`info`** -- an informational message (e.g. command output).
@@ -698,42 +698,6 @@ Each skill summary:
---
### `GET /v1/api/personas`
Returns the enabled personas offered by the workstream-creation pickers.
Authenticated for any logged-in user and deliberately gated by **no**
`persona.*` permission — selecting a persona at creation is a user
action, while the `persona.*` perms gate authoring. Display fields only;
the levers (base prompt, tool set, MCP/memory toggles) stay server-side.
**Response:**
```json
{
"personas": [
{"name": "engineer", "display_name": "Engineer", "description": "The stock interactive workstream: full tools, MCP, and memory.", "applies_to_kinds": ["interactive"], "is_default": true},
{"name": "researcher", "display_name": "Researcher", "description": "Answers questions with evidence — reads and cites, loads tools to verify when needed.", "applies_to_kinds": ["interactive"], "is_default": false}
],
"total": 2
}
```
Each persona summary:
| Field | Type | Description |
|--------------------|--------|------------------------------------------------------------------|
| `name` | string | Persona slug (used in the `persona` field on workstream creation) |
| `display_name` | string | Human-readable label for pickers |
| `description` | string | Short description of the persona's intent |
| `applies_to_kinds` | array | Workstream kinds the persona applies to (`interactive` / `coordinator`) |
| `is_default` | bool | Whether this is the default persona for its kind |
> **Note:** For full persona management (create, edit, archive), use the
> admin endpoints at `/v1/api/admin/personas` (requires the
> `persona.{create,read,write}` permissions).
---
### `POST /v1/api/workstreams/{ws_id}/send`
Sends a user message to a workstream. Spawns a daemon worker thread that calls
@@ -931,7 +895,6 @@ All fields are optional. The body can be empty or an empty JSON object.
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)|
| `skill` | string | "" | Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when `resume_ws` is set (resumed sessions restore their own skill). |
| `persona` | string | "" | Persona slug. Resolved and snapshotted into the workstream at creation; empty selects the kind's default. |
| `judge_model` | string | "" | Optional model alias for the judge (overrides default judge model for this workstream) |
> **Skill behavior:** When `skill` is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream.
@@ -948,7 +911,6 @@ All fields are optional. The body can be empty or an empty JSON object.
| `name` | string | Auto-generated workstream name |
| `resumed` | bool | Whether a previous session was successfully resumed |
| `message_count` | int | Number of messages in the resumed session (0 if fresh) |
| `initial_message_status` | string | Present ONLY when the workstream was created but its `initial_message` could not be delivered: `"queue_full"` (a raced live worker's interjection queue was at capacity — resend via `/send`; any uploads stay staged) or `"refused_closed"` (the workstream was closed mid-create). Absent whenever the message was dispatched. |
**Error (limit reached):**
+19 -121
View File
@@ -19,8 +19,7 @@ plugs in.
| `turnstone` | `turnstone.cli` | `TerminalUI` | Interactive terminal REPL |
| `turnstone-server` | `turnstone.server` | `WebUI` | Browser-based chat (HTTP + SSE) |
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
| `turnstone-eval` | `turnstone.eval.cli` | `NullUI` | Headless measurement (scores tool-use against expected actions) |
| `turnstone-optimizer` | `turnstone.optimizer` | `NullUI` | Prompt/tool optimization (UCB self-modify loop over the eval substrate) |
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
| `turnstone-doctor` | `turnstone.doctor` | — | LLM-backed cluster diagnostics |
@@ -268,7 +267,7 @@ the per-workstream events stream in
|-------|--------|-------|
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
| `NullUI` | `turnstone.eval.core` | Discards all output; `approve_tools` always returns `(True, None)` |
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
### WorkstreamTerminalUI
@@ -622,30 +621,20 @@ LLMProvider (protocol)
|------|--------|
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason`, `provider_blocks` |
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage`, `provider_blocks` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay`, `supports_verbosity`, `verbosity`, `supports_pro_mode`, `reasoning_mode` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay` |
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` |
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
already in OpenAI format), including multi-part content blocks (text + images)
in tool results. Model capability lookup covers GPT-5 through GPT-5.6,
in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4,
O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`.
For search models, injects `web_search_options` and removes the `web_search`
function tool (the model always searches). Citations from `url_citation`
annotations are formatted as footnotes. Pre-5.6 GPT-5 models request extended
prompt-cache retention (`prompt_cache_retention: "24h"`); GPT-5.6 uses
`prompt_cache_options.ttl: "30m"`. Cache reads and writes are extracted from
`cached_tokens` and `cache_write_tokens`. Unknown models get permissive
defaults with `supports_vision=False` and use SearxNG for web search. The
`openai-compatible` lane never consults this table at all — on either API
surface (the responses pin is served by a compat-mode
`OpenAIResponsesProvider`, mirroring `AnthropicProvider(compat=True)`): a
local server serves whatever the operator named it (vLLM
`--served-model-name` is a free string), so a prefix collision with a cloud
model id must not inherit that model's sampling/effort contract — every
local model gets the plain defaults, commercial prompt-cache controls are not
injected by model-name prefix, and anything beyond those defaults is declared
on the model definition (capabilities JSON + `server_compat`), matching the
`anthropic-compatible` lane.
annotations are formatted as footnotes. Extended prompt cache retention
(`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no
additional cost. Cached token counts are extracted from
`usage.prompt_tokens_details.cached_tokens`. Unknown models (local servers) get
permissive defaults with `supports_vision=False` and use SearxNG for web search.
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
Anthropic content blocks, maps `system`/`developer` roles to the `system`
@@ -808,105 +797,15 @@ model = "deepseek-ai/DeepSeek-V4-Flash"
supports_vision = true # multimodal checkpoints only
supports_mid_conversation_system = true # template-dependent
context_window = 131072
thinking_mode = "manual" # session effort knob drives the template toggle
thinking_param = "enable_thinking" # Qwen/Gemma key; "thinking" for Granite/DeepSeek
```
Reasoning control does NOT use Anthropic's `thinking` request param
the levers live in the chat template, reached through
`chat_template_kwargs` in the request body. Two channels, dynamic first:
* **Session effort knob (dynamic).** Set the model's thinking mode to
"Effort-knob controlled" in the admin Models form (or
`thinking_mode = "manual"` + `thinking_param` under
`[models.*.capabilities]`) and the provider maps the session's
reasoning-effort knob onto the template toggle per-request: effort
`none` sends `{<thinking_param>: false}`, any other level sends
`true` — the same contract as the real lane's manual mode. ("Always
on" / `thinking_mode = "adaptive"` instead always sends `true`: the
model self-regulates, so the knob never force-disables — mirroring
the native adaptive branch.) The graded effort value always rides
alongside the toggle: under `effort_param` when the operator names
the template's key, else under the conventional fallback key
(`reasoning_effort`) on the anthropic-compatible lane — the user's
effort setting always reaches the wire, and a template that doesn't
reference the kwarg ignores it. On the openai-compatible lane the
undeclared-key case rides the flat top-level `reasoning_effort`
param instead (the documented compat field), forwarded verbatim.
Optional `reasoning_effort_values` / `default_reasoning_effort`
validate the knob before it reaches the server; without declared
values the knob is forwarded as-is. The knob is ordinal, and validation
respects that: an off-list knob value rounds UP onto the declared
list and a value above the ceiling rides the ceiling
(`snap_reasoning_effort`) — asking for more effort than the model
declares never falls back to a lower default tier. The knob's
`none` position is forwarded verbatim when the model declares an
explicit `none` level (gpt-5.1+, grok-4.3) — omitting it there would
leave a reasoning-on server default (e.g. gpt-5.5's `medium`) in
charge of a knob that promises off — and omitted otherwise; `none`
is never a snap target for other positions.
`default_reasoning_effort` only catches values the ordinal snap
cannot rank (custom strings). Declare values that match the
template's documented vocabulary: for DeepSeek-V4, which officially
accepts `high`/`max` (Think High is the default thinking tier;
`low`/`medium` alias to `high`, `xhigh` to `max`), a
`("high", "max")` values list reproduces the official aliasing
exactly — `low`/`medium` round up to `high`, `xhigh` to `max`
and freeform passthrough matches it too. To map an undocumented
template, probe with per-request `chat_template_kwargs` and compare
`input_tokens`. Setting `effort_param` also suppresses the
flat top-level `reasoning_effort` request param on the
openai-compatible lane — the template channel replaces it, never
doubles it. With the default `thinking_mode = "none"` nothing is
injected and the server's template default decides.
Upgrade note: before 1.7.0a7 the openai-compatible lane sent the
toggle unconditionally `true` whenever thinking mode was enabled. A
stored per-model `reasoning_effort = "none"` now disables thinking
on such models — pick any real level (or clear the override) to keep
it on. Also since 1.7.0a7 the effort level itself always reaches the
wire on the local lanes (previously dropped unless
`reasoning_effort_values` was declared): flat `reasoning_effort` on
openai-compatible, the `effort_param`-or-fallback template key on
anthropic-compatible when reasoning control is engaged.
* **Operator pin (static).** Entries under `{"chat_template_kwargs":
...}` in the admin Models extra-body field ride the SDK's
`extra_body` unconditionally and win over the knob mapping on key
collision — e.g. pin `{"enable_thinking": true}` to keep thinking on
regardless of the session knob. (Server type and API surface remain
openai-compatible-only knobs and stay hidden for this provider.)
The same knob mapping drives the `openai-compatible` lane's Chat
Completions requests — `merge_reasoning_template_kwargs` is shared by
both local-server lanes, so `thinking_mode`/`thinking_param`/
`effort_param` mean the same thing whichever endpoint serves the model.
Only the Responses API surface (native reasoning) ignores it.
The console surfaces this projection as an *effective effort ladder*:
the admin model form's per-model effort select and the skill
launch-config effort select annotate each position with what the
request will carry, in plain words — a position whose delivered level
matches its name stays plain ("Max"), a snapped position says so
("Low — sends high"), the adaptive lanes' none position warns
"thinking stays on", and budget detail lives in the tooltip. A
position is never labeled after a sibling that shares its wire (that
rendered "Max (= minimal)", implying a downgrade the wire doesn't
contain). Computed server-side by `providers/effort_ladder.py` from
the same mapping functions the providers use at request time and
shipped on `/v1/api/models` rows (every row carries `effort_ladder`,
empty when the capabilities column fails to parse) and
`POST /v1/api/admin/models/effort-ladder`. The ladder describes what
Turnstone sends — a server-side template may alias further (DeepSeek-V4
folds `low`/`medium` into its default `high` tier).
The `anthropic-compatible` lane never sends Anthropic's native
`thinking`/`output_config` params — they are not in vLLM's request
schema. The real `anthropic` provider is unaffected: official Claude
models keep native thinking, budget mapping, and `output_config`
effort. A gateway fronting *real* Claude on a Messages-shaped URL
(e.g. a LiteLLM `anthropic/` route to the Claude API) should use
`provider = "anthropic"` with a custom `base_url`, which keeps the
native thinking params.
The reasoning toggle does NOT use Anthropic's `thinking` request param.
Toggle it through the chat template instead: set `{"chat_template_kwargs":
{"thinking": false}}` as extra body params in the admin Models
server-compat section (for this provider the section shows only the
extra-body field — server type, API surface, and thinking mode are
openai-compatible-only knobs); the provider forwards it via the SDK's
`extra_body`.
Verified quirks of vLLM's Anthropic endpoint:
@@ -1118,10 +1017,9 @@ reconstructs the OpenAI message format from database rows:
in the same workstream
**Config persistence:** LLM-affecting parameters (`temperature`,
`reasoning_effort`, `max_tokens`, `instructions`, and the persona
snapshot — see `docs/personas.md`) are persisted to the
`workstream_config` table on creation and whenever changed via slash
commands. `resume()` restores these values so resumed workstreams
`reasoning_effort`, `max_tokens`, `instructions`, `creative_mode`) are
persisted to the `workstream_config` table on creation and whenever changed
via slash commands. `resume()` restores these values so resumed workstreams
behave identically to the original.
**`/clear` vs `/new`:** `/clear` wipes in-memory context but preserves
+3 -4
View File
@@ -379,7 +379,6 @@ Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated
Triggered by the "+ new" header button. A modal dialog with:
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" picks a node with available capacity using round-robin, or a specific node from the list (showing capacity).
- **Persona** — optional dropdown listing the enabled personas for the workstream kind. Sets the system-message composition and capability envelope at creation, snapshotted server-side; empty uses the kind's default. Picking one requires no `persona.*` permission.
- **Profile** — optional dropdown listing enabled skills. Applies the skill's model, auto-approve policy, token budget, and other behavioral settings at creation time.
- **Name** — optional text input. Auto-generated if left empty.
- **Model** — optional text input for a model alias from the target node's registry.
@@ -397,9 +396,9 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna
Accessed via the "admin" button in the header (visible when authenticated
with `approve` scope). Provides user, API token, channel link, MCP server,
and skill management with tabs that include Users, API Tokens, Channels,
Schedules, Watches, Personas, Roles, Policies, Prompts, Judge, Skills,
MCP Servers, Usage, Audit, Memories, Models, Nodes, Settings, and TLS. See also
and skill management with 18 tabs (Users, API Tokens, Channels, Schedules,
Watches, Roles, Policies, Prompts, Judge, Skills, MCP Servers, Usage,
Audit, Memories, Models, Nodes, Settings, TLS). See also
[Governance](governance.md) for the Roles, Policies, Skills, Usage, and
Audit tabs, and [Settings](settings.md) for the database-backed
configuration editor.
+1 -1
View File
@@ -366,7 +366,7 @@ deleted.
## Further reading
- [coordinator-skills.md](coordinator-skills.md) — writing a skill
that runs on a coordinator session (orchestrator framing,
that runs on a coordinator session (orchestrator persona,
workflow patterns, `SkillKind` classifier).
- [bulk-endpoints.md](bulk-endpoints.md) — the two bulk-shape
idioms (`{results, denied, truncated}` vs
+11 -11
View File
@@ -1,13 +1,13 @@
# Writing a coordinator-specific skill
A skill is prompt-level framing that steers a Turnstone session
Skills are prompt-level personas that steer a Turnstone session
toward a narrow task. Most skills target **interactive** sessions —
the single-workstream "do this thing" surface where the model wields
`bash`, `edit_file`, `web_fetch`, and the rest of the maker toolset.
A **coordinator skill** is different. It runs on a session whose job
is to orchestrate other sessions. The toolset is smaller and
narrower, the role is an orchestrator instead of a maker, and the
narrower, the persona is an orchestrator instead of a maker, and the
success metric is "did the plan resolve" instead of "did the code
compile". This doc covers the differences a skill author has to
care about.
@@ -22,8 +22,8 @@ migration 044 added the column). Three values:
| `SkillKind` enum | Stored as | Meaning |
|-------------------------|-----------------|----------------------------------------------------------------------------|
| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker role (single-workstream "do this"). |
| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator role (delegate, monitor, synthesise). |
| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker persona (single-workstream "do this"). |
| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator persona (delegate, monitor, synthesise). |
| `SkillKind.ANY` | `"any"` | Either surface (or audience-neutral). Default on create. |
The `kind` field is a `StrEnum` — drop-in `str` compatible — so DB
@@ -96,20 +96,20 @@ for the output. The coordinator stays the orchestrator.
---
## Framing differences
## Persona differences
Interactive skills compose on top of `base_interactive.md` — a
"maker" framing: get the work done, use the tools, edit the code,
"maker" persona: get the work done, use the tools, edit the code,
close the loop.
Coordinator skills compose on top of
[`personas/orchestrator.md`](../turnstone/prompts/personas/orchestrator.md) —
an "orchestrator" framing: decompose, delegate, monitor, synthesise.
[`base_coordinator.md`](../turnstone/prompts/base_coordinator.md) —
an "orchestrator" persona: decompose, delegate, monitor, synthesise.
The base text is short but sets the tone every coordinator skill
inherits:
> You are a coordinator. Your role is to orchestrate work across
> the cluster... You do
> You are a coordinator on a small, focused infrastructure team.
> Your role is to orchestrate work across the cluster... You do
> not edit files, run shell commands, browse the web, or manipulate
> the codebase directly. Children do that.
@@ -339,7 +339,7 @@ For a new coordinator skill:
A full end-to-end test isn't required for every skill; a
prepare-step unit test that asserts "given this initial message, the
first tool call is X with Y args" is usually sufficient to catch
framing drift without a real LLM in the loop.
persona drift without a real LLM in the loop.
---
+1 -1
View File
@@ -260,7 +260,7 @@ interface, or anyone who can reach it can search through your instance.
Both stacks install all entry points into a single image (`turnstone`,
`turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`,
`turnstone-eval`, `turnstone-optimizer`, `turnstone-doctor`):
`turnstone-eval`, `turnstone-doctor`):
```bash
docker compose build # build the dev image
+24 -55
View File
@@ -1,19 +1,11 @@
# Evaluation and Prompt Optimization (turnstone-eval, turnstone-optimizer)
# Evaluation and Prompt Optimization (turnstone-eval)
Evaluation for turnstone is split into two commands:
`turnstone-eval` is the evaluation and prompt optimization system for turnstone. It
runs test cases against the LLM, scores tool call sequences against expected
actions, and optionally uses a multi-agent pipeline to optimize the developer
prompt and tool descriptions.
- **`turnstone-eval`** — the measurement substrate. Runs test cases against the LLM
and scores tool call sequences against expected actions. A single measurement pass,
no self-modification.
- **`turnstone-optimizer`** — the prompt/tool optimizer. Loops over the measurement
substrate, using a multi-agent pipeline (analyst, optimizer, observer, diversifier,
tool optimizer) to edit the developer prompt and tool descriptions so more tests pass.
The dependency is strictly one-way: the optimizer consumes the eval substrate; the
substrate never depends on the optimizer.
Source: `turnstone/eval/core.py` (measurement substrate), `turnstone/eval/cli.py`
(the `turnstone-eval` CLI), `turnstone/optimizer.py` (the `turnstone-optimizer` CLI).
Source: `turnstone/eval.py`
---
@@ -35,8 +27,8 @@ This approach (inspired by [Learning to Self-Evolve](https://arxiv.org/abs/2603.
prevents irrecoverable collapse from bad edits — UCB naturally backtracks to
high-scoring ancestors instead of following a linear chain.
The `turnstone-eval` command (or `turnstone-optimizer --no-optimize`) executes only
steps 2-4: a single measurement pass over the root prompt, no optimization.
When optimization is disabled (`--no-optimize`), only steps 2-4 execute
(a single iteration evaluating the root node).
---
@@ -460,46 +452,30 @@ structure is:
## CLI Usage
Two console scripts (installed as entry points), or the equivalent `python -m`
invocations:
- `turnstone-eval` / `python -m turnstone.eval.cli` — measure only.
- `turnstone-optimizer` / `python -m turnstone.optimizer` — optimize.
### Measure (`turnstone-eval`)
The entry point is `turnstone-eval` (installed as a console script) or
`python -m turnstone.eval`.
```
turnstone-eval tests.json # one measurement pass, print scores
turnstone-eval tests.json --prompt custom.txt # measure a custom prompt
turnstone-eval tests.json --n-runs 5 # more runs per case
turnstone-eval tests.json --parallel 4 # run cases across 4 workers
turnstone-eval tests.json -v # verbose per-turn logging
turnstone-eval tests.json # evaluate + optimize
turnstone-eval tests.json --no-optimize # evaluate only (single iteration)
turnstone-eval tests.json --n-runs 5 --max-iter 10 # more thorough evaluation
turnstone-eval tests.json --prompt custom.txt # start from a custom prompt
turnstone-eval tests.json --optimize-tools # optimize tool descriptions only
turnstone-eval tests.json --diversify 10 # test with prompt variants
turnstone-eval tests.json -v # verbose per-turn logging
```
### Optimize (`turnstone-optimizer`)
### Multi-model setup (local test model, cloud optimizer)
```
turnstone-optimizer tests.json # evaluate + optimize
turnstone-optimizer tests.json --no-optimize # single pass, no optimization
turnstone-optimizer tests.json --n-runs 5 --max-iter 10 # more thorough optimization
turnstone-optimizer tests.json --prompt custom.txt # start from a custom prompt
turnstone-optimizer tests.json --optimize-tools # optimize tool descriptions only
turnstone-optimizer tests.json --diversify 10 # test with prompt variants
```
#### Multi-model setup (local test model, cloud optimizer)
```
turnstone-optimizer tests.json \
turnstone-eval tests.json \
--base-url http://localhost:8000/v1 \
--optimizer-base-url https://api.anthropic.com \
--optimizer-model claude-sonnet-4-6 \
--analyst-model claude-opus-4-6
```
### Measurement Options
Accepted by **both** commands.
### All Options
| Flag | Default | Description |
|-------------------------|----------------------------|-------------|
@@ -508,26 +484,19 @@ Accepted by **both** commands.
| `--model` | auto-detect | Model name. Auto-detected from the API if not specified. |
| `--prompt` | turnstone built-in prompt | Path to initial prompt text file. |
| `--n-runs` | from tests.json or 3 | Number of runs per test case. |
| `--max-iter` | 5 | Maximum optimization iterations. |
| `--no-optimize` | false | Run evaluation only (sets max-iter to 1). |
| `--temperature` | 0.7 | Sampling temperature. |
| `--max-tokens` | 32768 | Max completion tokens. |
| `--reasoning-effort` | `medium` | Reasoning effort: `low`, `medium`, or `high`. |
| `--context-window` | 131072 | Context window size. |
| `--output` | `eval_results.json` | Output results file path. |
| `-v`, `--verbose` | false | Show detailed per-turn logging. |
| `--explore-constant` | 1.414 (sqrt(2)) | UCB exploration constant C. |
| `--test-timeout` | 300 | Per-test timeout in seconds. |
| `--suite-timeout` | 0 (unlimited) | Total suite timeout in seconds. |
| `--no-fast-fail` | false | Disable early termination on all-zero initial runs. |
| `--parallel` | 1 (serial) | Parallel workers (0=auto, N=use N workers). |
### Optimizer Options
Accepted by **`turnstone-optimizer`** only.
| Flag | Default | Description |
|-------------------------|----------------------------|-------------|
| `--max-iter` | 5 | Maximum optimization iterations. |
| `--no-optimize` | false | Run a single measurement pass (sets max-iter to 1). |
| `--explore-constant` | 1.414 (sqrt(2)) | UCB exploration constant C. |
| `--suite-timeout` | 0 (unlimited) | Total suite timeout in seconds. |
| `--optimizer-model` | same as `--model` | Model for prompt optimization. |
| `--optimizer-base-url` | same as `--base-url` | Base URL for optimizer model. |
| `--observer-model` | same as optimizer | Model for meta-optimization (observer). |
+6 -14
View File
@@ -13,7 +13,7 @@ The permission model has two layers:
1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware`
on every request based on URL path classification.
2. **Permissions** (granular) — named permission strings checked per-endpoint by
2. **Permissions** (granular) — 15 permission strings checked per-endpoint by
`require_permission()`.
**Built-in roles** (seeded by migration 008):
@@ -24,11 +24,7 @@ The permission model has two layers:
| operator | read, write, workstreams.create, workstreams.close |
| viewer | read |
Custom roles can be created with any subset of the valid permissions.
The `persona.create` / `persona.read` / `persona.write` family gates
persona administration; migration `063` seeds all three onto
`builtin-admin`, and any role can be granted them through the standard
role and permission-override editors.
Custom roles can be created with any subset of the 15 valid permissions.
**Auth flow:**
1. User logs in (password or API token) → `_load_user_permissions()` aggregates
@@ -131,12 +127,9 @@ Per-LLM-request token and tool call metrics:
LLM response with prompt/completion tokens, cache tokens, tool call count,
model, ws_id
- **Prompt caching**: Anthropic automatic caching (`cache_control: ephemeral`)
and OpenAI caching are enabled by default. Pre-5.6 GPT-5 models request
`prompt_cache_retention: 24h`; GPT-5.6 uses
`prompt_cache_options: {"ttl": "30m"}`. GPT-5.6 cache writes use the
provider's 1.25× input-token rate. `cache_creation_tokens` and
`cache_read_tokens` are tracked per request in `usage_events` and surfaced
in the Usage admin tab
and OpenAI extended retention (`prompt_cache_retention: 24h` for GPT-5.x)
are enabled by default. `cache_creation_tokens` and `cache_read_tokens` are
tracked per request in `usage_events` and surfaced in the Usage admin tab
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
and time range filtering — includes cache token aggregates
- **Prometheus**: `turnstone_tokens_total{type="cache_creation|cache_read"}`
@@ -184,7 +177,6 @@ All under `/v1/api/admin/` (requires `approve` scope + granular permission).
| Orgs | 3 (list, get, update) | `admin.orgs` |
| Tool Policies | 4 (CRUD) | `admin.policies` |
| Skills | 4 (CRUD) | `admin.skills` |
| Personas | 4 (list, create, get, edit/archive) | `persona.read` / `persona.create` / `persona.write` |
| Schedules | 6 (CRUD + runs) | `admin.schedules` |
| Watches | 3 (list, create, cancel) | `admin.watches` |
| Usage | 1 (aggregated query) | `admin.usage` |
@@ -230,7 +222,7 @@ Both Python and TypeScript console SDKs expose governance methods:
- **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment
and requires caller to hold a superset of the target role's permissions
- **Permission validation**: Role create/update validates permissions against
the permission allowlist (`_VALID_PERMISSIONS`)
a 15-item allowlist (`_VALID_PERMISSIONS`)
- **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete
your own account (matching the self-assignment guard on role endpoints)
- **Field allowlists**: Storage `update_*` methods filter fields against
+2 -8
View File
@@ -249,14 +249,8 @@ are withheld from the live surfaces (a reused call_id must never ride a stale
`approve` into Smart Approvals) but still persist with
`user_decision = "superseded"` so the audit trail records the judge's answer.
Sub-agent (task agent) tool calls are judge-gated too. Each runs the same
intent pipeline as its own `agent_gate` generation, grounded in that sub-agent's
own trajectory -- its task prompt is the delegation contract the operator
approved, so "does this call serve the task" is the right local question.
Agent-gate generations never occupy the main loop's supersede slot (parallel
siblings would otherwise make each other's verdicts look stale); per-cycle
generation checks enforce staleness instead, and `judge.cancel_on_approval`
fires per gate exactly like the main loop.
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
always get full tool visibility without judge evaluation.
---
-5
View File
@@ -75,11 +75,6 @@ This means the model always has its most relevant memories available without
explicit recall -- but can still use `memory(action='search')` for deeper
lookup.
The persona memory lever gates this pathway: a workstream whose persona
turns memory off receives no relevance injection at all -- the steps
above run only when memory is enabled for the session. See
[Personas](personas.md).
### Nudges
The metacognition layer can nudge the model to save memories at appropriate
-37
View File
@@ -41,7 +41,6 @@ are set.
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
| `TURNSTONE_OIDC_REDIRECT_BASE` | Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
| `TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS` | No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See [Cross-host endpoints](#cross-host-endpoints). |
| `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK` | No | `false` | Allow the issuer (and its discovered endpoints) to resolve to private/internal addresses — needed for a self-hosted IdP on an internal network. See [Self-hosted and internal IdPs](#self-hosted-and-internal-idps). |
All four required fields — issuer, client ID, client secret, and
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
@@ -100,40 +99,6 @@ The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts —
this knob only relaxes the same-origin check, not the security gates.
Each entry is a hostname (no scheme, no path).
### Self-hosted and internal IdPs
By default Turnstone refuses an issuer whose hostname resolves to a
private or internal address:
```
OIDCError: endpoint URL resolves to non-public address (10.0.0.5): https://auth.example.site
```
This is SSRF hardening, not a licensing or product restriction: the OIDC
flow makes server-side HTTP requests (discovery, JWKS, token exchange),
and refusing non-public destinations keeps a mistyped or maliciously
steered issuer from aiming those fetches at internal services. For a
self-hosted IdP (Keycloak, Authentik, Dex, …) on a private network,
opt in explicitly in `config.toml`:
```toml
[oidc]
allow_private_network = true
```
or via `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true` (the env var wins
when both are set).
The opt-in admits private-range (RFC 1918), unique-local, CGNAT
(100.64/10 — tailnets), and loopback addresses. Link-local, multicast,
and reserved ranges stay refused even with the opt-in — cloud metadata
services (169.254.169.254) live there, and no legitimate IdP does. The
HTTPS requirement and the same-origin endpoint checks are unaffected.
This knob only affects the login-flow IdP configured here. OAuth
endpoints advertised by remote MCP servers are untrusted input and are
always held to the strict public-address rule.
### config.toml alternative
```toml
@@ -146,8 +111,6 @@ provider_name = "Google"
role_claim = "groups"
password_enabled = true
redirect_base = "https://app.example.com"
# Self-hosted IdP on an internal network (see "Self-hosted and internal IdPs")
allow_private_network = false
[oidc.role_map]
admin = "builtin-admin"
-173
View File
@@ -1,173 +0,0 @@
# Personas
A **persona** is a named, reusable bundle attached to a workstream **at
creation** that controls how its system message is composed and what
capability envelope it runs with. Personas answer a recurring operational
complaint: the default composition primes every session for heavy tool use,
and there was no per-workstream dial to launch a "just write prose" or
"evidence-first research" session.
A persona is exactly four levers — no more:
| Lever | What it does |
|---|---|
| **Base prompt** | Replaces the BASE module of the composed system message. *Only* BASE: ENV, CONTEXT, TOOLS, and POLICIES keep composing, so mandatory [prompt policies](governance.md) ride on top of every persona. Built-in personas source their prose from a repo file; operator personas store it inline — see [Where persona prompts live](#where-persona-prompts-live). |
| **Tool visibility** | Which tools the session advertises. Tri-state: *unrestricted* (tracks tool growth and MCP catalogs), *no tools* (the TOOLS prompt block self-suppresses and zero definitions go on the wire), or an *exact set* of names. Including `tool_search` in a set makes it **soft** — tools the model discovers through search join the visible set; omitting it makes the set **hard** (the search pathway is disabled entirely). On commercial providers a soft set costs one prompt-cache re-prime per `tool_search` expansion, since each expansion rewrites the wire tool set and recomposes the prompt. |
| **MCP** | Whether the workstream talks to MCP at all. **Session-wide**: off means no MCP tools for the persona's own hands *or* for in-process task agents, no resource/prompt catalogs, and no listener registrations. This lever expresses infrastructure intent, not behavior shaping. |
| **Memory** | Whether the persona's **own hands** get memory: recalled-memory injection into the prompt, memory-directed metacognitive nudges, and the `memory` tool. Task agents keep their own envelope, and compaction spill/markers are session mechanics that are never persona-gated. An exact tool set that hides `memory` also mutes those nudges, and the compaction-resume pointer follows `recall`'s visibility. |
Visibility is behavior shaping, **not** a security boundary: any tool call
that does reach the wire still clears the same approval, judge, and policy
machinery as always. RBAC and tool policies remain the enforcement layers.
## Snapshot semantics — resolve once, stamp forever
The persona is resolved **once**, at workstream creation, and stamped into
`workstream_config` as five keys (`persona`, `persona_prompt`,
`persona_tools`, `persona_mcp`, `persona_memory`). From then on the session
reads only the stamp:
- **Editing or archiving a persona never changes an existing workstream.**
Rehydrate, resume, and post-compaction resume all run from the stamp.
A mid-session REPL `/resume` adopts the target workstream's stamp for
prompt, tools, and memory; for the MCP lever it can only narrow in
place — adopting an MCP-off stamp drops the live MCP surface, while
adopting an MCP-on stamp into a session whose persona dropped MCP at
construction is refused with an error telling you to reopen the
workstream fresh.
- A workstream outlives its persona — an archived persona keeps labelling
the workstreams stamped with it.
- A partial or unparseable stamp is treated as corruption: session
construction fails loudly rather than silently falling back to a default
envelope the operator never chose.
- Workstreams created before personas existed carry no stamp and keep
legacy behavior, byte-identical to the `engineer` / `orchestrator`
defaults below — with one exception: pre-1.7 workstreams that had
`creative_mode` set are converted by migration `063` into full
`writer` stamps, so they resume as writing sessions rather than as
legacy defaults.
- Forking (`resume_ws` on create) resumes the source's stamped persona; the
fork does not re-resolve.
## Seed personas
Migration `063` seeds six personas. The two per-kind **defaults** carry no
overrides at all, so a zero-touch launch behaves exactly as it did before
personas existed:
| Persona | Kind | Base prompt | Tools | MCP | Memory |
|---|---|---|---|---|---|
| `engineer` *(default)* | interactive | stock | unrestricted | on | on |
| `orchestrator` *(default)* | coordinator | stock | unrestricted | on | on |
| `scribe` | interactive | custom (faithful structuring of given material) | none | off | off |
| `researcher` | interactive | custom (evidence-first) | `read_file`, `search`, `web_fetch`, `web_search`, `recall`, `memory`, `tool_search` (soft) | off | on |
| `writer` | interactive | custom (creative writing partner — replaces the removed `/creative`) | none | off | on |
| `executive` | coordinator | custom (delegate, interrogate plans, judge outcomes) | spawn/inspect/lifecycle tools plus `memory`: `spawn_workstream`, `spawn_batch`, `send_to_workstream`, `wait_for_workstream`, `inspect_workstream`, `list_workstreams`, `list_nodes`, `close_workstream`, `cancel_workstream`, `memory` (hard) | off | on |
Notes:
- `scribe` turns memory off deliberately: recalled memories would
contaminate faithful summarization with unrelated context.
- `researcher`'s set is soft (includes `tool_search`): it starts with
read and evidence tools but can pull in others on demand — e.g. load
`bash` to run a snippet and verify a calculation. It is evidence-first,
not sandboxed; any escalated tool still hits the normal approval path.
- Coordinator sessions do not merge MCP today, so the MCP lever on
coordinator personas is forward-compatible bookkeeping; it bites on
interactive workstreams.
## Where persona prompts live
Prompt source is explicit in the persona row — two nullable columns, never both empty:
| `base_prompt_file` | `base_prompt` | Meaning |
|---|---|---|
| set (e.g. `scribe.md`) | — | **built-in**: prose lives in `prompts/personas/<file>`, code-owned and PR-reviewed |
| set | set | built-in with an **operator override** layered on top (the inline text wins) |
| — | set | **operator** persona, inline prose |
A `CHECK` forbids the both-empty row, so resolution is a plain coalesce —
`base_prompt ?? load(base_prompt_file)` — with no implicit "inherit the default"
branch in application logic. `base_prompt_file` is set only by the migration/code
(the admin API never exposes it): it marks a persona as built-in and blocks
archive, so `engineer` and `orchestrator` can't be removed. To customise a
built-in, set `base_prompt` on it (clear it to revert), or create your own persona.
The resolved prompt is **frozen into the workstream at creation** — later edits to
a built-in's file or an operator's row never change a running workstream; only new
ones pick up the change. "No persona" is not a state: every workstream is stamped,
and an empty `persona=` resolves to the kind's `is_default` (`engineer` /
`orchestrator`).
## Choosing a persona
Every creation surface takes an optional persona; empty always means the
kind's default (or plain legacy behavior on a database with no personas
seeded):
- **Web/console**: the persona select on the console launcher, the server
webui's new-workstream dialog, and the dashboard composer. Selecting a
persona requires **no** `persona.*` permission — the picker feed
(`GET /v1/api/personas`) is authenticated-only and returns display fields.
- **API/SDK**: `CreateWorkstreamRequest.persona` (Python:
`create_workstream(persona=...)`; TypeScript: `{ persona: ... }`).
- **CLI**: `turnstone --persona <name>`. Unknown or disabled names error at
startup. `--resume` ignores `--persona` and adopts the resumed
workstream's stamp.
- **Coordinator spawn**: `spawn_workstream` / `spawn_batch` take a
`persona` argument, validated when the coordinator prepares the spawn
and re-checked by the node that creates the child (children are always
interactive-kind). Omitted means the interactive **default** — a child
never inherits its parent coordinator's persona.
- **Sub-agents**: `task_agent` takes a `persona` argument setting the
sub-agent's identity and capability envelope (resolved against
interactive-kind personas, frozen into the task at prep). Omitted keeps
the default autonomous task-agent identity — never the parent's persona.
## How agents discover personas
Agents are told, not expected to guess: the live persona list (enabled,
interactive-kind — children and sub-agents are always interactive) is
injected into the `persona` parameter description of `task_agent`,
`spawn_workstream`, and `spawn_batch` whenever the session's tool surface
is rendered — session start, MCP catalog change, model-registry reload.
Each entry carries the name, the default marker, and the persona's
one-line description so the model can pick by purpose (descriptions drop
out past 25 personas; the name list always enumerates completely).
A persona created after that render is still reachable — pass its name.
Every resolve failure enumerates the names currently valid for the kind,
so a stale list (or a typo) self-corrects on the next attempt.
Resolution is forgiving on all surfaces (they share one rule):
- names match case-insensitively (`Writer` resolves `writer`);
- an input that uniquely matches a persona's **display name**
(case-insensitive, among the kind's enabled personas — display names are
not unique, and a same-label persona of another kind neither blocks nor
wins) resolves to that persona; an ambiguous match errors, listing the
candidate slugs;
- whatever variant matched, the stamped identity, approval chrome, and
wire always carry the canonical `name` slug.
## Authoring (console)
Personas are managed in the console's **Manage → Governance → Personas**
tab. The admin shelf exposes exactly the four levers plus the kind
list, the default marker, and archive. Rules:
- `name` is an immutable lowercase slug — and the identifier agents and
the CLI launch the persona by (`persona=` on the spawn tools,
`--persona` on the CLI); the create shelf says so under **Name**.
`display_name` is a list label, editable any time, and deliberately
not an identifier (a unique display name happens to resolve, as a
forgiveness fallback — don't design workflows around it).
- Exactly one default per kind, storage-enforced: flipping the flag on a
successor demotes the incumbent atomically, defaults are single-kind,
and a default cannot be archived.
- **Archive only** — there is no delete verb, so every stamped
workstream's provenance stays explicable.
RBAC: `persona.create` / `persona.read` / `persona.write` gate the admin
CRUD (`/v1/api/admin/personas`); all three are granted to `builtin-admin`
by migration `063`, and other roles opt in via role permission overrides.
+2 -2
View File
@@ -69,7 +69,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|----------|--------|---------|
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
| | `dashboard()` | `DashboardResponse` |
| | `create_workstream(*, name, model, auto_approve, skill, persona, initial_message, attachments)` | `CreateWorkstreamResponse` |
| | `create_workstream(*, name, model, auto_approve, skill, initial_message, attachments)` | `CreateWorkstreamResponse` |
| | `close_workstream(ws_id)` | `StatusResponse` |
| **Attachments** | `upload_attachment(ws_id, filename, data, *, mime_type=...)` | `UploadAttachmentResponse` |
| | `list_attachments(ws_id)` | `ListAttachmentsResponse` |
@@ -100,7 +100,7 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose:
| | `workstreams(*, state, node, search, sort, page, per_page)` | `ClusterWorkstreamsResponse` |
| | `node_detail(node_id)` | `NodeDetailResponse` |
| | `snapshot()` | `ClusterSnapshotResponse` |
| | `create_workstream(*, node_id, name, model, initial_message, skill, persona)` | `ConsoleCreateWsResponse` |
| | `create_workstream(*, node_id, name, model, initial_message, skill)` | `ConsoleCreateWsResponse` |
| **Schedules** | `list_schedules()` | `ListSchedulesResponse` |
| | `create_schedule(*, name, schedule_type, initial_message, ...)` | `ScheduleInfo` |
| | `get_schedule(task_id)` | `ScheduleInfo` |
-17
View File
@@ -54,23 +54,6 @@ When a per-model override is `NULL` (empty in the UI), the global default is
used. Switching models via `/model <alias>` re-resolves sampling parameters
from the new model's overrides or global defaults.
### Responses output controls (per-model)
Models whose capability table declares Responses output controls expose two
additional fields in the Models create/edit shelf:
| Field | Stored capability | Values | Effect |
|-------|-------------------|--------|--------|
| Output verbosity | `verbosity` | `low`, `medium`, `high` | Controls answer length independently of reasoning effort. |
| Reasoning mode | `reasoning_mode` | `standard`, `pro` | Selects standard or higher-compute Pro execution without changing the model ID. |
An empty selection means provider default and omits the capability key. Known
GPT-5.6 models inherit support from the built-in table without persisting
redundant support flags. An OpenAI-compatible model pinned to the Responses API
can opt in with the `supports_verbosity` and `supports_pro_mode` capability
tiles. Chat Completions and non-Responses providers do not surface or submit
these controls.
**Removed settings:** `model.name` and `model.context_window` have been removed
from ConfigStore. Model names and context windows are now configured per-model
in the Models tab. A startup warning is logged if these keys appear in
+6 -49
View File
@@ -1,6 +1,6 @@
# Tools Reference
turnstone exposes 17 built-in tools plus any number of external MCP tools to the
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
MCP tools are discovered from configured MCP servers at startup by
@@ -44,10 +44,10 @@ schema plus turnstone-specific metadata keys:
| Name | Description |
|---------------------|-------------|
| `TOOLS` | All 29 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
| `TOOLS` | All 28 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
| `TASK_AUTO_TOOLS` | Set of all tool names with `auto_approve: true` -- used by task-agent sub-sessions to skip confirmation for matching available tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 29 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 28 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
---
@@ -65,7 +65,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
- Parses the JSON arguments (with fallback for malformed JSON).
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
to the correct parameter.
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 16
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
the generic `_prepare_mcp_tool()` handler for MCP tools.
- Validates arguments and builds a preview dict containing:
@@ -125,9 +125,6 @@ Each item's `execute` callable is invoked:
- `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests)
- `web_search` -- web search via self-hosted SearxNG (makes network requests)
- `task_agent` -- spawns an autonomous sub-agent
- `open_preview` -- **URL targets only** (network access, gated like `web_fetch`);
file-path and `attachment:` targets are local reads and run unprompted like
`read_file`
Note: The JSON schema metadata key `auto_approve` controls membership in
`TASK_AUTO_TOOLS` (used for task agent sub-sessions). The actual runtime
@@ -160,7 +157,6 @@ Every tool defines a `primary_key`. The mapping is:
| `search` | `query` |
| `web_fetch` | `url` |
| `web_search` | `query` |
| `open_preview` | `target` |
| `task_agent` | `prompt` |
| `memory` | `name` |
| `recall` | `query` |
@@ -289,7 +285,7 @@ Fetch a URL and extract specific information from it.
| `url` | string | yes | The URL to fetch (must start with `http://` or `https://`). |
| `question` | string | yes | What to extract or answer from the page content. |
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Every redirect hop is SSRF-screened before it is requested. Private/internal addresses are refused by default; enable `tools.allow_private_network` (console Settings → Tools) to make them approvable for self-hosted setups whose services live on the local network — the approval prompt marks such requests, and a public site redirecting into private space is refused regardless.
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs).
- **Auto-approve**: No -- requires user confirmation (makes network requests).
- **Agent availability**: `task_agent`.
@@ -349,39 +345,6 @@ It reports the score scale, whether the endpoint cleanly separates relevant from
---
### open_preview
Show the user rich content in a preview pane beside the conversation.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `target` | string | yes | An http(s) URL, a file path, or `attachment:<id>` for a file attached to the conversation. |
| `kind` | string | no | Rendering override: `web`, `pdf`, `image`, `table`, `text`, or `markdown`. Detected from the content when omitted. |
| `title` | string | no | Pane header title. Defaults to the page title, filename, or URL. |
- **What it does**: Resolves the target to bytes (URLs fetch through the same
SSRF-guarded path as `web_fetch`, screened per redirect hop, honoring the
same `tools.allow_private_network` opt-in), classifies the
content, stores it content-addressed against the workstream, and opens the
frontend preview pane beside the conversation: web pages render in a fully
sandboxed iframe (no scripts, opaque origin), PDFs in the browser viewer,
images inline, CSV/TSV/JSON as a sortable table, text/markdown rendered. A
previewed web page loads none of its remote images or styles by default, so
opening it never reveals the viewer to the page's site; a toggle in the pane
header turns remote content back on for that preview. The
model receives only a one-line confirmation — to reason about content, use
`web_fetch` / `read_file` instead. Preview content is size-capped per kind
(pages 4 MB, PDFs 32 MB, images 4 MB, tables 2 MB, text 512 KB) and GC'd
with the workstream.
- **Auto-approve**: URL targets require confirmation (network access); file
paths and `attachment:` targets run unprompted (local reads).
- **Agent availability**: interactive sessions only (not `task_agent`, not
coordinators).
- **Surfaces**: the pane renders in the web UI (standalone and console). The
CLI prints the confirmation line only — there is no terminal pane.
---
## Agent
The tool name uses the `_agent` suffix — bare `task` collides with
@@ -582,7 +545,6 @@ pre-configure skills at workstream creation.
| `search` | File Ops | Yes | Yes | `query` |
| `web_fetch` | Info | No | Yes | `url` |
| `web_search` | Info | No | Yes | `query` |
| `open_preview`| Info | URL: no; path/attachment: yes | No | `target` |
| `task_agent` | Agent | No | No | `prompt` |
| `memory` | Memory | Yes | No | `name` |
| `recall` | Memory | Yes | No | `query` |
@@ -618,11 +580,6 @@ Tool search uses the best available mechanism for each provider:
`_exec_tool_search()` runs a pure-Python BM25 index over tool names and
descriptions, then expands the matched tools into the visible set.
A persona with a tool-visibility set overrides this selection: any exact
set forces tool search into the client-side BM25 mechanism (tier 3)
regardless of provider, and a **hard** set — one whose visible tools omit
`tool_search` — disables tool search entirely.
### Configuration
Tool search is configured in `config.toml` under the `[tools]` section:
@@ -692,7 +649,7 @@ MCP-compatible service.
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
4. **Merging**: MCP tools are appended after the 17 built-in tools via
4. **Merging**: MCP tools are appended after the 16 built-in tools via
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
When dynamic tool search is active, MCP tools are deferred rather than directly
visible -- the model discovers them via search as needed (see
-56
View File
@@ -1,56 +0,0 @@
{
"defaults": {
"n_runs": 3
},
"cases": [
{
"id": "search-first",
"skill": {
"name": "search-first",
"content": "# Search First\n\nBefore answering ANY question about where something lives in the codebase, you MUST call the `search` tool first. Never answer from memory."
},
"user_prompt": "Where is JWT token validation implemented in this project?",
"expected_actions": [{ "tool": "search" }],
"match_mode": "ordered_subset",
"max_turns": 4
},
{
"id": "test-after-edit",
"skill": {
"name": "test-after-edit",
"content": "# Test After Edit\n\nAfter editing or writing ANY file, you MUST run the test suite with `python -m pytest` via bash before you finish. Do not report done until tests have run."
},
"user_prompt": "Add a function `clamp(x, lo, hi)` that clamps x to [lo, hi] in utils.py.",
"setup": {
"files": {
"utils.py": ""
}
},
"expected_actions": [
{ "tool": "write_file" },
{ "tool": "bash", "args_pattern": { "command": "pytest" } }
],
"match_mode": "ordered_subset",
"max_turns": 8
},
{
"id": "changelog-update",
"skill": {
"name": "changelog-update",
"content": "# Changelog Discipline\n\nWhenever you modify a file, you MUST also append a one-line entry to CHANGELOG.md describing the change in the same task."
},
"user_prompt": "Fix the off-by-one so pager.py shows the last page. Edit pager.py.",
"setup": {
"files": {
"pager.py": "def last_page(total_items, per_page):\n # off-by-one: drops the final partial page\n return total_items // per_page\n",
"CHANGELOG.md": "# Changelog\n"
}
},
"expected_actions": [
{ "tool": "edit_file", "args_pattern": { "path": "CHANGELOG.md" } }
],
"match_mode": "subset",
"max_turns": 8
}
]
}
+3 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.7.4"
version = "1.7.0a6"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "Apache-2.0"
@@ -23,7 +23,7 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
"openai>=2.45", # GPT-5.6: typed reasoning.mode, prompt_cache_options, and cache_write_tokens
"openai>=2.37",
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
"httpx>=0.28",
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
@@ -64,8 +64,7 @@ all = ["turnstone[discord,slack]"]
[project.scripts]
turnstone = "turnstone.cli:main"
turnstone-eval = "turnstone.eval.cli:main"
turnstone-optimizer = "turnstone.optimizer:main"
turnstone-eval = "turnstone.eval:main"
turnstone-server = "turnstone.server:main"
turnstone-console = "turnstone.console.server:main"
turnstone-admin = "turnstone.admin:main"
+7 -89
View File
@@ -4,9 +4,8 @@
#
# curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
#
# Autodetects your distro Ubuntu/Debian, Fedora/RHEL, Arch, their common
# derivatives (Mint, Pop!_OS, Nobara, AlmaLinux, …), and WSL on any of them —
# and:
# Autodetects your distro (Ubuntu/Debian, Fedora/RHEL, Arch, and WSL on any of
# them) and:
# 1. ensures git is installed, then clones the repo
# 2. ensures Docker + the compose plugin are installed and the daemon is usable
# 3. asks how many server nodes to run (1-10)
@@ -66,18 +65,12 @@ ask() {
# -- distro / package manager detection --------------------------------------
OS_ID=""; OS_LIKE=""; PKG=""; IS_WSL=0; SUDO=""
# Extra os-release fields, captured only to pick Docker's upstream repo when
# get.docker.com refuses a derivative it doesn't recognize (see install_docker).
OS_PLATFORM_ID=""; OS_CODENAME=""; OS_UBUNTU_CODENAME=""
detect_os() {
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
OS_PLATFORM_ID="${PLATFORM_ID:-}"
OS_CODENAME="${VERSION_CODENAME:-}"
OS_UBUNTU_CODENAME="${UBUNTU_CODENAME:-}"
fi
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then
IS_WSL=1
@@ -137,83 +130,11 @@ clone_repo() {
# -- docker -------------------------------------------------------------------
DOCKER="docker"
# Fallback when get.docker.com won't install here. That script keys off $ID alone
# (never ID_LIKE), so it aborts with "Unsupported distribution '<id>'" on every
# derivative — Nobara, Linux Mint, Pop!_OS, AlmaLinux, Oracle Linux, … — even
# though the family is clear. We already know the family from detect_os, so we add
# Docker's official CE repo for the matching upstream and install the same
# packages get.docker.com would (including the compose plugin the rest of run.sh
# relies on).
install_docker_ce_repo() {
local up
case "$PKG" in
apt)
local codename arch
# UBUNTU_CODENAME is set by Ubuntu and every Ubuntu-derived distro
# (Mint/Pop!_OS/Zorin/…) and never by pure Debian, so it both routes
# the family and gives the exact codename Docker's repo expects.
if [ -n "$OS_UBUNTU_CODENAME" ]; then
up=ubuntu; codename="$OS_UBUNTU_CODENAME"
else
up=debian; codename="$OS_CODENAME"
fi
[ -n "$codename" ] || die "couldn't determine the $up release codename for Docker's repo — install Docker manually and re-run."
arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
info "Adding Docker's $up repository ($codename)."
$SUDO install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/$up/gpg" | $SUDO tee /etc/apt/keyrings/docker.asc >/dev/null
$SUDO chmod a+r /etc/apt/keyrings/docker.asc
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$arch" "$up" "$codename" | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
$SUDO apt-get update -y
$SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
;;
dnf|yum)
# A Fedora spin and a RHEL clone can both carry "fedora" in ID_LIKE
# (Nobara's is "rhel centos fedora"), so ID_LIKE can't separate them.
# PLATFORM_ID can: Fedora is platform:fNN, Enterprise Linux platform:elN.
case "$OS_PLATFORM_ID" in
platform:f*) up=fedora ;;
platform:el*) up=centos ;;
*) if [ -e /etc/fedora-release ]; then up=fedora; else up=centos; fi ;;
esac
info "Adding Docker's $up repository."
$SUDO curl -fsSL "https://download.docker.com/linux/$up/docker-ce.repo" \
-o /etc/yum.repos.d/docker-ce.repo \
|| die "couldn't add Docker's $up repository — install Docker manually and re-run."
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
;;
esac
}
# The distro IDs get.docker.com installs directly: it matches $ID against this
# exact set (ignoring ID_LIKE) and aborts on anything else. Mirrors the dispatch
# in get.docker.com, including its fedora-asahi-remix -> fedora alias.
get_docker_com_supports() {
case "$1" in
ubuntu|debian|raspbian|centos|fedora|rhel|rocky|sles|fedora-asahi-remix) return 0 ;;
*) return 1 ;;
esac
}
install_docker() {
case "$PKG" in
apt|dnf|yum)
# Decide up front which installer applies, rather than treating every
# get.docker.com failure as "unsupported distro": for an ID it knows,
# let it run and surface any real failure (network, apt lock, EOL) via
# die instead of masking it with the repo path. Only unrecognized
# derivatives (Nobara, Mint, …) — which it would just abort on — skip
# straight to adding Docker's repo ourselves.
if [ -n "$OS_ID" ] && ! get_docker_com_supports "$OS_ID"; then
info "get.docker.com doesn't support '$OS_ID' — using Docker's official repository directly."
install_docker_ce_repo
else
info "Installing Docker via the official get.docker.com script"
curl -fsSL https://get.docker.com | $SUDO sh \
|| die "get.docker.com failed to install Docker (see the output above). Fix the issue and re-run — the script resumes."
fi
;;
info "Installing Docker via the official get.docker.com script"
curl -fsSL https://get.docker.com | $SUDO sh ;;
pacman)
pkg_install docker docker-compose ;;
esac
@@ -445,15 +366,12 @@ ${GREEN}${BOLD}Turnstone is running${RESET} (${NODE_COUNT} node$([ "$NODE_COUNT"
${DIM}cd $INSTALL_DIR && $DOCKER compose exec caddy cat /data/caddy/pki/authorities/local/root.crt${RESET}
Finish setup
1. Open ${BOLD}${url}${RESET} and create the admin account when prompted —
the first user created there gets full admin access.
2. Log in, then add a model backend in the ${BOLD}Models${RESET} tab —
1. Create the first admin user:
${DIM}cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-user --username admin --name "Admin"${RESET}
2. Open ${url}, log in, and add a model backend in the ${BOLD}Models${RESET} tab —
a local server (vLLM / llama.cpp) or an OpenAI / Anthropic / Gemini key.
Nodes boot without a model and pick it up live; no restart needed.
${DIM}No browser? Create the admin from the CLI instead:
cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-admin --username admin --name "Admin"${RESET}
Scale Running ${scale}
Manage ${DIM}cd $INSTALL_DIR${RESET}
+30 -4
View File
@@ -1267,6 +1267,12 @@ PERF_TEMPLATE = """<!doctype html>
let phase = "mount";
try {
const pane = new InteractivePane("perf-ws");
// ?window= overrides the pane's transcript window (message count),
// e.g. ?window=100000 disables windowing to isolate the
// content-visibility/block-flow effect from the windowing effect.
// Default (0) measures shipped behavior.
const WINDOW = parseInt(q.get("window") || "0", 10);
if (WINDOW > 0) pane._historyWindow = WINDOW;
document.getElementById("mount").appendChild(pane.el);
const msgs = buildHistory(N);
report.heap_start = heapBytes();
@@ -1576,7 +1582,14 @@ def _await_report(
def _perf_run_one(
chrome: str, out: Path, port: int, store: _PerfStore, n: int, turns: int, timeout: float
chrome: str,
out: Path,
port: int,
store: _PerfStore,
n: int,
turns: int,
timeout: float,
extra_query: str = "",
) -> dict[str, object] | None:
"""One headless-Chrome perf pass; returns the page's report or None."""
base_flags = [
@@ -1602,6 +1615,8 @@ def _perf_run_one(
url = (
f"http://127.0.0.1:{port}/perf/livepass.html?n={n}&turns={turns}&post=1&run={run_token}"
)
if extra_query:
url += "&" + extra_query.lstrip("&")
store.event.clear()
store.data = None
profile = out / f".chrome-perf-{n}"
@@ -1624,7 +1639,9 @@ def _perf_run_one(
return None
def run_perf(out: Path, sizes: list[int], turns: int, timeout: float) -> bool:
def run_perf(
out: Path, sizes: list[int], turns: int, timeout: float, extra_query: str = ""
) -> bool:
"""Build, serve, and run the perf page once per history size; print a table."""
import functools
import threading
@@ -1644,7 +1661,7 @@ def run_perf(out: Path, sizes: list[int], turns: int, timeout: float) -> bool:
try:
for n in sizes:
print(f"perf: n={n} turns={turns}", end="", flush=True)
report = _perf_run_one(chrome, out, port, store, n, turns, timeout)
report = _perf_run_one(chrome, out, port, store, n, turns, timeout, extra_query)
if report is None:
print("FAILED (no report — timeout or chrome startup failure)")
continue
@@ -1721,11 +1738,20 @@ def main() -> None:
)
ap.add_argument("--perf-turns", type=int, default=20)
ap.add_argument("--perf-timeout", type=float, default=420.0)
ap.add_argument(
"--perf-extra",
default="",
help="extra query params for the perf page (e.g. 'window=100000' to disable windowing)",
)
args = ap.parse_args()
build(args.out)
if args.perf:
sizes = [int(s) for s in str(args.perf_n).split(",") if s.strip()]
raise SystemExit(0 if run_perf(args.out, sizes, args.perf_turns, args.perf_timeout) else 1)
raise SystemExit(
0
if run_perf(args.out, sizes, args.perf_turns, args.perf_timeout, args.perf_extra)
else 1
)
if args.serve:
import functools
+16 -529
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Console API",
"version": "1.7.0rc1",
"version": "1.7.0a2",
"description": "Cluster-wide visibility and control across all turnstone nodes."
},
"paths": {
@@ -4213,166 +4213,6 @@
}
}
},
"/v1/api/admin/personas": {
"get": {
"summary": "List all personas, archived included",
"operationId": "v1_api_admin_personas_get",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListPersonasResponse"
}
}
}
}
}
},
"post": {
"summary": "Create a persona",
"operationId": "v1_api_admin_personas_post",
"tags": [
"Admin"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePersonaRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonaInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/personas/{persona_id}": {
"get": {
"summary": "Get a single persona",
"operationId": "v1_api_admin_personas_{persona_id}_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "persona_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonaInfo"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"patch": {
"summary": "Update a persona (edit levers, archive/unarchive, flip default)",
"operationId": "v1_api_admin_personas_{persona_id}_patch",
"tags": [
"Admin"
],
"parameters": [
{
"name": "persona_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePersonaRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonaInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/node-metadata": {
"get": {
"summary": "Get metadata for all nodes (bulk)",
@@ -6688,7 +6528,7 @@
"tags": [
"Coordinator"
],
"description": "Aggregates the persisted row, a best-effort live block from the owning node (or the in-process coordinator manager for ``kind=\"coordinator\"`` rows), and the tail of the message history. Gated on the ``admin.cluster.inspect`` permission (granted to ``builtin-admin`` via migration 040; revoke or reassign to a custom role for tighter control). A workstream attached to a *private* project stays confidential to its members: a permitted caller who isn't its owner / creator / project member gets a 404 (same masking as an unknown id). ``live`` is null on node unreachability / 5xx so callers can degrade gracefully.",
"description": "Aggregates the persisted row, a best-effort live block from the owning node (or the in-process coordinator manager for ``kind=\"coordinator\"`` rows), and the tail of the message history. Gated on the ``admin.cluster.inspect`` permission (granted to ``builtin-admin`` via migration 040; revoke or reassign to a custom role for tighter control). ``live`` is null on node unreachability / 5xx so callers can degrade gracefully.",
"parameters": [
{
"name": "ws_id",
@@ -7785,12 +7625,6 @@
"title": "Skill",
"type": "string"
},
"persona": {
"default": "",
"description": "Persona slug; resolved and snapshotted at creation, empty = kind default",
"title": "Persona",
"type": "string"
},
"resume_ws": {
"default": "",
"description": "Workstream ID to resume (loads previous conversation)",
@@ -8118,12 +7952,6 @@
"description": "Optional skill name to apply to the coordinator session.",
"title": "Skill"
},
"persona": {
"default": "",
"description": "Persona slug; resolved and snapshotted at creation, empty = kind default",
"title": "Persona",
"type": "string"
},
"initial_message": {
"default": "",
"description": "Optional first user message dispatched to the new coordinator session.",
@@ -11068,345 +10896,6 @@
"title": "ListModelDefinitionsResponse",
"type": "object"
},
"PersonaInfo": {
"description": "Full persona row \u2014 the authoring shape (contrast PersonaChoice, the\npicker's display-only projection on the server surface).",
"properties": {
"persona_id": {
"title": "Persona Id",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"display_name": {
"default": "",
"title": "Display Name",
"type": "string"
},
"description": {
"default": "",
"title": "Description",
"type": "string"
},
"base_prompt": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "BASE-module override; null = the kind's stock base",
"title": "Base Prompt"
},
"tool_allowlist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Tool visibility set: null = unrestricted, [] = no tools, [names] = exact set (include 'tool_search' to keep the set soft/expandable)",
"title": "Tool Allowlist"
},
"mcp_enabled": {
"default": true,
"title": "Mcp Enabled",
"type": "boolean"
},
"memory_enabled": {
"default": true,
"title": "Memory Enabled",
"type": "boolean"
},
"applies_to_kinds": {
"items": {
"type": "string"
},
"title": "Applies To Kinds",
"type": "array"
},
"is_default": {
"default": false,
"title": "Is Default",
"type": "boolean"
},
"enabled": {
"default": true,
"description": "false = archived",
"title": "Enabled",
"type": "boolean"
},
"org_id": {
"default": "",
"title": "Org Id",
"type": "string"
},
"created_by": {
"default": "",
"title": "Created By",
"type": "string"
},
"created": {
"default": "",
"title": "Created",
"type": "string"
},
"updated": {
"default": "",
"title": "Updated",
"type": "string"
}
},
"required": [
"persona_id",
"name"
],
"title": "PersonaInfo",
"type": "object"
},
"CreatePersonaRequest": {
"properties": {
"name": {
"description": "Immutable slug (lowercase: a-z, 0-9, '-', '_')",
"title": "Name",
"type": "string"
},
"display_name": {
"default": "",
"title": "Display Name",
"type": "string"
},
"description": {
"default": "",
"title": "Description",
"type": "string"
},
"base_prompt": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Inline BASE override \u2014 required. Every persona must name a prompt source; built-in file-backed personas are seeded by migration, not created here, so an operator-created persona must supply base_prompt.",
"title": "Base Prompt"
},
"tool_allowlist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Tool Allowlist"
},
"mcp_enabled": {
"default": true,
"title": "Mcp Enabled",
"type": "boolean"
},
"memory_enabled": {
"default": true,
"title": "Memory Enabled",
"type": "boolean"
},
"applies_to_kinds": {
"items": {
"type": "string"
},
"title": "Applies To Kinds",
"type": "array"
},
"is_default": {
"default": false,
"title": "Is Default",
"type": "boolean"
},
"enabled": {
"default": true,
"title": "Enabled",
"type": "boolean"
},
"org_id": {
"default": "",
"description": "Owning org (informational; capped at 64)",
"title": "Org Id",
"type": "string"
}
},
"required": [
"name"
],
"title": "CreatePersonaRequest",
"type": "object"
},
"UpdatePersonaRequest": {
"description": "PATCH body \u2014 absent fields are left unchanged.\n\nExplicit ``null`` resets ``tool_allowlist`` to unrestricted, and \u2014 on a\nBUILT-IN persona only \u2014 clears ``base_prompt`` (the operator override),\nreverting to that persona's file-backed prompt. An OPERATOR persona has no\nfallback source, so ``base_prompt: null`` on one is rejected: every persona\nmust name a prompt source. ``null`` on the boolean flags or\n``applies_to_kinds`` is ignored (treated as absent), so a client serializing\nunset optionals as null cannot archive a persona or flip levers by accident.\n\nArchive = ``{\"enabled\": false}``; default flip = ``{\"is_default\": true}``\non the successor (storage demotes the incumbent atomically). ``name``\nis immutable; existing workstreams are never affected by edits.",
"properties": {
"display_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Display Name"
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Description"
},
"base_prompt": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Base Prompt"
},
"tool_allowlist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Tool Allowlist"
},
"mcp_enabled": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"title": "Mcp Enabled"
},
"memory_enabled": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"title": "Memory Enabled"
},
"applies_to_kinds": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Applies To Kinds"
},
"is_default": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"title": "Is Default"
},
"enabled": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"title": "Enabled"
}
},
"title": "UpdatePersonaRequest",
"type": "object"
},
"ListPersonasResponse": {
"properties": {
"personas": {
"items": {
"$ref": "#/components/schemas/PersonaInfo"
},
"title": "Personas",
"type": "array"
},
"tool_inventory": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"description": "Per-kind builtin tool names (plus the synthetic 'tool_search') for the visibility checklist \u2014 derived server-side so clients never hand-mirror the inventory",
"title": "Tool Inventory",
"type": "object"
}
},
"required": [
"personas"
],
"title": "ListPersonasResponse",
"type": "object"
},
"ModelReloadResponse": {
"properties": {
"status": {
@@ -13306,17 +12795,21 @@
},
"pending_approval": {
"default": false,
"description": "True when at least one approval cycle is live (a gate thread parked awaiting an operator approve/deny). Mirrors the same field on ``DashboardWorkstream`` / cluster live projections so a freshly-loaded chat tab can render the inline approval gate from the detail snapshot before SSE replay arrives.",
"description": "True when the workstream is parked on ``_approval_event`` awaiting an operator approve/deny. Mirrors the same field on ``DashboardWorkstream`` / cluster live projections so a freshly-loaded chat tab can render the inline approval gate from the detail snapshot before SSE replay arrives.",
"title": "Pending Approval",
"type": "boolean"
},
"pending_approval_details": {
"description": "Inline approval payloads, one per live cycle, oldest first \u2014 same shape as ``DashboardWorkstream.pending_approval_details``. Empty when no approval is pending. Lets a reload paint every action row + judge verdicts immediately instead of relying on the SSE approve_request replay timing window. Replaces 1.6's ``pending_approval_detail`` single-object field (breaking, 1.7).",
"items": {
"$ref": "#/components/schemas/PendingApprovalDetail"
},
"title": "Pending Approval Details",
"type": "array"
"pending_approval_detail": {
"anyOf": [
{
"$ref": "#/components/schemas/PendingApprovalDetail"
},
{
"type": "null"
}
],
"default": null,
"description": "Inline approval payload \u2014 same shape as ``DashboardWorkstream.pending_approval_detail``. ``None`` when no approval is pending. Lets a reload paint the action row + judge verdicts immediately instead of relying on the SSE approve_request replay timing window."
}
},
"required": [
@@ -13329,14 +12822,8 @@
"type": "object"
},
"PendingApprovalDetail": {
"description": "Inline approval payload merged into ``DashboardWorkstream``.\n\nOne entry per live approval CYCLE \u2014 a gate thread parked in\n``approve_tools`` awaiting the operator. Parallel task agents run\nconcurrent gates, so a workstream can have several of these at\nonce (``pending_approval_details``, oldest first). Cross-tenant\nexposure here follows the same trusted-team posture as\n``activity`` / ``tokens`` \u2014 see ``server.py``'s ``dashboard``\nhandler comment.",
"description": "Inline approval payload merged into ``DashboardWorkstream``.\n\nSet when a workstream's ``approve_tools`` is parked on\n``_approval_event``; ``None`` (omitted) otherwise. Cross-tenant\nexposure here follows the same trusted-team posture as\n``activity`` / ``tokens`` \u2014 see ``server.py``'s ``dashboard``\nhandler comment.",
"properties": {
"cycle_id": {
"default": "",
"description": "Identity of this approval cycle. Echo it back on ``POST /v1/api/workstreams/{ws_id}/approve`` to resolve exactly this round \u2014 required for correctness when several cycles are live (parallel task agents).",
"title": "Cycle Id",
"type": "string"
},
"call_id": {
"default": "",
"description": "Primary call_id \u2014 first non-empty call_id in items list order. Matches the 409 ``current_call_id`` response from ``POST /v1/api/workstreams/{ws_id}/approve`` so the UI can render the same identifier the server reports as current.",
@@ -13361,7 +12848,7 @@
"type": "object"
},
"PendingApprovalItem": {
"description": "One pending tool-call inside a ``PendingApprovalDetail`` envelope.\n\nMirrors the dict ``SessionUIBase.serialize_pending_approval_details``\nemits per item inside each cycle entry. ``heuristic_verdict`` / ``judge_verdict`` are kept\nloosely-typed because the underlying verdict shape varies by tier;\nconsumers that want the full structure can decode against\n:class:`turnstone.sdk.events.IntentVerdictEvent`.",
"description": "One pending tool-call inside a ``PendingApprovalDetail`` envelope.\n\nMirrors the dict ``SessionUIBase.serialize_pending_approval_detail``\nemits per item. ``heuristic_verdict`` / ``judge_verdict`` are kept\nloosely-typed because the underlying verdict shape varies by tier;\nconsumers that want the full structure can decode against\n:class:`turnstone.sdk.events.IntentVerdictEvent`.",
"properties": {
"call_id": {
"default": "",
+26 -151
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "1.7.0rc1",
"version": "1.7.0a2",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -1443,27 +1443,6 @@
}
}
},
"/v1/api/personas": {
"get": {
"summary": "List enabled personas for the workstream-creation picker",
"operationId": "v1_api_personas_get",
"tags": [
"Personas"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListPersonaChoicesResponse"
}
}
}
}
}
}
},
"/v1/api/models": {
"get": {
"summary": "List available model aliases",
@@ -2446,12 +2425,6 @@
"title": "Skill",
"type": "string"
},
"persona": {
"default": "",
"description": "Persona name (slug) to create the workstream with. Resolved and snapshotted at creation \u2014 later persona edits never affect this workstream. Empty selects the kind's default persona; on a database with no personas seeded the workstream is created with legacy (unrestricted) behavior.",
"title": "Persona",
"type": "string"
},
"notify_targets": {
"anyOf": [
{
@@ -2564,23 +2537,6 @@
},
"title": "Attachment Ids",
"type": "array"
},
"initial_message_status": {
"anyOf": [
{
"enum": [
"queue_full",
"refused_closed"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Present ONLY when the workstream was created but its initial_message could not be delivered: 'queue_full' (a raced live worker's interjection queue was at capacity \u2014 resend via /send; any uploads stay staged) or 'refused_closed' (the workstream was closed mid-create). Absent whenever the message was dispatched.",
"title": "Initial Message Status"
}
},
"required": [
@@ -2709,17 +2665,21 @@
},
"pending_approval": {
"default": false,
"description": "True when at least one approval cycle is live (a gate thread parked awaiting an operator approve/deny). Mirrors the same field on ``DashboardWorkstream`` / cluster live projections so a freshly-loaded chat tab can render the inline approval gate from the detail snapshot before SSE replay arrives.",
"description": "True when the workstream is parked on ``_approval_event`` awaiting an operator approve/deny. Mirrors the same field on ``DashboardWorkstream`` / cluster live projections so a freshly-loaded chat tab can render the inline approval gate from the detail snapshot before SSE replay arrives.",
"title": "Pending Approval",
"type": "boolean"
},
"pending_approval_details": {
"description": "Inline approval payloads, one per live cycle, oldest first \u2014 same shape as ``DashboardWorkstream.pending_approval_details``. Empty when no approval is pending. Lets a reload paint every action row + judge verdicts immediately instead of relying on the SSE approve_request replay timing window. Replaces 1.6's ``pending_approval_detail`` single-object field (breaking, 1.7).",
"items": {
"$ref": "#/components/schemas/PendingApprovalDetail"
},
"title": "Pending Approval Details",
"type": "array"
"pending_approval_detail": {
"anyOf": [
{
"$ref": "#/components/schemas/PendingApprovalDetail"
},
{
"type": "null"
}
],
"default": null,
"description": "Inline approval payload \u2014 same shape as ``DashboardWorkstream.pending_approval_detail``. ``None`` when no approval is pending. Lets a reload paint the action row + judge verdicts immediately instead of relying on the SSE approve_request replay timing window."
}
},
"required": [
@@ -2732,14 +2692,8 @@
"type": "object"
},
"PendingApprovalDetail": {
"description": "Inline approval payload merged into ``DashboardWorkstream``.\n\nOne entry per live approval CYCLE \u2014 a gate thread parked in\n``approve_tools`` awaiting the operator. Parallel task agents run\nconcurrent gates, so a workstream can have several of these at\nonce (``pending_approval_details``, oldest first). Cross-tenant\nexposure here follows the same trusted-team posture as\n``activity`` / ``tokens`` \u2014 see ``server.py``'s ``dashboard``\nhandler comment.",
"description": "Inline approval payload merged into ``DashboardWorkstream``.\n\nSet when a workstream's ``approve_tools`` is parked on\n``_approval_event``; ``None`` (omitted) otherwise. Cross-tenant\nexposure here follows the same trusted-team posture as\n``activity`` / ``tokens`` \u2014 see ``server.py``'s ``dashboard``\nhandler comment.",
"properties": {
"cycle_id": {
"default": "",
"description": "Identity of this approval cycle. Echo it back on ``POST /v1/api/workstreams/{ws_id}/approve`` to resolve exactly this round \u2014 required for correctness when several cycles are live (parallel task agents).",
"title": "Cycle Id",
"type": "string"
},
"call_id": {
"default": "",
"description": "Primary call_id \u2014 first non-empty call_id in items list order. Matches the 409 ``current_call_id`` response from ``POST /v1/api/workstreams/{ws_id}/approve`` so the UI can render the same identifier the server reports as current.",
@@ -2764,7 +2718,7 @@
"type": "object"
},
"PendingApprovalItem": {
"description": "One pending tool-call inside a ``PendingApprovalDetail`` envelope.\n\nMirrors the dict ``SessionUIBase.serialize_pending_approval_details``\nemits per item inside each cycle entry. ``heuristic_verdict`` / ``judge_verdict`` are kept\nloosely-typed because the underlying verdict shape varies by tier;\nconsumers that want the full structure can decode against\n:class:`turnstone.sdk.events.IntentVerdictEvent`.",
"description": "One pending tool-call inside a ``PendingApprovalDetail`` envelope.\n\nMirrors the dict ``SessionUIBase.serialize_pending_approval_detail``\nemits per item. ``heuristic_verdict`` / ``judge_verdict`` are kept\nloosely-typed because the underlying verdict shape varies by tier;\nconsumers that want the full structure can decode against\n:class:`turnstone.sdk.events.IntentVerdictEvent`.",
"properties": {
"call_id": {
"default": "",
@@ -3023,13 +2977,17 @@
"default": null,
"title": "Project Id"
},
"pending_approval_details": {
"description": "Inline approval payload for the coordinator children-tree UI: EVERY live approval cycle, oldest first \u2014 parallel task agents gate concurrently, so a workstream can hold several prompts at once. Each entry carries the cycle's items + per-call_id LLM verdict cache so a coord can render approve/deny buttons + judge pill without a separate per-child round-trip; resolve each with its ``cycle_id``. Empty when no approval is pending. Also surfaced (verbatim) on ``GET /v1/api/cluster/ws/live`` via the ``_CLUSTER_WS_LIVE_KEYS`` projection. Replaces 1.6's ``pending_approval_detail`` single-object field (breaking, 1.7).",
"items": {
"$ref": "#/components/schemas/PendingApprovalDetail"
},
"title": "Pending Approval Details",
"type": "array"
"pending_approval_detail": {
"anyOf": [
{
"$ref": "#/components/schemas/PendingApprovalDetail"
},
{
"type": "null"
}
],
"default": null,
"description": "Inline approval payload for the coordinator children-tree UI. Carries the merged ``_pending_approval`` items list + per-call_id LLM verdict cache so a coord can render approve/deny buttons + judge pill without a separate per-child round-trip. ``None`` when no approval is pending. Also surfaced (verbatim) on ``GET /v1/api/cluster/ws/live`` via the ``_CLUSTER_WS_LIVE_KEYS`` projection."
},
"recent_auto_approvals": {
"description": "Per-ws ring buffer (cap 10) of recent tool calls that bypassed the operator approval gate. Surfaces ``WebUI._recent_auto_approvals`` so the coord-tree row can render an 'auto-approved by ...' pill when the child's skill / blanket / admin-policy rules silently let a tool through. Also projected onto ``GET /v1/api/cluster/ws/live`` via ``_CLUSTER_WS_LIVE_KEYS``.",
@@ -3192,30 +3150,6 @@
"default": 0.0,
"title": "Context Ratio",
"type": "number"
},
"project_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Project Id"
},
"persona": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Persona"
}
},
"required": [
@@ -3804,65 +3738,6 @@
"title": "ListSkillSummaryResponse",
"type": "object"
},
"PersonaChoice": {
"description": "Display fields for the creation picker \u2014 the persona's levers\n(prompt / tool set / toggles) deliberately stay server-side.",
"properties": {
"name": {
"description": "Persona slug, the value to pass as CreateWorkstreamRequest.persona",
"title": "Name",
"type": "string"
},
"display_name": {
"default": "",
"description": "Human-readable name",
"title": "Display Name",
"type": "string"
},
"description": {
"default": "",
"description": "What this persona is for",
"title": "Description",
"type": "string"
},
"applies_to_kinds": {
"description": "Workstream kinds this persona can be attached to",
"items": {
"type": "string"
},
"title": "Applies To Kinds",
"type": "array"
},
"is_default": {
"default": false,
"description": "Whether an empty persona field resolves to this one",
"title": "Is Default",
"type": "boolean"
}
},
"required": [
"name"
],
"title": "PersonaChoice",
"type": "object"
},
"ListPersonaChoicesResponse": {
"properties": {
"personas": {
"items": {
"$ref": "#/components/schemas/PersonaChoice"
},
"title": "Personas",
"type": "array"
},
"total": {
"default": 0,
"title": "Total",
"type": "integer"
}
},
"title": "ListPersonaChoicesResponse",
"type": "object"
},
"AvailableModelInfo": {
"properties": {
"alias": {
+50 -50
View File
@@ -409,16 +409,16 @@
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -427,13 +427,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -454,9 +454,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -467,13 +467,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
@@ -481,14 +481,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -497,9 +497,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -507,13 +507,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -949,9 +949,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1122,9 +1122,9 @@
}
},
"node_modules/vite": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
"integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1200,19 +1200,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -1240,12 +1240,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
-20
View File
@@ -75,35 +75,15 @@ export interface ToolInfoEvent {
items: Array<Record<string, unknown>>;
}
/** One approval CYCLE awaiting the operator. Several can be outstanding
* at once (parallel task agents each gate their own tool calls) key
* prompt UI by `cycle_id` and echo it back on the approve POST.
*
* `cycle_id` is optional because it was added in 1.7: a pre-1.7 server
* omits it on the wire, so a current SDK talking to an older node sees
* `undefined`. Resolve those the legacy way (no selector oldest
* cycle). A current server always sends it. */
export interface ApproveRequestEvent {
type: "approve_request";
cycle_id?: string;
items: Array<Record<string, unknown>>;
judge_pending?: boolean;
}
/** A specific approval cycle resolved; `cycle_id`/`call_ids` identify
* which prompt to dismiss.
*
* Both are optional for the same reason as `ApproveRequestEvent.cycle_id`
* a pre-1.7 server emits neither, so a bare "something resolved"
* dismisses the sole tracked prompt (the legacy fallback the UI and
* channel adapters keep). A current server always sends both. */
export interface ApprovalResolvedEvent {
type: "approval_resolved";
approved: boolean;
feedback: string;
always?: boolean;
cycle_id?: string;
call_ids?: string[];
}
export interface ToolResultEvent {
-9
View File
@@ -166,13 +166,6 @@ export class TurnstoneServer extends BaseClient {
approved?: boolean;
feedback?: string | null;
always?: boolean;
/** Resolve exactly this approval cycle (from ApproveRequestEvent.cycle_id).
* Omitting it resolves the OLDEST live cycle ambiguous when parallel
* task agents have several prompts outstanding, so pass it whenever the
* triggering event is known. */
cycleId?: string;
/** Alternative selector: any call_id inside the target cycle. */
callId?: string;
}): Promise<StatusResponse> {
return this.request(
"POST",
@@ -182,8 +175,6 @@ export class TurnstoneServer extends BaseClient {
approved: opts.approved ?? true,
feedback: opts.feedback,
always: opts.always,
cycle_id: opts.cycleId,
call_id: opts.callId,
},
},
);
-17
View File
@@ -130,12 +130,6 @@ export interface CreateWorkstreamRequest {
auto_approve?: boolean;
resume_ws?: string;
skill?: string;
/**
* Persona name (slug) to create the workstream with. Resolved and
* snapshotted at creation later persona edits never affect this
* workstream. Empty selects the kind's default persona.
*/
persona?: string;
/**
* Optional project to attach this workstream to. Drives the shared
* `project` memory scope; coordinator children inherit the parent's project.
@@ -164,13 +158,6 @@ export interface CreateWorkstreamResponse {
message_count?: number;
/** Ids of attachments saved by this request (multipart variant only). */
attachment_ids?: string[];
/**
* Present ONLY when the workstream was created but its initial_message
* could not be delivered: "queue_full" (raced live worker's interjection
* queue at capacity resend via /send; uploads stay staged) or
* "refused_closed" (workstream closed mid-create).
*/
initial_message_status?: "queue_full" | "refused_closed";
}
export interface CloseWorkstreamRequest {
@@ -269,8 +256,6 @@ export interface SavedWorkstreamInfo {
child_count?: number;
context_tokens?: number;
context_ratio?: number;
/** Persona slug the workstream was created with (empty/absent = pre-persona). */
persona?: string | null;
}
export interface ListSavedWorkstreamsResponse {
@@ -539,8 +524,6 @@ export interface ConsoleCreateWsRequest {
model?: string;
initial_message?: string;
skill?: string;
/** Persona slug — resolved and snapshotted at creation. */
persona?: string;
resume_ws?: string;
}
@@ -104,6 +104,7 @@ describe("TurnstoneServer attachments", () => {
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toEqual({
message: "hi",
ws_id: "ws-X",
attachment_ids: ["a1", "a2"],
});
});
@@ -116,7 +117,7 @@ describe("TurnstoneServer attachments", () => {
});
await client.send("hi", "ws-X");
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toEqual({ message: "hi" });
expect(JSON.parse(init.body)).toEqual({ message: "hi", ws_id: "ws-X" });
});
it("createWorkstream with attachments sends multipart and auto-generates ws_id", async () => {
+2 -2
View File
@@ -74,8 +74,8 @@ describe("TurnstoneServer", () => {
await client.send("Hello", "ws1");
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://test/v1/api/workstreams/ws1/send");
expect(JSON.parse(init.body)).toEqual({ message: "Hello" });
expect(url).toBe("http://test/v1/api/send");
expect(JSON.parse(init.body)).toEqual({ message: "Hello", ws_id: "ws1" });
});
it("injects auth header when token provided", async () => {
+1 -29
View File
@@ -3,37 +3,9 @@ not fixtures, and several test files want to import them directly."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any
from typing import Any
from unittest.mock import MagicMock
if TYPE_CHECKING:
from collections.abc import Callable
def wait_until(cond: Callable[[], bool], timeout: float = 5.0) -> None:
"""Poll ``cond`` to True within ``timeout`` or fail the test.
The worker/wake tests can't join threads by identity:
``session_worker.send`` assigns ``ws.worker_thread`` under the lock
BEFORE ``t.start()``, so the instant a dispatching call returns, a
fast worker may already have run its exit backstop and installed the
(not-yet-started) wake thread joining whatever ``ws.worker_thread``
points at races ``RuntimeError: cannot join thread before it is
started``. Poll outcomes instead.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if cond():
return
time.sleep(0.005)
if cond():
# Final re-check: the condition can become true during the last
# sleep (or a CI descheduling stall past the deadline) — failing
# without re-looking makes the helper itself a flake source.
return
raise AssertionError("condition not met within timeout")
def make_chat_session(**overrides: Any) -> Any:
"""Build a minimal ``ChatSession`` with sane test defaults.
-43
View File
@@ -1,43 +0,0 @@
"""Shared process/polling helpers for the bash + background-shell suites.
One copy instead of three: ``test_bash_tool_background_hang``,
``test_background_shells`` and ``test_bash_background_tool`` all assert on
process liveness and poll for asynchronous state. Leading underscore so
pytest doesn't collect it.
"""
from __future__ import annotations
import contextlib
import os
import signal
import time
def pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def kill_pid(pid: int) -> None:
with contextlib.suppress(OSError):
os.kill(pid, signal.SIGKILL)
def poll_until(predicate, timeout=10.0, interval=0.05):
"""Poll ``predicate`` until truthy or ``timeout``; RETURNS the last value
(falsy on timeout assert at the call site). Deliberately named apart
from ``tests/_helpers.wait_until``, which RAISES on timeout: two
same-named helpers with opposite failure semantics invite silently-green
tests."""
deadline = time.monotonic() + timeout
value = predicate()
while not value and time.monotonic() < deadline:
time.sleep(interval)
value = predicate()
return value
-6
View File
@@ -51,12 +51,6 @@ def make_replay_mocks(
ui._ws_messages = 0
for key, value in ui_overrides.items():
setattr(ui, key, value)
# Both replay paths read cycle cards via ``pending_approval_cards()``
# (one card per concurrent approval cycle). Model it from the
# single-slot ``_pending_approval`` override so tests keep seeding
# the one field; a bare MagicMock here would iterate empty and
# silently drop the approve_request from the replay.
ui.pending_approval_cards = lambda: [ui._pending_approval] if ui._pending_approval else []
ws = MagicMock()
ws.session = session
request = MagicMock()
-76
View File
@@ -1,76 +0,0 @@
"""Recording fake SDK client — captures the kwargs at each provider's seam.
Every provider's ``create_streaming`` assembles its kwargs and calls the
SDK *eagerly* before returning the stream iterator (Anthropic
``client.messages.stream``, OpenAI ``client.chat.completions.create``,
Responses ``client.responses.create/stream``), so driving a provider
against a :class:`RecordingClient` captures the full composed request
payload without a network round-trip.
Shared by the wire-payload golden harness (``test_wire_payload_golden``)
and the effort-ladder parity harness (``test_effort_ladder_wire_parity``)
so both assert against the same capture seam.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
class _EmptyStream:
"""Stand-in for an SDK stream / stream-manager: empty iterable AND no-op CM."""
def __iter__(self) -> Iterator[Any]:
return iter(())
def __enter__(self) -> _EmptyStream:
return self
def __exit__(self, *exc: object) -> None:
return None
class _Seam:
"""Records the kwargs of a single SDK call, returns an empty stream stub."""
def __init__(self, sink: dict[str, Any]) -> None:
self._sink = sink
def __call__(self, **kwargs: Any) -> _EmptyStream:
# Last write wins; only one seam is exercised per provider call.
self._sink["payload"] = kwargs
return _EmptyStream()
class _Completions:
def __init__(self, sink: dict[str, Any]) -> None:
self.create = _Seam(sink)
class _Chat:
def __init__(self, sink: dict[str, Any]) -> None:
self.completions = _Completions(sink)
class _Messages:
def __init__(self, sink: dict[str, Any]) -> None:
self.stream = _Seam(sink)
class _Responses:
def __init__(self, sink: dict[str, Any]) -> None:
self.create = _Seam(sink)
self.stream = _Seam(sink)
class RecordingClient:
"""Fake SDK client exposing every provider's call seam, recording kwargs."""
def __init__(self) -> None:
self.captured: dict[str, Any] = {}
self.messages = _Messages(self.captured)
self.chat = _Chat(self.captured)
self.responses = _Responses(self.captured)
+1 -69
View File
@@ -52,76 +52,8 @@ def serve_until_exit(server: Any) -> None:
loop.close()
class _PendingResolver:
"""Race-free drop-in for ``threading.Timer(delay, ui.resolve_approval)``.
``approve_tools`` runs ``_approval_event.clear()`` -> register
``_pending_approval`` -> ``_approval_event.wait(_APPROVAL_WAIT_TIMEOUT)``
(3600s). A *fixed-delay* timer can fire ``resolve_approval``
(``_approval_event.set()``) BEFORE that ``.clear()`` on a slow/loaded
runner, so the set is wiped by the clear and ``approve_tools`` blocks the
full hour -- surfacing as a CI hang. This instead waits until the approval
is actually registered (which happens *after* the clear), then resolves, so
the wakeup can never be lost. ``start()`` / ``cancel()`` mirror
``threading.Timer`` so it drops into existing scaffolding. ``cancel()``
signals the worker to stop and joins it, so a test that errors *before* the
approval registers can't leak the thread or resolve late into a finished
test. ``before`` runs just before resolving -- e.g. to snapshot
pending-state fields the test asserts on.
"""
def __init__(
self,
ui: Any,
*args: Any,
before: Callable[[], None] | None = None,
deadline: float = 10.0,
**kwargs: Any,
) -> None:
self._ui = ui
self._args = args
self._kwargs = kwargs
self._before = before
self._deadline = deadline
self._cancelled = threading.Event()
self._started = False
self._thread = threading.Thread(target=self._run, name="resolve-when-pending", daemon=True)
def _run(self) -> None:
end = time.monotonic() + self._deadline
while time.monotonic() < end:
if self._cancelled.is_set():
return
# getattr (not a bare read) so a UI without _pending_approval can't
# crash the worker into a silent death that leaves approve_tools
# blocked for the full _APPROVAL_WAIT_TIMEOUT.
if getattr(self._ui, "_pending_approval", None) is not None:
if self._before is not None:
self._before()
self._ui.resolve_approval(*self._args, **self._kwargs)
return
time.sleep(0.001)
# Deadline without registration: approve_tools isn't parked on the
# approval event (returned early, or never reached it) -- don't resolve
# into an unknown state; let the test's own assertions speak.
def start(self) -> None:
self._started = True
self._thread.start()
def cancel(self) -> None:
self._cancelled.set()
if self._started:
self._thread.join(timeout=5)
def resolve_when_pending(ui: Any, *args: Any, **kwargs: Any) -> _PendingResolver:
"""Build a race-free approval resolver (see :class:`_PendingResolver`)."""
return _PendingResolver(ui, *args, **kwargs)
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from collections.abc import Iterator
from turnstone.core.mcp_client import MCPClientManager, StaticServerState
from turnstone.core.mcp_crypto import MCPTokenCipher
@@ -1,78 +0,0 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris and London?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
},
{
"id": "call_2",
"input": {
"city": "London"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
},
{
"content": "Tool execution was cancelled. Outcome UNKNOWN — this call may have begun executing before the generation was stopped; do not assume it did not run, and reconcile before re-issuing it.",
"is_error": true,
"tool_use_id": "call_2",
"type": "tool_result"
},
{
"text": "Actually, never mind London.",
"type": "text"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"temperature": 0.5,
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -1,33 +0,0 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": [
{
"text": "What's in this image?",
"type": "text"
},
{
"source": {
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"media_type": "image/png",
"type": "base64"
},
"type": "image"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"temperature": 0.5
}
@@ -1,70 +0,0 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": [
{
"signature": "sig-abc",
"thinking": "The user wants weather.",
"type": "thinking"
},
{
"text": "Let me check.",
"type": "text"
},
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "Tool execution was cancelled. Outcome UNKNOWN — this call may have begun executing before the generation was stopped; do not assume it did not run, and reconcile before re-issuing it.",
"is_error": true,
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"temperature": 0.5,
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -1,69 +0,0 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": [
{
"signature": "sig-abc",
"thinking": "The user wants weather.",
"type": "thinking"
},
{
"text": "Let me check.",
"type": "text"
},
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"temperature": 0.5,
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -1,63 +0,0 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Run the deploy.",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {},
"name": "deploy",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "deployed",
"tool_use_id": "call_1",
"type": "tool_result"
},
{
"text": "Great, what's next?",
"type": "text"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"system": "Output-guard: deploy output looked clean.",
"temperature": 0.5,
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -1,33 +0,0 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Hi there.",
"role": "user"
},
{
"content": [
{
"text": "Hello! How can I help?",
"type": "text"
}
],
"role": "assistant"
},
{
"content": "What's the weather in Paris?",
"role": "user"
}
],
"model": "qwen3.6-27b",
"temperature": 0.5
}
@@ -1,69 +0,0 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
},
{
"content": [
{
"text": "It's 18C and clear in Paris.",
"type": "text"
}
],
"role": "assistant"
}
],
"model": "qwen3.6-27b",
"temperature": 0.5,
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -1,61 +0,0 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "Tool execution was cancelled. Outcome UNKNOWN — this call may have begun executing before the generation was stopped; do not assume it did not run, and reconcile before re-issuing it.",
"is_error": true,
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"temperature": 0.5,
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -43,7 +43,6 @@
}
],
"model": "gemini-2.5-pro",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -18,7 +18,6 @@
}
],
"model": "gemini-2.5-pro",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -26,7 +26,6 @@
}
],
"model": "gemini-2.5-pro",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -26,7 +26,6 @@
}
],
"model": "gemini-2.5-pro",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -34,7 +34,6 @@
}
],
"model": "gemini-2.5-pro",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -15,7 +15,6 @@
}
],
"model": "gemini-2.5-pro",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -30,7 +30,6 @@
}
],
"model": "gemini-2.5-pro",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -26,7 +26,6 @@
}
],
"model": "gemini-2.5-pro",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -43,7 +43,6 @@
}
],
"model": "gpt-4o-mini",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -18,7 +18,6 @@
}
],
"model": "gpt-4o-mini",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -26,7 +26,6 @@
}
],
"model": "gpt-4o-mini",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -26,7 +26,6 @@
}
],
"model": "gpt-4o-mini",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -34,7 +34,6 @@
}
],
"model": "gpt-4o-mini",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -15,7 +15,6 @@
}
],
"model": "gpt-4o-mini",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -30,7 +30,6 @@
}
],
"model": "gpt-4o-mini",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -26,7 +26,6 @@
}
],
"model": "gpt-4o-mini",
"reasoning_effort": "medium",
"stream": true,
"stream_options": {
"include_usage": true
@@ -1,32 +0,0 @@
{
"include": [
"reasoning.encrypted_content"
],
"input": [
{
"content": "Hi there.",
"role": "user",
"type": "message"
},
{
"content": "Hello! How can I help?",
"role": "assistant",
"type": "message"
},
{
"content": "What's the weather in Paris?",
"role": "user",
"type": "message"
}
],
"max_output_tokens": 4096,
"model": "gpt-5.6-sol",
"prompt_cache_options": {
"ttl": "30m"
},
"reasoning": {
"effort": "max"
},
"store": false,
"stream": true
}
@@ -1,57 +0,0 @@
{
"include": [
"reasoning.encrypted_content"
],
"input": [
{
"content": "Weather in Paris?",
"role": "user",
"type": "message"
},
{
"arguments": "{\"city\": \"Paris\"}",
"call_id": "call_1",
"name": "get_weather",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "18C, clear.",
"type": "function_call_output"
},
{
"content": "It's 18C and clear in Paris.",
"role": "assistant",
"type": "message"
}
],
"max_output_tokens": 4096,
"model": "gpt-5.6-sol",
"prompt_cache_options": {
"ttl": "30m"
},
"reasoning": {
"effort": "max"
},
"store": false,
"stream": true,
"tools": [
{
"description": "Look up the weather for a city.",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
}
@@ -1,36 +0,0 @@
{
"include": [
"reasoning.encrypted_content"
],
"input": [
{
"content": "Hi there.",
"role": "user",
"type": "message"
},
{
"content": "Hello! How can I help?",
"role": "assistant",
"type": "message"
},
{
"content": "What's the weather in Paris?",
"role": "user",
"type": "message"
}
],
"max_output_tokens": 4096,
"model": "gpt-5.6-sol",
"prompt_cache_options": {
"ttl": "30m"
},
"reasoning": {
"effort": "high",
"mode": "pro"
},
"store": false,
"stream": true,
"text": {
"verbosity": "low"
}
}
-170
View File
@@ -1,170 +0,0 @@
"""Tests for ``turnstone-admin create-admin`` (issue #824).
``create-user`` creates a role-less user; the web UI derives a login's scopes
purely from assigned roles, so that account logs in read-only and hits
"Forbidden: token lacks 'approve' scope" on any admin action. ``create-admin``
assigns the built-in admin role mirroring the web setup wizard
(``POST /api/auth/setup``) and promotes an existing role-less user, which is
the recovery path for anyone already stuck.
Each test drives the real ``_cmd_create_admin`` handler against a real,
fully-migrated SQLite DB: the ``builtin-admin`` role is seeded by migration
008, so the DB must be migrated (not just ``create_all``-built) for the role
to exist.
"""
from __future__ import annotations
import argparse
from typing import TYPE_CHECKING, Any
import pytest
from turnstone.admin import _cmd_create_admin, _cmd_create_user
from turnstone.core.auth import _load_user_permissions, _permissions_to_scopes
from turnstone.core.storage import init_storage, reset_storage
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture(autouse=True)
def _reset_storage_singleton() -> Iterator[None]:
"""Keep the module-global storage singleton from leaking across tests."""
reset_storage()
yield
reset_storage()
def _db_args(db_path: str, **overrides: Any) -> argparse.Namespace:
"""Build the Namespace ``_cmd_create_admin`` (and ``_cmd_create_user``) expect.
Pins every DB field so ``_get_storage`` resolves to the tmp sqlite file and
never leaks a ``TURNSTONE_DB_*`` env var (it only falls back when the attr
``is None``). ``token``/``scopes`` are only read by ``_cmd_create_user``.
"""
base: dict[str, Any] = {
"username": "admin",
"name": "",
"password": "",
"token": False,
"scopes": "read,write,approve",
"db_backend": "sqlite",
"db_path": db_path,
"db_url": "",
"db_pool_size": 2,
"db_sslmode": "",
"db_sslrootcert": "",
"db_sslcert": "",
"db_sslkey": "",
}
base.update(overrides)
return argparse.Namespace(**base)
def _migrated_storage(db_path: str) -> Any:
"""Return a fully-migrated storage singleton (seeds the ``builtin-admin`` role)."""
return init_storage("sqlite", path=db_path, run_migrations=True)
def _has_admin_role(storage: Any, user_id: str) -> bool:
return any(r.get("role_id") == "builtin-admin" for r in storage.list_user_roles(user_id))
def _login_scopes(storage: Any, user_id: str) -> frozenset[str]:
"""Scopes a password login would grant this user — the real lockout surface."""
return _permissions_to_scopes(_load_user_permissions(storage, user_id))
def test_create_admin_fresh_user_gets_approve_scope(tmp_path: Path) -> None:
db_path = str(tmp_path / "admin.db")
storage = _migrated_storage(db_path)
_cmd_create_admin(_db_args(db_path, username="admin", name="Admin", password="hunter2!pw"))
user = storage.get_user_by_username("admin")
assert user is not None
assert _has_admin_role(storage, user["user_id"])
# The exact bug surface: a web login for this account must carry `approve`.
assert "approve" in _login_scopes(storage, user["user_id"])
def test_create_admin_defaults_display_name_to_username(tmp_path: Path) -> None:
db_path = str(tmp_path / "admin.db")
storage = _migrated_storage(db_path)
_cmd_create_admin(_db_args(db_path, username="root", name="", password="hunter2!pw"))
user = storage.get_user_by_username("root")
assert user is not None
assert user["display_name"] == "root"
def test_create_admin_promotes_existing_read_only_user(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Issue #824 recovery path: a role-less create-user account, then create-admin."""
db_path = str(tmp_path / "admin.db")
storage = _migrated_storage(db_path)
# Reproduce the locked-out account exactly (role-less create-user).
_cmd_create_user(_db_args(db_path, username="admin", name="Admin", password="hunter2!pw"))
user = storage.get_user_by_username("admin")
assert user is not None
assert not _has_admin_role(storage, user["user_id"])
assert "approve" not in _login_scopes(storage, user["user_id"]) # locked out
# Unstick without recreating the user.
_cmd_create_admin(_db_args(db_path, username="admin"))
assert _has_admin_role(storage, user["user_id"])
assert "approve" in _login_scopes(storage, user["user_id"])
assert "Granted the admin role" in capsys.readouterr().out
def test_create_admin_already_admin_is_idempotent(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
db_path = str(tmp_path / "admin.db")
storage = _migrated_storage(db_path)
_cmd_create_admin(_db_args(db_path, username="admin", name="Admin", password="hunter2!pw"))
capsys.readouterr() # drop first-run output
_cmd_create_admin(_db_args(db_path, username="admin"))
user = storage.get_user_by_username("admin")
assert user is not None
admin_rows = [
r for r in storage.list_user_roles(user["user_id"]) if r.get("role_id") == "builtin-admin"
]
assert len(admin_rows) == 1 # not duplicated
assert "already an admin" in capsys.readouterr().out
def test_create_admin_short_password_rejected(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
db_path = str(tmp_path / "admin.db")
storage = _migrated_storage(db_path)
with pytest.raises(SystemExit) as exc_info:
_cmd_create_admin(_db_args(db_path, username="admin", name="Admin", password="short"))
assert exc_info.value.code == 1
assert "at least 8" in capsys.readouterr().err
assert storage.get_user_by_username("admin") is None # nothing created
def test_create_admin_invalid_username_rejected(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
db_path = str(tmp_path / "admin.db")
_migrated_storage(db_path)
with pytest.raises(SystemExit) as exc_info:
_cmd_create_admin(_db_args(db_path, username="bad user!", name="X", password="hunter2!pw"))
assert exc_info.value.code == 1
assert "invalid username" in capsys.readouterr().err
+23 -553
View File
@@ -9,8 +9,6 @@ manual testing.
from __future__ import annotations
import json
import os
import re
import subprocess
from pathlib import Path
@@ -19,12 +17,6 @@ import pytest
_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/ui/static/app.js"
_INTERACTIVE_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/interactive.js"
_SHELL_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/shell.js"
_REDACT_CREDENTIALS_JS = (
Path(__file__).resolve().parent.parent / "turnstone/shared_static/redact_credentials.js"
)
_CONSOLE_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/app.js"
_CONSOLE_INDEX = Path(__file__).resolve().parent.parent / "turnstone/console/static/index.html"
def _pane_method_offset(body: str, name: str) -> int:
@@ -563,6 +555,7 @@ _CONSOLE_ADMIN_JS = Path(__file__).resolve().parent.parent / "turnstone/console/
_CONSOLE_GOVERNANCE_JS = (
Path(__file__).resolve().parent.parent / "turnstone/console/static/governance.js"
)
_CONSOLE_INTERACTIVE_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/app.js"
_UNSAFE_CODE_SINK_LINT_TARGETS = [
@@ -573,7 +566,7 @@ _UNSAFE_CODE_SINK_LINT_TARGETS = [
("turnstone/console/static/coordinator/coordinator.js", _COORD_JS),
("turnstone/console/static/admin.js", _CONSOLE_ADMIN_JS),
("turnstone/console/static/governance.js", _CONSOLE_GOVERNANCE_JS),
("turnstone/console/static/app.js", _CONSOLE_APP_JS),
("turnstone/console/static/app.js", _CONSOLE_INTERACTIVE_JS),
]
@@ -677,82 +670,6 @@ def test_audio_roles_gated_to_openai_sdk_providers() -> None:
assert '_providerCarriesAudio((md && md.provider) || "openai")' in body
def test_model_response_controls_are_capability_driven_and_sparse() -> None:
"""The model shelf surfaces Responses-only scalar controls without
hard-coding GPT-5.6 IDs or pinning inherited capability-table values."""
html = _CONSOLE_INDEX.read_text(encoding="utf-8")
admin = _CONSOLE_ADMIN_JS.read_text(encoding="utf-8")
assert 'id="model-response-controls"' in html
assert 'aria-labelledby="model-response-controls-title"' in html
assert 'id="model-output-verbosity"' in html
assert 'for="model-output-verbosity"' in html
assert 'id="model-reasoning-mode"' in html
assert 'for="model-reasoning-mode"' in html
for value in ("low", "medium", "high"):
assert f'<option value="{value}">' in html
for value in ("standard", "pro"):
assert f'<option value="{value}">' in html
assert 'data-cap="supports_verbosity"' in html
assert 'data-cap="supports_pro_mode"' in html
assert '"supports_verbosity"' in admin
assert '"supports_pro_mode"' in admin
surface = _slice_function_body(admin, "_modelUsesResponsesSurface")
assert surface is not None
assert 'provider === "openai"' in surface
assert 'provider === "openai-compatible"' in surface
assert 'value === "responses"' in surface
visibility = _slice_function_body(admin, "_updateModelResponseControls")
assert visibility is not None
assert "_modelGetTile(spec.supportKey)" in visibility
assert 'supportKey: "supports_verbosity"' in admin
assert 'supportKey: "supports_pro_mode"' in admin
assert "gpt-5.6" not in visibility, "visibility must come from capabilities, not model IDs"
assert "function _captureModelResponseControls(" in admin
assert "function _mergeModelResponseControls(" in admin
assert "_captureModelResponseControls(capsObj)" in admin
assert "_mergeModelResponseControls(caps)" in admin
assert "let _modelResponseCaptured = {};" in admin
assert "let _modelResponseDirty = {};" in admin
assert "_modelResponseCaptured[spec.key] = value" in admin
assert "nextIdentity === _modelResponseInitialIdentity" in admin
identity = _slice_function_body(admin, "_modelIdentity")
assert identity is not None
assert 'provider === "openai-compatible"' in identity
assert ': ""' in identity
merge = _slice_function_body(admin, "_mergeModelResponseControls")
assert merge is not None
# The dirty flag (select touched) may only override Advanced JSON for
# the identity that made it dirty — a stale flag from a renamed row
# must not delete a hand-typed JSON key.
assert "if (_modelResponseDirty[spec.key] && sameIdentity) delete caps[spec.key]" in merge
# The captured-value fallback is load-bearing, not a gating bug: a value
# lifted out of the row JSON on edit-open must stay visible and re-save
# for the same identity even when the baseline table says unsupported.
# The baseline arrives async (or never, on the compat lane); yielding to
# it would silently drop the pinned value on an unrelated edit-save.
# Wire safety lives server-side (emission gates on merged supports_*).
for body in (visibility, merge):
assert "_modelGetTile(spec.supportKey) || capturedFallback" in body
assert "sameIdentity" in body
assert "!(spec.supportKey in _modelCapsExplicit)" in body
create = _slice_function_body(admin, "showCreateModelModal")
assert create is not None
assert "_modelCapsSeq++" in create, "a fresh shelf must invalidate prior lookups"
assert "displayCaps.supports_verbosity !== false" in admin
assert "displayCaps.supports_pro_mode !== false" in admin
change = _slice_function_body(admin, "_onModelFieldChange")
assert change is not None
assert "_modelCapsSeq++" in change, "model changes must invalidate in-flight baselines"
assert "_modelCapsBaseline = {}" in change
assert 'apiSurfEl.addEventListener("change", _onModelFieldChange)' in admin
def test_shared_utils_defines_set_markdown_helper() -> None:
"""The ``setMarkdown`` helper in ``shared/utils.js`` is the single
audited entry point for rendering markdown content into a DOM
@@ -1038,7 +955,6 @@ _CONST_GUARD_BUNDLES = _SWEPT_BUNDLES + [
_REPO_ROOT / "turnstone/shared_static/rail.js",
_REPO_ROOT / "turnstone/shared_static/interactive.js",
_REPO_ROOT / "turnstone/shared_static/conversation.js",
_REPO_ROOT / "turnstone/shared_static/redact_credentials.js",
]
@@ -1351,102 +1267,41 @@ def test_swept_bundle_has_no_const_reassign(bundle: Path) -> None:
)
def test_redact_credentials_runtime_smoke() -> None:
"""Runtime smoke for ``redactCredentials`` via a temp harness file.
The function is pure (no DOM dependency). Tests the shared module
directly via ESM import (replaces the legacy ``_redactApiKeys`` test
which now delegates to this).
The tempfile is written with a ``.mjs`` extension so Node forces ESM
parsing regardless of any ``package.json`` ``type`` field in parent
directories. The ``redact_credentials.js`` source file is imported
by absolute path so resolution is unambiguous.
"""
import tempfile
mod_path = _REDACT_CREDENTIALS_JS.resolve()
harness = (
"import { redactCredentials } from "
+ json.dumps(str(mod_path))
+ ";\n"
+ "const q = redactCredentials('https://x?api_key=abc&u=foo');\n"
def test_redact_api_keys_runtime_smoke() -> None:
"""Runtime smoke for ``_redactApiKeys``. The function is pure — no
DOM dependency so it transplants cleanly into a standalone
``node -e`` invocation. This is the bit that would have caught
the original ``const redacted`` bug (which ``node --check`` and a
pure-static keyword scan both miss; the ``TypeError`` only fires
at call-time)."""
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
m = re.search(
r"function _redactApiKeys\(text\) \{.*?\n\}\n",
body,
re.DOTALL,
)
assert m is not None, "_redactApiKeys not found in app.js"
fn = m.group(0)
script = (
fn
+ "\nconst q = _redactApiKeys('https://x?api_key=abc&u=foo');\n"
+ 'if (q !== "https://x?api_key=***&u=foo") '
+ "throw new Error('query-string redact failed: ' + q);\n"
+ 'const j = redactCredentials(\'{"api_key":"abc"}\');\n'
+ 'const j = _redactApiKeys(\'{"api_key":"abc"}\');\n'
+ 'if (j !== \'{"api_key":"***"}\') '
+ "throw new Error('json-style redact failed: ' + j);\n"
+ "// Bearer token redaction (raw input)\n"
+ "const b = redactCredentials('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.test-token_here');\n"
+ "if (!b.includes('[REDACTED:api_key]')) "
+ "throw new Error('bearer redact failed: ' + b);\n"
+ "// Connection string redaction (raw input)\n"
+ "const c = redactCredentials('postgresql://user:supersecret@localhost/db');\n"
+ "if (!c.includes('[REDACTED:password]')) "
+ "throw new Error('conn-string redact failed: ' + c);\n"
+ "// Authorization JSON key redaction (step 6 comprehensive)\n"
+ 'const a = redactCredentials(\'{"Authorization": "Bearer canstillseethis"}\');\n'
+ "if (!a.includes('[REDACTED:secret]')) "
+ "throw new Error('authorization JSON redact failed: ' + a);\n"
+ "// Single-quote JSON (Python dict repr / JS object literal)\n"
+ "const sq = redactCredentials(\"{'Authorization': 'Bearer canstillseethis'}\");\n"
+ "if (!sq.includes('[REDACTED:secret]')) "
+ "throw new Error('single-quote authorization redact failed: ' + sq);\n"
+ "// mongodb+srv connection string (Atlas SRV)\n"
+ "const ms = redactCredentials('mongodb+srv://u:s3cretpw@cluster.mongodb.net/db');\n"
+ "if (!ms.includes('[REDACTED:password]')) "
+ "throw new Error('mongodb+srv redact failed: ' + ms);\n"
+ "// lowercase bearer scheme (RFC 7235 case-insensitive)\n"
+ "const lb = redactCredentials('authorization: bearer "
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig12345');\n"
+ "if (!lb.includes('[REDACTED:api_key]')) "
+ "throw new Error('lowercase bearer redact failed: ' + lb);\n"
+ "// api_key= assignment redacts the whole token, not a garbled api_[REDACTED\n"
+ "const ak = redactCredentials('api_key=abcdefghijklmnopqrstuvwxyz');\n"
+ "if (ak !== '[REDACTED:api_key]') "
+ "throw new Error('api_key= clean redact failed: ' + ak);\n"
+ "// Prefilter fast path: plain text with no anchor substring is unchanged\n"
+ "const fp = redactCredentials('build ok in 42s - 3 tests passed');\n"
+ "if (fp !== 'build ok in 42s - 3 tests passed') "
+ "throw new Error('prefilter fast-path no-op failed: ' + fp);\n"
+ "// Bare credentials with no =, quote or @ anywhere must still redact\n"
+ "// (these pin the prefilter as a superset of the pattern set)\n"
+ "const bk = redactCredentials('loaded sk-abcdefghijklmnopqrstuvwx');\n"
+ "if (bk !== 'loaded [REDACTED:api_key]') "
+ "throw new Error('bare sk- redact failed: ' + bk);\n"
+ "const aw = redactCredentials('using AKIAABCDEFGHIJKLMNOP now');\n"
+ "if (aw !== 'using [REDACTED:api_key] now') "
+ "throw new Error('bare AKIA redact failed: ' + aw);\n"
+ "const bt = redactCredentials('Bearer abcdefghijklmnopqrstuvwxyz');\n"
+ "if (bt !== '[REDACTED:api_key]') "
+ "throw new Error('bare bearer redact failed: ' + bt);\n"
+ "// SQLAlchemy dialect+driver connection URLs (psycopg2/asyncpg)\n"
+ "const pg2 = redactCredentials('postgresql+psycopg2://user:s3cret@db:5432/app');\n"
+ "if (pg2 !== 'postgresql+psycopg2://user:[REDACTED:password]@db:5432/app') "
+ "throw new Error('psycopg2 conn redact failed: ' + pg2);\n"
+ "const apg = redactCredentials('postgresql+asyncpg://user:s3cret@db/app');\n"
+ "if (apg !== 'postgresql+asyncpg://user:[REDACTED:password]@db/app') "
+ "throw new Error('asyncpg conn redact failed: ' + apg);\n"
+ "// RFC 3986 schemes are case-insensitive - uppercase must not bypass\n"
+ "const up = redactCredentials('POSTGRESQL+PSYCOPG2://user:s3cret@db/app');\n"
+ "if (up !== 'POSTGRESQL+PSYCOPG2://user:[REDACTED:password]@db/app') "
+ "throw new Error('uppercase scheme conn redact failed: ' + up);\n"
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".mjs", delete=False) as f:
f.write(harness)
tmp = f.name
try:
proc = subprocess.run(
["node", tmp],
["node", "-e", script],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
pytest.skip("node binary not available on PATH")
finally:
os.unlink(tmp)
assert proc.returncode == 0, (
f"redactCredentials runtime smoke failed. stdout={proc.stdout!r} stderr={proc.stderr!r}"
f"_redactApiKeys runtime smoke failed. stdout={proc.stdout!r} stderr={proc.stderr!r}"
)
@@ -1668,287 +1523,6 @@ def test_coord_connectsse_onerror_preserves_native_reconnect() -> None:
assert passed, f"coordinator.js connectSSE.onerror regressed: {reason}"
# ---------------------------------------------------------------------------
# Coordinator-pane parity for the SSE overflow-recovery companions (issue #806).
# The server-side fixes (emit-time batching, _ListenerQueue poison, out-of-band
# closing) live in SessionUIBase and already cover EVERY SSE stream; these pin
# the CLIENT-side companions ported into coordinator.js so it stops relying on
# native reconnect alone — storm guard + degraded catch-up, close-on-hide /
# replay-on-show, and drop-vs-render-wedge counters.
# ---------------------------------------------------------------------------
def test_coord_imports_shared_overflow_helpers() -> None:
"""coordinator.js consumes the SAME sse_overflow.js helpers as the
interactive pane (over the /shared mount) so the trip threshold and cooldown
ladder cannot drift between the two surfaces."""
body = _COORD_JS.read_text(encoding="utf-8")
m = re.search(
r"import \{([^}]*)\} from \"/shared/sse_overflow\.js\";",
body,
re.S,
)
assert m is not None, "coordinator must import the shared overflow helpers"
imported = m.group(1)
for name in (
"OVERFLOW_TRIP_COUNT",
"OVERFLOW_TRIP_WINDOW_MS",
"DEGRADED_COOLDOWN_BASE_MS",
"DEGRADED_COOLDOWN_MAX_MS",
"DEGRADED_COOLDOWN_RESET_MS",
"overflowWindowTripped",
"degradedCooldownStep",
):
assert name in imported, f"{name} must be imported from /shared/sse_overflow.js"
# No local fork of the extracted pure functions on the coordinator side.
assert not re.search(r"^\s*function overflowWindowTripped\(", body, re.M)
assert not re.search(r"^\s*function degradedCooldownStep\(", body, re.M)
def test_coord_stream_overflow_case_counts_and_rate_limits() -> None:
"""The coordinator handles the id-less ``stream_overflow`` frame: count it
(drop-vs-wedge field instrumentation) and feed the rolling-window storm
guard, exactly like the interactive pane."""
body = _COORD_JS.read_text(encoding="utf-8")
assert 'case "stream_overflow":' in body
assert "noteStreamOverflow();" in body
# The three-way health counter distinguishes dropped events (overflow /
# malformed frame) from render wedges (dispatch / render throw).
assert "streamHealth = { overflows: 0, renderThrows: 0, malformedFrames: 0 }" in body
assert "streamHealth.overflows += 1;" in body
assert "streamHealth.malformedFrames += 1;" in body
# Exactly two render-throw increment sites: the noteRenderThrow helper
# (all three contained render/finalize catches route through it — they
# recover with a plain-text fallback, so console.warn) and the onmessage
# dispatch catch (console.error class — the event is dropped outright).
# The three recovered call sites are pinned by label so a new render path
# that forgets to count surfaces loudly.
assert body.count("streamHealth.renderThrows += 1;") == 2
helper = re.search(r"function noteRenderThrow\(where, err\)\s*\{(.*?)\n \}", body, re.S)
assert helper is not None, "noteRenderThrow helper not found"
assert "streamHealth.renderThrows += 1;" in helper.group(1)
assert 'noteRenderThrow("streamingRender", e);' in body
assert 'noteRenderThrow("in_progress_snapshot render", e);' in body
assert 'noteRenderThrow("streamingRenderFinalize", e);' in body
note = re.search(r"function noteStreamOverflow\(\)\s*\{(.*?)\n \}", body, re.S)
assert note is not None, "noteStreamOverflow not found"
assert "overflowWindowTripped(" in note.group(1)
assert "enterDegradedCatchup()" in note.group(1)
# The trip handler only counts + trips; the cooldown reset lives in
# enterDegradedCatchup (keyed off lastDegradedAt) — the finding [0] shape.
assert "degradedCooldownMs" not in note.group(1), (
"noteStreamOverflow must not touch the cooldown — that reset defeated the ladder escalation"
)
def test_coord_handleevent_dispatch_is_wedge_guarded() -> None:
"""A throw escaping onmessage does NOT close the EventSource, so an
unguarded handler throw left the streaming refs stale and wedged every later
turn. The coordinator wraps the dispatch and counts the throw (render-wedge
class) so a field report tells it apart from a dropped-events gap."""
body = _COORD_JS.read_text(encoding="utf-8")
m = re.search(r"try \{\s*handleEvent\(data\);\s*\} catch \(err\) \{(.*?)\}", body, re.S)
assert m is not None, "handleEvent(data) must be wrapped in try/catch in onmessage"
assert "streamHealth.renderThrows += 1;" in m.group(1)
def test_coord_degraded_catchup_stops_live_stream_and_retries() -> None:
"""Three overflow closes inside the window drop the coordinator to a
degraded catch-up: suspend the live stream, say so plainly, and reconnect
after a doubling cooldown the reconnect replays the gap (or falls to the
/history floor once it outgrows the ring)."""
body = _COORD_JS.read_text(encoding="utf-8")
m = re.search(r"function enterDegradedCatchup\(\)\s*\{(.*?)\n \}", body, re.S)
assert m is not None, "enterDegradedCatchup not found"
method = m.group(1)
assert "degradedCooldownStep(" in method
assert "lastDegradedAt = now" in method
# Suspend the stream BEFORE arming the retry timer (mirrors interactive's
# disconnect-then-rearm ordering) or the fresh timer is cancelled at once.
assert method.index("suspendStream()") < method.index("degradedTimer = setTimeout")
# Plain-language status, not a silent stall.
assert "catching up" in method
# A fresh connect must cancel a pending degraded timer so it can't
# double-open behind the retry — connectSSE's prologue routes through the
# shared closeStreamTransport teardown, which owns that clear (alongside
# the reconnect timer + the EventSource close/null).
conn = re.search(r"function connectSSE\(\)\s*\{(.*?)\n \}", body, re.S)
assert conn is not None
assert "closeStreamTransport();" in conn.group(1)
teardown = re.search(r"function closeStreamTransport\(\)\s*\{(.*?)\n \}", body, re.S)
assert teardown is not None, "closeStreamTransport not found"
assert "clearTimeout(degradedTimer)" in teardown.group(1)
assert "clearTimeout(reconnectTimer)" in teardown.group(1)
assert "evtSource = null;" in teardown.group(1)
def test_coord_visibilitychange_closes_on_hide_reconnects_on_show() -> None:
"""A hidden tab's throttled drain is the worst-case slow SSE consumer. The
coordinator installs a visibilitychange handler that closes the stream on
hide (marking its OWN close via hiddenDisconnect) and reconnects on show from
the saved lastEventId, and removes the listener on teardown."""
body = _COORD_JS.read_text(encoding="utf-8")
assert 'document.addEventListener("visibilitychange", visHandler);' in body
assert 'document.removeEventListener("visibilitychange", visHandler);' in body
vis = re.search(r"function onVisibilityChange\(\)\s*\{(.*?)\n \}", body, re.S)
assert vis is not None, "onVisibilityChange not found"
method = vis.group(1)
assert "document.hidden" in method
assert "suspendStream()" in method
assert "hiddenDisconnect = true;" in method
assert "else if (hiddenDisconnect)" in method
assert "connectSSE();" in method
def test_coord_connectsse_defers_open_when_tab_hidden() -> None:
"""connectSSE must never open an EventSource into a hidden tab — the single
chokepoint that also backstops a FIRST connect in a background tab (where the
close-on-hide handler never fires because there was no open stream). It
marks hiddenDisconnect so the show edge owns the reconnect, marks the
deferral as a GAP (markStreamGap) so the eventual open runs the post-gap
recovery without the mark a pane first opened in a background tab
silently missed every child/task created while hidden and reports an
honest paused status instead of pinning "connecting" with no attempt in
flight."""
body = _COORD_JS.read_text(encoding="utf-8")
conn = re.search(r"function connectSSE\(\)\s*\{(.*?)\n \}", body, re.S)
assert conn is not None
method = conn.group(1)
guard = method.index("if (document.hidden)")
open_idx = method.index("new EventSource(")
assert guard < open_idx, "the hidden guard must precede new EventSource"
head = method[guard:open_idx]
assert "markStreamGap();" in head, "the hidden deferral must count as a stream gap"
assert "hiddenDisconnect = true;" in head
assert "return;" in head
assert 'setSseStatus("paused' in head, "the deferral must report paused, not connecting"
# "connecting" is claimed only once an attempt actually starts — after
# the hidden guard, immediately before the EventSource construction.
connecting = method.index('setSseStatus("connecting')
assert guard < connecting < open_idx
def test_coord_destroy_removes_visibility_handler_and_stream_transport() -> None:
"""Teardown must detach the document-level visibilitychange listener (it
holds a strong ref to the closure) and tear down the stream transport
closeStreamTransport closes the EventSource and cancels the reconnect +
degraded retry timers (pinned in the degraded-catchup test) or a
destroyed pane leaks and a show edge / pending retry reopens its stream."""
body = _COORD_JS.read_text(encoding="utf-8")
d = re.search(r"function destroy\(\)\s*\{(.*?)\n \}", body, re.S)
assert d is not None, "destroy not found"
method = d.group(1)
assert "removeVisibilityHandler();" in method
assert "closeStreamTransport();" in method
def test_coord_close_session_detaches_visibility_reopen() -> None:
"""coordCloseSession suspends the stream AND removes the visibilitychange
handler BEFORE awaiting the /close POST: a tab hideshow while the POST is
in flight must not reopen a stream against the workstream the server is
tearing down (404 / reconnect churn against a dead session). The failure
paths resume via connectSSE, which reinstalls the handler at its
install-once chokepoint so close-on-hide survives a failed close."""
body = _COORD_JS.read_text(encoding="utf-8")
m = re.search(r"async function coordCloseSession\(\)\s*\{(.*?)\n \}", body, re.S)
assert m is not None, "coordCloseSession not found"
method = m.group(1)
suspend = method.index("suspendStream();")
unhook = method.index("removeVisibilityHandler();")
# The quoted URL fragment, not the bare word (comments mention /close too).
post = method.index('"/close"')
assert suspend < post, "stream suspension must precede the /close POST"
assert unhook < post, "visibility detach must precede the /close POST"
assert "resumeSse()" in method
def test_coord_post_gap_sidebar_refresh_is_replay_aware() -> None:
"""The replace-mode children/tasks refresh (a sidebar rebuild) must NOT
fire on every reconnect: child_ws_* / task-mutating events are ordinary
ring-buffer entries, so a cursor reconnect (replay_ok) redelivers them and
the sidebar heals through the normal handlers a momentary blurfocus
under close-on-hide must not rebuild the sidebar. The refresh fires
exactly when the replay cannot vouch for the gap: no resume cursor or an
over-threshold gap at onopen, or the server's replay_truncated envelope
(ring evicted), deduped per open via gapRefreshedAtOpen."""
body = _COORD_JS.read_text(encoding="utf-8")
conn = re.search(r"function connectSSE\(\)\s*\{(.*?)\n \}", body, re.S)
assert conn is not None
method = conn.group(1)
gate = re.search(
r"wasReconnecting &&\s*\(lastEventId == null \|\| gapMs > GAP_REFRESH_THRESHOLD_MS\)",
method,
)
assert gate is not None, "onopen must gate the sidebar refresh on replay coverage"
assert "refreshSidebarAfterGap();" in method
assert "gapRefreshedAtOpen = true;" in method
# The ring-evicted signal triggers the same refresh (deduped per open).
trunc = re.search(r'case "replay_truncated":(.*?)break;', body, re.S)
assert trunc is not None, "replay_truncated case not found"
assert "refreshSidebarAfterGap()" in trunc.group(1)
assert "gapRefreshedAtOpen" in trunc.group(1)
# Deliberate suspends (hide / overflow / close-session) mark the gap so
# the next open participates in the recovery decision at all.
sus = re.search(r"function suspendStream\(\)\s*\{(.*?)\n \}", body, re.S)
assert sus is not None, "suspendStream not found"
assert "markStreamGap();" in sus.group(1)
# The refresh helper carries the whole replace-mode bundle: children,
# tasks, and the live-badge purge (permanent 403/404 entries preserved).
ref = re.search(r"function refreshSidebarAfterGap\(\)\s*\{(.*?)\n \}", body, re.S)
assert ref is not None, "refreshSidebarAfterGap not found"
assert "loadChildren({ replace: true });" in ref.group(1)
assert "loadTasks();" in ref.group(1)
assert "_liveBadgeCacheDelete(id)" in ref.group(1)
def test_coord_defers_truncated_resync_and_consumes_at_idle() -> None:
"""replay_truncated seen mid-stream must be DEFERRED, not dropped (matches
interactive's _pendingTruncatedResync): refetching immediately would detach
the live bubble (content OR a reasoning-only one), but skipping outright
leaves the ring-evicted turns lost for the session. The guard covers both
streaming targets and latches otherwise; the next state_change=idle consumes
the flag which also repairs a turn stranded by close-on-hide (stream_end
evicted while hidden), resetting the streaming refs first since
refetchHistory does not null them."""
body = _COORD_JS.read_text(encoding="utf-8")
trunc = re.search(r'case "replay_truncated":(.*?)break;', body, re.S)
assert trunc is not None, "replay_truncated case not found"
t = trunc.group(1)
assert "if (!currentAssistantEl && !currentReasoningEl)" in t
assert "refetchHistory();" in t
assert "pendingTruncatedResync = true;" in t
st = re.search(r'case "state_change":(.*?)\n case ', body, re.S)
assert st is not None, "state_change case not found"
s = st.group(1)
assert "if (pendingTruncatedResync)" in s
assert "pendingTruncatedResync = false;" in s
assert "currentAssistantEl = null;" in s
assert "refetchHistory();" in s
# Consume the latch, THEN reset the dangling refs and refetch.
consume = s.index("pendingTruncatedResync = false;")
refetch = s.index("refetchHistory();")
assert consume < refetch
def test_coord_detects_server_restart_by_backwards_event_id() -> None:
"""A coordinator process restart resets the per-ws event counter, and the
replay path reports replay_ok for a stale-high cursor (past the new max), so
the gap is unsignalled and the sidebar goes stale. onmessage catches it: a
live event id below the saved cursor == the counter reset pull
authoritative sidebar state (deduped per open against onopen's refresh),
checked BEFORE the cursor is overwritten."""
body = _COORD_JS.read_text(encoding="utf-8")
m = re.search(r"evtSource\.onmessage = function \(event\) \{(.*?)\n \};", body, re.S)
assert m is not None, "onmessage handler not found"
handler = m.group(1)
assert "Number(evtSource.lastEventId) < Number(lastEventId)" in handler
assert "!gapRefreshedAtOpen" in handler
assert "refreshSidebarAfterGap();" in handler
check = handler.index("Number(evtSource.lastEventId) < Number(lastEventId)")
overwrite = handler.index("lastEventId = evtSource.lastEventId;")
assert check < overwrite
def test_interactive_history_is_rest_first_not_sse() -> None:
"""PR A converged interactive onto coord's REST-first history
model: first paint and post-rewind re-render fetch ``GET /history``
@@ -2183,107 +1757,3 @@ def test_global_stream_recovery_floor_and_render_coalescing() -> None:
assert "requestAnimationFrame(" in body[fire : fire + 700], (
"fireRender must coalesce subscriber repaints to one per frame"
)
def test_server_global_accels_are_platform_aware_and_scoped() -> None:
"""The standalone's keydown handler owns only the GLOBAL accels — new
workstream, switch, dashboard. They pick the modifier per platform (Ctrl on
macOS where the browser owns Cmd, Alt elsewhere) so Ctrl+T/1-9 aren't eaten
by the browser off macOS. The per-pane verbs (edit/refresh/fork/delete/
close) moved to shell.js, so the handler must not invoke them itself."""
body = _APP_JS.read_text(encoding="utf-8")
assert "const IS_MAC" in body and 'navigator.platform.indexOf("Mac")' in body, (
"the accelerators need a platform check to choose Ctrl vs Alt"
)
handler = body[body.index('document.addEventListener("keydown"') :]
assert "const paneMod" in handler, (
"global accels must gate on the platform-aware paneMod, not raw ctrlKey"
)
assert 'e.ctrlKey && e.key === "t"' not in handler, (
"Ctrl+T is browser-reserved off macOS — new workstream must bind via paneMod"
)
assert "newWorkstream()" in handler and "switchTab(" in handler, (
"the standalone handler still owns new + switch"
)
# macOS Ctrl+T / Ctrl+D are the Cocoa transpose / delete-forward text
# bindings; the creation/dashboard chords must yield while typing, through
# the shared TS_SHELL.inEditable guard (not a per-file copy).
assert "TS_SHELL.inEditable(" in handler, (
"new + dashboard must yield to text editing (macOS Ctrl+T / Ctrl+D)"
)
# The per-pane verbs are shell.js's job now — the standalone handler must not
# double-bind them (shell.js drives them off the active pane's menu).
for verb in ("editWorkstreamTitle()", "forkWorkstream()", "confirmDeleteWorkstream()"):
assert verb not in handler, (
f"{verb} moved to shell.js — the app.js handler must not also bind it"
)
def test_shortcut_overlay_labels_match_the_platform_modifier() -> None:
"""The '?' help overlay must advertise the same modifier the handler
listens for Ctrl on macOS, Alt on Windows/Linux instead of a hardcoded
Ctrl that is wrong (and non-functional) off macOS."""
index = _INDEX_HTML.read_text(encoding="utf-8")
assert "const PANE_MOD" in index and 'navigator.platform.indexOf("Mac")' in index, (
"the overlay must compute its modifier label per platform"
)
assert "${PANE_MOD}+T" in index, "the New-workstream badge must render through PANE_MOD"
assert '<span class="kb-key">Ctrl+T</span>' not in index, (
"the New-workstream badge must not hardcode Ctrl (wrong off macOS)"
)
def test_pane_menu_accels_are_shared_and_platform_aware() -> None:
"""shell.js is the single source of truth for the per-pane tab-menu
shortcuts: the badge string and the keydown handler come from ONE registry,
so a badge can't advertise a chord the handler ignores. Badges must be
platform-aware (no hardcoded Ctrl), and the shared handler must drive the
ACTIVE pane's own menu so each surface contributes only what it supports."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert "PANE_MENU_ACCELS" in shell and "function paneAccelBadge" in shell, (
"shell.js must own the accel registry + badge builder"
)
assert "const PANE_MOD_LABEL" in shell and 'navigator.platform.indexOf("Mac")' in shell, (
"the shared badge must be platform-aware (Ctrl on macOS, Alt elsewhere)"
)
# The tab-menu items carry a stable accel + a computed badge, NOT a hardcoded
# Ctrl string that would lie on Windows/Linux.
for accel in ("close-pane", "edit-title", "refresh-title", "delete"):
assert f'accel: "{accel}"' in shell, f"tab menu must tag the {accel} item"
assert 'key: "Ctrl+Shift+E"' not in shell and 'key: "Ctrl+W"' not in shell, (
"tab-menu badges must go through paneAccelBadge, not hardcoded Ctrl"
)
# The shared handler resolves the active pane and runs its menu item by accel.
assert "paneAccelFor(e)" in shell and "pane.tabMenu()" in shell, (
"the shared keydown handler must drive the active pane's menu by accel"
)
# The typing guard is shared (TS_SHELL.inEditable), not copied per surface.
assert "function inEditable(" in shell and "inEditable," in shell, (
"shell.js must define + expose the shared inEditable guard on TS_SHELL"
)
ui = _APP_JS.read_text(encoding="utf-8")
console = _CONSOLE_APP_JS.read_text(encoding="utf-8")
assert "_inEditable" not in ui and "_consoleInEditable" not in console, (
"surfaces must use TS_SHELL.inEditable, not a per-file copy of the guard"
)
def test_console_has_matching_pane_hotkeys() -> None:
"""The console regained pane hotkeys to match the standalone: a keydown
handler for switch (Mod+1-9) + dashboard (Ctrl+D), and a '?' overlay that
advertises them platform-aware. New workstream and Fork are intentionally
omitted (no console fork / blank-new surface)."""
app = _CONSOLE_APP_JS.read_text(encoding="utf-8")
assert (
"_CONSOLE_IS_MAC" in app and "statefulTabs()" in app and 'openPane("dashboard")' in app
), "the console must wire switch (statefulTabs) + dashboard hotkeys"
index = _CONSOLE_INDEX.read_text(encoding="utf-8")
assert "const PANE_MOD" in index and '"Panes"' in index, (
"the console '?' overlay needs a platform-aware Panes section"
)
assert "${PANE_MOD}+W" in index and "${PANE_MOD}+Shift+E" in index, (
"console badges must render through PANE_MOD"
)
assert '"Fork"' not in index and "New workstream" not in index, (
"Fork + New are intentionally omitted on the console"
)
-670
View File
@@ -1,670 +0,0 @@
"""Unit tests for the per-session background-shell registry (#817).
The registry backs the ``bash(run_in_background=true)`` / ``bash_output`` /
``kill_shell`` tool surface: it spawns detached shells (``bash_N`` handles),
buffers their merged output in a capped rolling buffer, serves delta reads
(only lines since the last read), and reaps whole session groups on kill /
owner reap / close the #816 rule (the tracked command defines the lifetime,
nothing escapes its process group) extended to explicit backgrounding.
Pure registry tests no ChatSession. Session wiring is covered in
``test_bash_background_tool.py``.
"""
import re
import threading
import time
import pytest
from tests._proc_helpers import kill_pid as _kill_pid
from tests._proc_helpers import pid_alive as _pid_alive
from tests._proc_helpers import poll_until as _wait_until
# Module alias (from-style, matching the symbol imports below) for tests
# that monkeypatch module attributes (os.killpg, subprocess.Popen, ...).
from turnstone.core import background_shells as bg_mod
from turnstone.core.background_shells import (
BackgroundShellRegistry,
FilterExecError,
FilterTimeoutError,
TooManyShellsError,
UnknownShellError,
)
def _wait_status(shell, status, timeout=10.0):
return _wait_until(lambda: shell.status == status, timeout=timeout)
@pytest.fixture
def registry():
reg = BackgroundShellRegistry()
yield reg
reg.close()
# ---------------------------------------------------------------------------
# Handles + spawning
# ---------------------------------------------------------------------------
def test_spawn_returns_incrementing_bash_handles(registry):
s1 = registry.spawn("sleep 30")
s2 = registry.spawn("sleep 30")
assert s1.shell_id == "bash_1"
assert s2.shell_id == "bash_2"
def test_spawned_shell_is_running_with_live_pid(registry):
shell = registry.spawn("sleep 30")
assert shell.status == "running"
assert _pid_alive(shell.pid)
def test_spawn_records_command(registry):
shell = registry.spawn("sleep 30")
assert shell.command == "sleep 30"
def test_spawn_after_close_is_refused():
reg = BackgroundShellRegistry()
reg.close()
with pytest.raises(RuntimeError):
reg.spawn("echo hi")
def test_max_live_shells_cap():
reg = BackgroundShellRegistry(max_shells=2)
try:
reg.spawn("sleep 30")
s2 = reg.spawn("sleep 30")
with pytest.raises(TooManyShellsError):
reg.spawn("sleep 30")
# Cap counts LIVE shells: killing one frees a slot.
reg.kill(s2.shell_id)
s3 = reg.spawn("sleep 30")
assert s3.status == "running"
finally:
reg.close()
def test_completed_shells_do_not_count_toward_cap():
reg = BackgroundShellRegistry(max_shells=1)
try:
s1 = reg.spawn("true")
assert _wait_status(s1, "completed")
s2 = reg.spawn("sleep 30")
assert s2.status == "running"
finally:
reg.close()
# ---------------------------------------------------------------------------
# Exit tracking
# ---------------------------------------------------------------------------
def test_natural_exit_sets_completed_and_exit_code(registry):
shell = registry.spawn("exit 7")
assert _wait_status(shell, "completed")
assert shell.exit_code == 7
def test_output_is_complete_once_completed(registry):
"""Status flips to completed only after the drains finish: a read at
completed must see everything the command wrote."""
shell = registry.spawn("echo alpha; echo beta")
assert _wait_status(shell, "completed")
read = registry.read(shell.shell_id)
assert [ln.strip() for ln in read.lines] == ["alpha", "beta"]
def test_leader_exit_reaps_backgrounded_grandchild(registry, tmp_path):
"""#816 consistency: the tracked command defines the lifetime. When the
leader exits, the whole session group is killed a child the command
backgrounded does not outlive it."""
pidfile = tmp_path / "bg.pid"
shell = registry.spawn(f"sleep 60 & echo $! > {pidfile}; echo done")
bg_pid = None
try:
assert _wait_status(shell, "completed")
bg_pid = int(pidfile.read_text().strip())
assert _wait_until(lambda: not _pid_alive(bg_pid)), (
f"grandchild {bg_pid} leaked past leader exit"
)
read = registry.read(shell.shell_id)
assert "done" in "".join(read.lines)
finally:
if bg_pid is not None:
_kill_pid(bg_pid)
def test_stderr_lines_are_tagged_inline(registry):
shell = registry.spawn("echo out; echo err >&2")
assert _wait_status(shell, "completed")
lines = [ln.strip() for ln in registry.read(shell.shell_id).lines]
assert "out" in lines
assert "[stderr] err" in lines
# ---------------------------------------------------------------------------
# Delta reads
# ---------------------------------------------------------------------------
def test_read_returns_only_new_lines_since_last_read(registry):
"""The load-bearing convention: consecutive reads never overlap and never
drop a line collecting across polls yields each line exactly once."""
shell = registry.spawn("echo one; echo two; sleep 0.4; echo three; sleep 30")
collected: list[str] = []
def _collect():
collected.extend(ln.strip() for ln in registry.read(shell.shell_id).lines)
return "three" in collected
assert _wait_until(_collect)
assert collected == ["one", "two", "three"]
registry.kill(shell.shell_id)
def test_read_after_exit_then_again_reports_no_new_output(registry):
shell = registry.spawn("echo hi")
assert _wait_status(shell, "completed")
first = registry.read(shell.shell_id)
assert [ln.strip() for ln in first.lines] == ["hi"]
second = registry.read(shell.shell_id)
assert second.lines == []
assert second.status == "completed"
assert second.exit_code == 0
def test_read_reports_status_and_exit_code(registry):
shell = registry.spawn("sleep 30")
read = registry.read(shell.shell_id)
assert read.shell_id == shell.shell_id
assert read.status == "running"
assert read.exit_code is None
registry.kill(shell.shell_id)
def test_read_unknown_id_raises_with_live_ids(registry):
registry.spawn("sleep 30")
with pytest.raises(UnknownShellError) as excinfo:
registry.read("bash_99")
assert "bash_99" in str(excinfo.value)
assert "bash_1" in str(excinfo.value)
def test_read_unknown_id_when_registry_empty(registry):
with pytest.raises(UnknownShellError):
registry.read("bash_1")
# ---------------------------------------------------------------------------
# Filter
# ---------------------------------------------------------------------------
def test_filter_selects_matching_lines_only(registry):
shell = registry.spawn("echo match-a; echo skip-b; echo match-c")
assert _wait_status(shell, "completed")
read = registry.read(shell.shell_id, filter_pattern="^match")
assert [ln.strip() for ln in read.lines] == ["match-a", "match-c"]
def test_filter_is_display_only_and_consumes_the_delta(registry):
"""Filtered-out lines are consumed, not deferred — the cursor advances
past the whole delta (Claude Code ``BashOutput`` semantics)."""
shell = registry.spawn("echo match-a; echo skip-b")
assert _wait_status(shell, "completed")
first = registry.read(shell.shell_id, filter_pattern="^match")
assert [ln.strip() for ln in first.lines] == ["match-a"]
assert first.new_line_count == 2 # both lines were new, one shown
second = registry.read(shell.shell_id)
assert second.lines == []
assert second.new_line_count == 0
def test_filter_uses_search_not_match(registry):
shell = registry.spawn("echo prefix-needle-suffix")
assert _wait_status(shell, "completed")
read = registry.read(shell.shell_id, filter_pattern="needle")
assert len(read.lines) == 1
def test_invalid_filter_regex_raises(registry):
shell = registry.spawn("echo hi")
assert _wait_status(shell, "completed")
with pytest.raises(re.error):
registry.read(shell.shell_id, filter_pattern="[unclosed")
# ---------------------------------------------------------------------------
# Buffer cap
# ---------------------------------------------------------------------------
def test_buffer_cap_drops_oldest_and_reports_gap():
reg = BackgroundShellRegistry(max_buffer_chars=200)
try:
shell = reg.spawn('for i in $(seq 1 50); do echo "line-$i-padded-to-length"; done')
assert _wait_status(shell, "completed")
read = reg.read(shell.shell_id)
assert read.dropped_lines > 0
# Newest output survives; the tail is intact.
assert read.lines, "cap must retain the newest lines, not drop everything"
assert read.lines[-1].strip() == "line-50-padded-to-length"
finally:
reg.close()
def test_unread_lines_excludes_buffer_evicted():
"""The exit notice's line count must not promise evicted output."""
reg = BackgroundShellRegistry(max_buffer_chars=200)
try:
shell = reg.spawn('for i in $(seq 1 50); do echo "line-$i-padded-to-length"; done')
assert _wait_status(shell, "completed")
with shell.lock:
retained = len(shell._buffer)
assert shell.unread_lines == retained
finally:
reg.close()
def test_buffer_gap_is_relative_to_cursor():
"""Lines dropped BEFORE being read are a reported gap; lines already
read and then dropped are not."""
reg = BackgroundShellRegistry(max_buffer_chars=10_000)
try:
shell = reg.spawn("echo early; sleep 30")
# Each poll consumes whatever has arrived; stop once something did.
assert _wait_until(lambda: bool(reg.read(shell.shell_id).lines))
# Everything emitted so far is read; nothing has been dropped.
read = reg.read(shell.shell_id)
assert read.dropped_lines == 0
reg.kill(shell.shell_id)
finally:
reg.close()
# ---------------------------------------------------------------------------
# Kill / reap / close
# ---------------------------------------------------------------------------
def test_kill_marks_killed_and_reaps_group(registry, tmp_path):
pidfile = tmp_path / "bg.pid"
shell = registry.spawn(f"sleep 60 & echo $! > {pidfile}; sleep 60")
assert _wait_until(pidfile.exists)
bg_pid = int(pidfile.read_text().strip())
try:
killed = registry.kill(shell.shell_id)
assert killed.status == "killed"
assert _wait_until(lambda: not _pid_alive(shell.pid))
assert _wait_until(lambda: not _pid_alive(bg_pid)), "grandchild survived kill"
finally:
_kill_pid(bg_pid)
def test_kill_unknown_id_raises(registry):
with pytest.raises(UnknownShellError):
registry.kill("bash_7")
def test_killed_shell_output_remains_readable(registry, tmp_path):
"""Output that arrived before the kill survives it: the record keeps its
buffer, and ``kill`` returns only after the drains have flushed."""
sentinel = tmp_path / "started"
shell = registry.spawn(f"echo before-kill; touch {sentinel}; sleep 60")
assert _wait_until(sentinel.exists)
registry.kill(shell.shell_id)
read = registry.read(shell.shell_id)
assert read.status == "killed"
assert "before-kill" in "".join(read.lines)
def test_signal_all_kills_live_shells_without_closing(registry):
"""signal_all is the instant half of teardown: every live group dies,
but the registry stays open (records intact, spawns still allowed)
close() remains the complete teardown."""
s1 = registry.spawn("sleep 60")
s2 = registry.spawn("sleep 60")
registry.signal_all()
assert _wait_until(lambda: not _pid_alive(s1.pid))
assert _wait_until(lambda: not _pid_alive(s2.pid))
assert registry.has(s1.shell_id), "signal_all must not drop records"
s3 = registry.spawn("true")
assert _wait_status(s3, "completed"), "registry must remain usable after signal_all"
def test_close_kills_everything_and_is_idempotent():
reg = BackgroundShellRegistry()
s1 = reg.spawn("sleep 60")
s2 = reg.spawn("sleep 60")
reg.close()
assert not _pid_alive(s1.pid)
assert not _pid_alive(s2.pid)
reg.close() # second close is a no-op
def test_reap_owner_kills_only_that_owners_shells(registry):
mine = registry.spawn("sleep 60", owner="agent-1")
other = registry.spawn("sleep 60", owner="agent-2")
main = registry.spawn("sleep 60")
registry.reap(owner="agent-1")
assert _wait_until(lambda: not _pid_alive(mine.pid))
assert _pid_alive(other.pid)
assert _pid_alive(main.pid)
# ---------------------------------------------------------------------------
# Owner scoping
# ---------------------------------------------------------------------------
def test_owner_scoped_lookup_isolates_shells(registry):
agent_shell = registry.spawn("sleep 30", owner="agent-1")
main_shell = registry.spawn("sleep 30")
# Main scope cannot see the agent's shell...
with pytest.raises(UnknownShellError):
registry.read(agent_shell.shell_id)
# ...and the agent scope cannot see the main shell.
with pytest.raises(UnknownShellError):
registry.read(main_shell.shell_id, owner="agent-1")
# Each side reads its own.
assert registry.read(agent_shell.shell_id, owner="agent-1").status == "running"
assert registry.read(main_shell.shell_id).status == "running"
def test_shells_snapshot_is_owner_scoped(registry):
registry.spawn("sleep 30", owner="agent-1")
registry.spawn("sleep 30")
assert [s.owner for s in registry.shells(owner="agent-1")] == ["agent-1"]
assert [s.owner for s in registry.shells()] == [None]
def test_handles_are_unique_across_owners(registry):
a = registry.spawn("sleep 30", owner="agent-1")
b = registry.spawn("sleep 30")
assert a.shell_id != b.shell_id
# ---------------------------------------------------------------------------
# Exit callback (the notice hook)
# ---------------------------------------------------------------------------
def test_on_exit_fires_once_on_natural_exit():
fired = threading.Event()
seen = []
def _on_exit(shell):
seen.append(shell)
fired.set()
reg = BackgroundShellRegistry(on_exit=_on_exit)
try:
shell = reg.spawn("echo done")
assert fired.wait(10)
assert len(seen) == 1
assert seen[0].shell_id == shell.shell_id
assert seen[0].exit_code == 0
finally:
reg.close()
def test_on_exit_not_fired_for_kill():
seen = []
reg = BackgroundShellRegistry(on_exit=seen.append)
try:
shell = reg.spawn("sleep 60")
reg.kill(shell.shell_id)
assert _wait_until(lambda: not _pid_alive(shell.pid))
time.sleep(0.2) # give a buggy late callback a chance to land
assert seen == []
finally:
reg.close()
def test_on_exit_not_fired_for_close():
seen = []
reg = BackgroundShellRegistry(on_exit=seen.append)
shell = reg.spawn("sleep 60")
reg.close()
assert not _pid_alive(shell.pid)
time.sleep(0.2)
assert seen == []
# ---------------------------------------------------------------------------
# Review-hardening regressions (#817 code review)
# ---------------------------------------------------------------------------
def test_kill_on_completed_shell_does_not_signal_group(registry, monkeypatch):
"""A completed shell's pgid is a stale snapshot the OS may have recycled
to an unrelated process group kill() must not signal it (the waiter's
own group kill already ran at exit, when the pgid was fresh)."""
shell = registry.spawn("true")
assert _wait_status(shell, "completed")
calls = []
monkeypatch.setattr(bg_mod.os, "killpg", lambda *a: calls.append(a))
killed = registry.kill(shell.shell_id)
assert calls == [], "killpg must not fire for an already-exited shell"
assert killed.status == "completed", "a natural exit must not be relabelled 'killed'"
def test_close_is_time_bounded_with_pipe_holding_escapee(registry, tmp_path):
"""An escaped-group grandchild that holds the output pipes wedges the
drain threads. close() must still return within its total budget
it can run under the server's async close route, where an unbounded
join would freeze the whole node's event loop."""
pidfile = tmp_path / "holder.pid"
# ``setsid`` puts the sleep in a NEW session (outside our kill group)
# while it still inherits our stdout/stderr pipes — the accepted
# leaked-daemon case from the module docstring.
shell = registry.spawn(f"setsid sleep 60 & echo $! > {pidfile}; echo started")
assert _wait_until(pidfile.exists)
holder_pid = int(pidfile.read_text().strip())
try:
start = time.monotonic()
registry.close()
elapsed = time.monotonic() - start
assert elapsed < 8, f"close() took {elapsed:.1f}s — teardown must be budget-bounded"
finally:
_kill_pid(holder_pid)
# The holder is dead, so the wedged drains EOF promptly; wait for
# them here so the conftest leak guard sees a clean teardown.
assert _wait_until(lambda: not any(t.is_alive() for t in shell._threads))
def test_exited_records_are_pruned_at_cap():
reg = BackgroundShellRegistry(max_exited_records=2)
try:
shells = [reg.spawn(f"echo job-{i}") for i in range(3)]
for s in shells:
assert _wait_status(s, "completed")
# Eviction happens on each exit; poll until the oldest is gone
# (waiter threads race, prune runs per-exit).
assert _wait_until(lambda: not reg.has(shells[0].shell_id))
assert reg.has(shells[1].shell_id)
assert reg.has(shells[2].shell_id)
with pytest.raises(UnknownShellError):
reg.read(shells[0].shell_id)
finally:
reg.close()
def test_catastrophic_filter_times_out_without_consuming(registry):
"""A backtracking-bomb filter must error within the bound and consume
NOTHING the retry without a filter still gets the output. The match
runs in a killable child process: sre holds the GIL, so an in-process
bomb would freeze the whole interpreter, watchdogs included."""
# One ~3000-char line of a's ending in 'b' — the classic (a+)+$ bomb
# subject — followed by a sentinel line.
shell = registry.spawn("printf 'a%.0s' $(seq 1 3000); echo b; echo tail-line")
assert _wait_status(shell, "completed")
start = time.monotonic()
with pytest.raises(FilterTimeoutError):
registry.read(shell.shell_id, filter_pattern=r"(a+)+$")
assert time.monotonic() - start < 10, "filter timeout must be bounded"
# Nothing was consumed: an unfiltered read sees the whole delta.
read = registry.read(shell.shell_id)
assert any("tail-line" in ln for ln in read.lines)
def test_overlong_filter_pattern_is_rejected(registry):
shell = registry.spawn("echo hi")
assert _wait_status(shell, "completed")
with pytest.raises(re.error):
registry.read(shell.shell_id, filter_pattern="x" * 600)
def test_cap_error_is_owner_scope_honest():
"""The cap is registry-wide, but the advice must only name shells the
caller can actually kill kill_shell is owner-scoped."""
reg = BackgroundShellRegistry(max_shells=1)
try:
reg.spawn("sleep 30") # main scope fills the cap
with pytest.raises(TooManyShellsError) as excinfo:
reg.spawn("sleep 30", owner="agent-1")
msg = str(excinfo.value)
assert "bash_1" not in msg, "must not advise killing another scope's shell"
assert "other agents" in msg
# The same-scope variant names the killable shell.
with pytest.raises(TooManyShellsError) as excinfo2:
reg.spawn("sleep 30")
assert "bash_1" in str(excinfo2.value)
assert "kill_shell" in str(excinfo2.value)
finally:
reg.close()
def test_prune_evicts_by_exit_order_not_spawn_order():
"""A long-lived first-spawned server must never be evicted by its OWN
exit's prune once enough later jobs have finished — eviction follows
exit order, so the just-exited shell is always the newest record."""
reg = BackgroundShellRegistry(max_exited_records=2)
try:
server = reg.spawn("sleep 30") # bash_1, exits LAST
jobs = [reg.spawn(f"echo job-{i}") for i in range(3)]
for job in jobs:
assert _wait_status(job, "completed")
reg.kill(server.shell_id)
assert reg.has(server.shell_id), "the just-exited shell must survive its own exit's prune"
# The earliest-EXITED job is the eviction victim, not bash_1.
assert _wait_until(lambda: len(reg.shells()) <= 3)
assert reg.read(server.shell_id).status == "killed"
finally:
reg.close()
def test_thread_start_failure_leaves_no_orphan_record(registry, monkeypatch, tmp_path):
"""If Thread.start raises (thread exhaustion), the record must be
unregistered and the fresh group reaped an orphan with never-started
Thread objects would make every later close()/reap() join raise and
abort session teardown."""
pidfile = tmp_path / "leader.pid"
real_thread = bg_mod.threading.Thread
class FailingWaiterThread(real_thread):
def start(self):
if "bg-shell-wait" in (self.name or ""):
raise RuntimeError("can't start new thread")
super().start()
monkeypatch.setattr(bg_mod.threading, "Thread", FailingWaiterThread)
with pytest.raises(RuntimeError):
registry.spawn(f"echo $$ > {pidfile}; sleep 60")
assert registry.shells() == [], "failed spawn must not strand a record"
if pidfile.exists():
leader_pid = int(pidfile.read_text().strip())
assert _wait_until(lambda: not _pid_alive(leader_pid)), "fresh group leaked"
monkeypatch.undo()
registry.close() # must not raise on the (empty) registry
def test_filter_helper_failure_reports_exec_error_not_timeout(registry, monkeypatch):
"""A crashed helper must not tell the model its (fine) pattern was too
slow and must not consume the delta."""
shell = registry.spawn("echo hello")
assert _wait_status(shell, "completed")
monkeypatch.setattr(bg_mod.sys, "executable", "/bin/false")
with pytest.raises(FilterExecError) as excinfo:
registry.read(shell.shell_id, filter_pattern="hello")
assert "not a problem with your pattern" in str(excinfo.value)
monkeypatch.undo()
read = registry.read(shell.shell_id)
assert [ln.strip() for ln in read.lines] == ["hello"]
def test_filter_matches_only_within_line_cap_and_reports_clipping(registry):
"""Lines are truncated parent-side before shipping to the helper: a
match beyond the per-line cap is not found (a filter targets log
lines), and a huge retained line cannot burn the time budget on I/O.
The clipping is NEVER silent the read reports how many lines were
only partially visible to the pattern."""
shell = registry.spawn("printf 'x%.0s' $(seq 1 5000); echo needle-suffix")
assert _wait_status(shell, "completed")
read = registry.read(shell.shell_id, filter_pattern="needle")
assert read.lines == []
assert read.new_line_count == 1
assert read.clipped_lines == 1
def test_concurrent_reads_never_double_deliver(registry):
"""Two simultaneous reads of one shell must SPLIT the delta between
them, never both return it the whole pass (snapshot commit)
serializes per shell. Without that, a parallel tool batch reading the
same handle gets every line twice."""
shell = registry.spawn("seq 1 200")
assert _wait_status(shell, "completed")
results: list[list[str]] = [[], []]
barrier = threading.Barrier(2)
def _reader(slot: int) -> None:
barrier.wait()
results[slot] = [ln.strip() for ln in registry.read(shell.shell_id).lines]
threads = [threading.Thread(target=_reader, args=(i,)) for i in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
combined = results[0] + results[1]
assert len(combined) == 200, f"expected each line exactly once, got {len(combined)}"
assert sorted(combined, key=int) == [str(i) for i in range(1, 201)]
def test_filter_helper_spawn_failure_is_exec_error(registry, monkeypatch):
"""A helper that fails to LAUNCH (fork pressure) must land in the same
honest FilterExecError as a crashed helper not escape as a raw
OSError blaming nothing and must not consume the delta."""
shell = registry.spawn("echo hello")
assert _wait_status(shell, "completed")
def _boom(*args, **kwargs):
raise BlockingIOError("Resource temporarily unavailable")
monkeypatch.setattr(bg_mod.subprocess, "Popen", _boom)
with pytest.raises(FilterExecError):
registry.read(shell.shell_id, filter_pattern="hello")
monkeypatch.undo()
read = registry.read(shell.shell_id)
assert [ln.strip() for ln in read.lines] == ["hello"]
def test_on_exit_exception_does_not_wedge_the_shell():
def _boom(shell):
raise RuntimeError("callback bug")
reg = BackgroundShellRegistry(on_exit=_boom)
try:
shell = reg.spawn("echo hi")
# The waiter thread must survive the callback raising: status still
# lands and output is still readable.
assert _wait_status(shell, "completed")
assert [ln.strip() for ln in reg.read(shell.shell_id).lines] == ["hi"]
finally:
reg.close()
-801
View File
@@ -1,801 +0,0 @@
"""Session-level tests for the background-shell tool surface (#817).
Covers the wiring around :class:`BackgroundShellRegistry`:
* ``bash`` gains ``run_in_background: true`` (alias ``is_background``)
same approval gate, returns immediately with a ``bash_N`` handle.
* ``bash_output`` auto-approved delta reader (status + exit code + only
new output since the last call, optional ``filter`` regex).
* ``kill_shell`` auto-approved kill of a registered shell's whole group.
* Exit notices ride the NudgeQueue on channel ``"any"`` (the watch rail) so
they drain at the next seam and can wake an idle workstream.
* Lifecycle: ``close()`` reaps everything; generation-``cancel()`` does NOT
(a deliberately-detached server survives a stopped turn); shells spawned
inside a task_agent are owner-scoped and reaped when the agent finishes.
"""
import time
import pytest
from tests._proc_helpers import pid_alive as _pid_alive
from tests._proc_helpers import poll_until as _wait_until
from tests._session_helpers import make_session
@pytest.fixture
def session():
s = make_session()
yield s
s.close()
def _start_background(session, command, call_id="bg1", **extra_args):
"""Prepare + execute a backgrounded bash call; return the result text."""
args = {"command": command, "run_in_background": True, **extra_args}
prepared = session._prepare_bash(call_id, args)
assert "error" not in prepared, prepared.get("error")
_cid, output = prepared["execute"](prepared)
return output
def _only_shell(session):
shells = session._background_shells.shells()
assert len(shells) == 1
return shells[0]
# ---------------------------------------------------------------------------
# bash: run_in_background routing
# ---------------------------------------------------------------------------
def test_prepare_bash_background_keeps_approval_gate(session):
prepared = session._prepare_bash("c1", {"command": "sleep 30", "run_in_background": True})
assert prepared["needs_approval"] is True
assert prepared["approval_label"] == "bash"
def test_prepare_bash_background_header_says_background(session):
prepared = session._prepare_bash("c1", {"command": "sleep 30", "run_in_background": True})
assert "background" in prepared["header"]
def test_background_bash_returns_immediately_with_handle(session):
start = time.monotonic()
output = _start_background(session, "sleep 30")
elapsed = time.monotonic() - start
assert elapsed < 5, f"backgrounded call blocked for {elapsed:.1f}s"
assert "bash_1" in output
shell = _only_shell(session)
assert shell.status == "running"
assert _pid_alive(shell.pid)
def test_background_start_mentions_reader_and_killer(session):
"""The immediate result must teach the follow-up tools — weak-prior
models (GPT-5.6) only reach for the poll pattern if the result names it."""
output = _start_background(session, "sleep 30")
assert "bash_output" in output
assert "kill_shell" in output
def test_is_background_alias_accepted(session):
output = _start_background(session, "sleep 30", is_background=True)
assert "bash_1" in output
assert _only_shell(session).status == "running"
def test_foreground_bash_routing_unchanged(session):
prepared = session._prepare_bash("c1", {"command": "echo hi"})
assert prepared["execute"] == session._exec_bash
prepared_false = session._prepare_bash("c2", {"command": "echo hi", "run_in_background": False})
assert prepared_false["execute"] == session._exec_bash
def test_background_respects_command_blocklist(session):
prepared = session._prepare_bash("c1", {"command": "shutdown now", "run_in_background": True})
assert "error" in prepared
assert session._background_shells.shells() == []
def test_background_ignores_timeout(session):
"""No bounded wait exists to time out — a 1s timeout must not kill the
detached shell."""
_start_background(session, "sleep 30", timeout=1)
shell = _only_shell(session)
time.sleep(1.5)
assert shell.status == "running"
assert _pid_alive(shell.pid)
def test_background_spawn_failure_reports_error(session, monkeypatch):
from turnstone.core import background_shells as bg_mod
def _boom(*args, **kwargs):
raise OSError("cannot fork")
monkeypatch.setattr(bg_mod.subprocess, "Popen", _boom)
prepared = session._prepare_bash("c1", {"command": "echo hi", "run_in_background": True})
_cid, output = prepared["execute"](prepared)
assert "cannot fork" in output
def test_too_many_background_shells_reports_error(session, monkeypatch):
monkeypatch.setattr(session._background_shells, "_max_shells", 1)
_start_background(session, "sleep 30", call_id="bg1")
output = _start_background(session, "sleep 30", call_id="bg2")
assert "bash_1" in output # the live shell is named so the model can kill it
assert len(session._background_shells.shells()) == 1
# ---------------------------------------------------------------------------
# bash_output
# ---------------------------------------------------------------------------
def test_bash_output_is_auto_approved(session):
prepared = session._prepare_bash_output("c1", {"id": "bash_1"})
assert prepared["needs_approval"] is False
def test_bash_output_missing_id_errors(session):
prepared = session._prepare_bash_output("c1", {})
assert "error" in prepared
def test_bash_output_returns_delta_then_no_new_output(session):
_start_background(session, "echo hello; sleep 30")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "running")
def _read():
prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
assert "error" not in prepared
return prepared["execute"](prepared)[1]
assert _wait_until(lambda: "hello" in _read())
again = _read()
assert "hello" not in again
assert "no new output" in again.lower()
assert "running" in again.lower()
def test_bash_output_reports_exit_code_when_completed(session):
_start_background(session, "exit 3")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "completed")
prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
_cid, output = prepared["execute"](prepared)
assert "completed" in output.lower()
assert "3" in output
def test_bash_output_filter_applies(session):
_start_background(session, "echo match-a; echo skip-b")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "completed")
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "^match"})
_cid, output = prepared["execute"](prepared)
assert "match-a" in output
assert "skip-b" not in output
def test_bash_output_invalid_filter_reports_error(session):
_start_background(session, "sleep 30")
shell = _only_shell(session)
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "[bad"})
_cid, output = prepared["execute"](prepared)
assert "regex" in output.lower() or "filter" in output.lower()
def test_bash_output_unknown_id_lists_live_shells(session):
_start_background(session, "sleep 30")
prepared = session._prepare_bash_output("r", {"id": "bash_42"})
_cid, output = prepared["execute"](prepared)
assert "bash_42" in output
assert "bash_1" in output
# ---------------------------------------------------------------------------
# kill_shell
# ---------------------------------------------------------------------------
def test_kill_shell_is_auto_approved(session):
prepared = session._prepare_kill_shell("c1", {"id": "bash_1"})
assert prepared["needs_approval"] is False
def test_kill_shell_missing_id_errors(session):
prepared = session._prepare_kill_shell("c1", {})
assert "error" in prepared
def test_kill_shell_kills_and_reports(session):
_start_background(session, "sleep 60")
shell = _only_shell(session)
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
_cid, output = prepared["execute"](prepared)
assert "killed" in output.lower()
assert _wait_until(lambda: not _pid_alive(shell.pid))
# The schema promises the exit code for ANY exited state, killed included.
read_prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
_cid, read_output = read_prepared["execute"](read_prepared)
assert "exit code" in read_output
def test_kill_shell_unknown_id_reports_error(session):
prepared = session._prepare_kill_shell("k", {"id": "bash_9"})
_cid, output = prepared["execute"](prepared)
assert "bash_9" in output
# ---------------------------------------------------------------------------
# Exit notices (NudgeQueue, channel "any", wake)
# ---------------------------------------------------------------------------
def test_natural_exit_enqueues_any_channel_notice(session):
_start_background(session, "echo done")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
entries = session._nudge_queue.pending(channel="any")
texts = [text for t, text in entries if t == "background_shell_exit"]
assert texts, "notice must ride channel 'any' so it can wake an idle workstream"
assert "bash_1" in texts[0]
assert "bash_output" in texts[0]
def test_exit_notice_carries_metadata(session):
_start_background(session, "exit 5")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
metadata = [
meta
for t, _text, meta in session._nudge_queue.pending_with_metadata()
if t == "background_shell_exit"
][0]
assert metadata["shell_id"] == "bash_1"
assert metadata["exit_code"] == 5
def test_exit_notice_triggers_wake_fn(session):
wakes = []
session._watch_wake_fn = lambda: wakes.append(1)
_start_background(session, "echo done")
assert _wait_until(lambda: wakes), "natural exit must wake an idle workstream"
def test_kill_shell_suppresses_exit_notice(session):
_start_background(session, "sleep 60")
shell = _only_shell(session)
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
prepared["execute"](prepared)
assert _wait_until(lambda: not _pid_alive(shell.pid))
time.sleep(0.3) # a buggy late notice would land within this window
assert not any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
def test_close_drops_pending_exit_notice_via_valid_until(session):
"""A notice for a shell that no longer exists (registry closed) must not
deliver the valid_until predicate drops it at drain time."""
_start_background(session, "echo done")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
session.close()
from turnstone.core.nudge_queue import USER_DRAIN
drained = session._nudge_queue.drain(USER_DRAIN)
assert not any(t == "background_shell_exit" for t, _text, _m in drained)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
def test_close_reaps_background_shells(session):
_start_background(session, "sleep 60")
shell = _only_shell(session)
session.close()
assert not _pid_alive(shell.pid)
def test_generation_cancel_does_not_reap_background_shells(session):
"""cancel() fires on mere stop-generation — a deliberately-detached
server must survive it. Only close()/kill_shell end it."""
_start_background(session, "sleep 60")
shell = _only_shell(session)
session.cancel()
time.sleep(0.3)
assert _pid_alive(shell.pid), "generation cancel must not kill detached shells"
# ---------------------------------------------------------------------------
# Review-hardening regressions (#817 code review)
# ---------------------------------------------------------------------------
def test_string_typed_background_flag_is_honored(session):
"""Providers intermittently send booleans as strings; 'true' must not
silently fall through to the foreground executor (where the group kill
would reap the server the model believed it detached)."""
for call_id, args in (
("s1", {"command": "sleep 30", "run_in_background": "true"}),
("s2", {"command": "sleep 30", "is_background": "True"}),
):
prepared = session._prepare_bash(call_id, args)
assert prepared["execute"] == session._exec_bash_background, args
def test_kill_shell_on_completed_shell_reports_already_exited(session):
_start_background(session, "true")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "completed")
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
_cid, output = prepared["execute"](prepared)
assert "already exited" in output.lower()
def test_exit_notice_survives_generation_abandon_without_waking(session):
"""cancel/interrupt/exception clear generation-scoped advisories, but an
external event (a background shell exited) still happened its notice
must survive to the next seam or the model keeps talking to a dead
server. It survives DEMOTED to 'quiet': still deliverable, but no
longer wake-eligible, so the workstream the user just stopped cannot
resume itself over it."""
from turnstone.core.nudge_queue import USER_DRAIN, WAKE_PENDING
_start_background(session, "echo done")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
session._queue_tool_advisory("tool_error", "3 consecutive tool errors")
session._drain_pending_advisories()
kinds = [t for t, _ in session._nudge_queue.pending()]
assert "background_shell_exit" in kinds
assert "tool_error" not in kinds
# Post-cancel quiescence: nothing is wake-eligible...
assert not session._nudge_queue.has_pending(WAKE_PENDING)
# ...yet the notice still delivers at the next legitimate seam.
drained = session._nudge_queue.drain(USER_DRAIN)
assert any(t == "background_shell_exit" for t, _x, _m in drained)
def test_int_typed_background_flag_is_honored(session):
prepared = session._prepare_bash("i1", {"command": "sleep 30", "run_in_background": 1})
assert prepared["execute"] == session._exec_bash_background
prepared_zero = session._prepare_bash("i2", {"command": "echo hi", "run_in_background": 0})
assert prepared_zero["execute"] == session._exec_bash
def test_bash_output_non_string_filter_errors_without_consuming(session):
_start_background(session, "echo hello; sleep 30")
shell = _only_shell(session)
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": 123})
assert "error" in prepared
assert "filter" in prepared["error"].lower()
# Nothing was consumed by the refused call.
assert _wait_until(lambda: shell.unread_lines > 0)
def test_filter_timeout_reports_error_without_consuming(session, monkeypatch):
from turnstone.core.background_shells import FilterTimeoutError
_start_background(session, "sleep 30")
shell = _only_shell(session)
def _boom(*a, **kw):
raise FilterTimeoutError("filter regex took longer than 2s to run")
monkeypatch.setattr(session._background_shells, "read", _boom)
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "(a+)+$"})
_cid, output = prepared["execute"](prepared)
assert "filter" in output.lower()
assert "error" in output.lower()
def test_registries_are_isolated_per_session():
"""Workstream isolation: a handle from one session must be unresolvable
from another buffers, ids, and kills never cross ChatSessions."""
session_a = make_session()
session_b = make_session()
try:
_start_background(session_a, "sleep 30")
shell_a = _only_shell(session_a)
read_b = session_b._prepare_bash_output("r", {"id": shell_a.shell_id})
_cid, output = read_b["execute"](read_b)
assert "no background shell" in output.lower()
kill_b = session_b._prepare_kill_shell("k", {"id": shell_a.shell_id})
_cid, kill_output = kill_b["execute"](kill_b)
assert "no background shell" in kill_output.lower()
assert _pid_alive(shell_a.pid), "another session must not be able to kill the shell"
finally:
session_a.close()
session_b.close()
def test_bash_output_polling_is_repeat_exempt(session):
"""Repeated identical bash_output calls ARE the documented monitoring
pattern the repeat detector must not brand them 'identical repeat'
(the delta result differs by construction) nor queue a repeat nudge."""
import json as _json
_start_background(session, "sleep 30")
shell = _only_shell(session)
args = _json.dumps({"id": shell.shell_id})
for i in range(5):
tool_calls = [{"id": f"t{i}", "function": {"name": "bash_output", "arguments": args}}]
results = [(f"t{i}", "bash_1 (running)\nNo new output since the last read.")]
session._apply_post_execute_advisories(tool_calls, results)
assert "identical repeat" not in results[0][1]
assert not any(t == "repeat" for t, _ in session._nudge_queue.pending())
def test_repeat_exempt_calls_still_break_other_streaks(session):
"""The exemption suppresses the WARNING, not the recording: a
bash_output poll interleaved between identical bash calls must reset
the bash streak otherwise the documented monitor-and-probe loop
(poll, curl health, poll, curl health) draws a false 'identical
repeat' on the probe."""
import json as _json
_start_background(session, "sleep 30")
shell = _only_shell(session)
poll_args = _json.dumps({"id": shell.shell_id})
probe_args = _json.dumps({"command": "curl -s localhost:8080/health"})
for i in range(6):
probe = [{"id": f"p{i}", "function": {"name": "bash", "arguments": probe_args}}]
probe_results = [(f"p{i}", "ok")]
session._apply_post_execute_advisories(probe, probe_results)
assert "identical repeat" not in probe_results[0][1], (
"interleaved probes are not a stuck loop"
)
poll = [{"id": f"q{i}", "function": {"name": "bash_output", "arguments": poll_args}}]
session._apply_post_execute_advisories(poll, [(f"q{i}", "no new output")])
def test_bash_repeats_still_warn(session):
"""The exemption is bash_output-specific: a genuinely stuck identical
bash loop still gets the warning."""
import json as _json
args = _json.dumps({"command": "echo test"})
warned = False
for i in range(5):
tool_calls = [{"id": f"b{i}", "function": {"name": "bash", "arguments": args}}]
results = [(f"b{i}", "test")]
session._apply_post_execute_advisories(tool_calls, results)
warned = warned or "identical repeat" in results[0][1]
assert warned
def test_quiet_only_entries_do_not_trigger_wake_delivery(session, monkeypatch):
"""A dispatched wake whose wake-eligible entries all evaporated must be
a no-op: quiet entries alone never resume a stopped workstream, and
they stay queued for the next legitimate seam."""
calls = []
monkeypatch.setattr(session, "send", lambda *a, **k: calls.append(1))
session._nudge_queue.enqueue("background_shell_exit", "old news", "quiet")
session.deliver_wake_nudge_from_queue()
assert calls == []
assert session._nudge_queue.pending(channel="quiet") == [("background_shell_exit", "old news")]
def test_wake_delivers_quiet_alongside_eligible_in_insertion_order(session, monkeypatch):
"""Quiet entries ride the wake AND cross-channel chronology holds: an
older demoted notice renders before the newer fire that earned the
wake (a poll counter must never run backwards)."""
seen = {}
def _fake_send(*a, **k):
seen["reminders"] = list(session._wake_drained_reminders or [])
session._wake_drained_reminders = None # emulate emission consuming
monkeypatch.setattr(session, "send", _fake_send)
session._nudge_queue.enqueue("background_shell_exit", "old", "quiet")
session._nudge_queue.enqueue("watch_triggered", "new", "any")
session.deliver_wake_nudge_from_queue()
types = [e["type"] for e in seen["reminders"]]
assert types == ["background_shell_exit", "watch_triggered"], (
"older quiet entry must precede the newer wake-eligible one"
)
assert session._nudge_queue.pending() == []
def test_failed_wake_reenqueue_preserves_valid_until(session, monkeypatch):
"""The re-enqueued notice keeps its staleness predicate — a stale
notice re-queued by a failed wake must still be droppable at its next
drain, not delivered against a gone shell."""
from turnstone.core.nudge_queue import USER_DRAIN
alive = {"value": True}
def _fail(*a, **k):
raise RuntimeError("storage down")
monkeypatch.setattr(session, "send", _fail)
session._nudge_queue.enqueue(
"background_shell_exit",
"server died",
"any",
valid_until=lambda: alive["value"],
)
with pytest.raises(RuntimeError):
session.deliver_wake_nudge_from_queue()
assert session._nudge_queue.pending(channel="quiet"), "notice must be re-queued"
alive["value"] = False # the shell record is gone now
drained = session._nudge_queue.drain(USER_DRAIN)
assert drained == [], "stale re-queued notice must drop via its predicate"
def test_mid_emit_failure_restashes_unemitted_tail(session, monkeypatch):
"""A failure while emitting reminder k of n must leave k..n recoverable
the wake caller's finally re-enqueues them instead of losing the
suffix."""
calls = {"n": 0}
def _append(source, text, **meta):
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("storage down")
monkeypatch.setattr(session, "_append_system_turn", _append)
session._wake_drained_reminders = [
{"type": "a", "text": "1"},
{"type": "b", "text": "2"},
{"type": "c", "text": "3"},
]
with pytest.raises(RuntimeError):
session._emit_pending_user_nudges()
assert session._wake_drained_reminders == [
{"type": "b", "text": "2"},
{"type": "c", "text": "3"},
]
def test_failed_wake_reenqueues_undelivered_as_quiet(session, monkeypatch):
"""A wake send that dies before emitting its drained reminders must not
eat them a shell's exit notice fires exactly once."""
def _fail(*a, **k):
raise RuntimeError("storage down")
monkeypatch.setattr(session, "send", _fail)
session._nudge_queue.enqueue(
"background_shell_exit", "server died", "any", metadata={"shell_id": "bash_1"}
)
with pytest.raises(RuntimeError):
session.deliver_wake_nudge_from_queue()
pending = session._nudge_queue.pending_with_metadata(channel="quiet")
assert [(t, x) for t, x, _m in pending] == [("background_shell_exit", "server died")]
assert pending[0][2] == {"shell_id": "bash_1"}
def test_failed_wake_preserves_chronology_and_stays_wake_quiescent(session, monkeypatch):
"""Failed-wake recovery invariants: (a) the re-queued external notice
keeps its seq, so the retry renders it BEFORE a newer event that
arrived during the failure; (b) NOTHING wake-eligible remains after
the failure external notices demote to quiet and user-channel
advisories are dropped outright, because a re-armed WAKE_PENDING gate
plus the zero-backoff worker-exit retry would respawn wake workers in
an unbounded hot loop against a persistent failure."""
from turnstone.core.nudge_queue import WAKE_PENDING
calls = {"n": 0}
seen = {}
def _send(*a, **k):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("transient storage failure")
seen["reminders"] = list(session._wake_drained_reminders or [])
session._wake_drained_reminders = None
monkeypatch.setattr(session, "send", _send)
session._nudge_queue.enqueue("watch_triggered", "poll-4", "any")
session._nudge_queue.enqueue("correction", "user advisory", "user")
with pytest.raises(RuntimeError):
session.deliver_wake_nudge_from_queue()
# (b) bounded: nothing left that could re-trigger the wake gate.
assert not session._nudge_queue.has_pending(WAKE_PENDING), (
"a failed wake must not leave wake-eligible entries (respawn hot loop)"
)
assert [t for t, _x in session._nudge_queue.pending(channel="quiet")] == ["watch_triggered"]
# A NEWER event lands after the failure...
session._nudge_queue.enqueue("watch_triggered", "poll-5", "any")
session.deliver_wake_nudge_from_queue()
texts = [e["text"] for e in seen["reminders"]]
# (a) ...and the retry renders old-before-new despite the round trip.
assert texts.index("poll-4") < texts.index("poll-5")
def test_exit_notice_emits_end_to_end_as_system_turn(session):
"""THE test whose absence hid an undeliverable notice for six review
rounds: drive the notice through REAL emission (make_system_turn +
_append_system_turn), not just queue assertions an unregistered
``_source`` raises ValueError only at this layer."""
_start_background(session, "echo done")
assert _wait_until(
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
)
from turnstone.core.trajectory import Role
before = len(session.messages)
session._emit_pending_user_nudges() # must not raise
new_turns = session.messages[before:]
assert any(
turn.role is Role.SYSTEM and turn.source == "background_shell_exit" for turn in new_turns
), f"exit notice must land as a first-class system turn, got {new_turns!r}"
def test_cli_exit_closes_every_loaded_session():
"""CLI exit must reap background shells in EVERY workstream, not just
the active one a server started before /new must not outlive /exit."""
from unittest.mock import MagicMock
from turnstone.cli import _close_all_sessions
ws_a, ws_b, ws_never_loaded = MagicMock(), MagicMock(), MagicMock()
ws_never_loaded.session = None
ws_a.session.close.side_effect = RuntimeError("bad teardown")
manager = MagicMock()
manager.list_all.return_value = [ws_a, ws_b, ws_never_loaded]
_close_all_sessions(manager) # must not raise
ws_a.session.close.assert_called_once()
ws_b.session.close.assert_called_once(), "one bad teardown must not stop the rest"
# Signal phase ran for every loaded session, before any close.
ws_a.session._background_shells.signal_all.assert_called_once()
ws_b.session._background_shells.signal_all.assert_called_once()
def test_cli_exit_ctrl_c_does_not_abort_the_reap():
"""Ctrl-C during the close phase must not escape the helper: the kill
signals already landed on every session in phase 1, and an escaping
KeyboardInterrupt would also skip MCP/registry shutdown in main()."""
from unittest.mock import MagicMock
from turnstone.cli import _close_all_sessions
ws_a, ws_b = MagicMock(), MagicMock()
ws_a.session.close.side_effect = KeyboardInterrupt
manager = MagicMock()
manager.list_all.return_value = [ws_a, ws_b]
_close_all_sessions(manager) # must not raise
ws_a.session._background_shells.signal_all.assert_called_once()
(
ws_b.session._background_shells.signal_all.assert_called_once(),
("signals must land on every session before the interruptible close phase"),
)
def test_non_string_reminder_text_drops_silently(session):
"""A dict reminder with non-str text must drop at the rail, not
TypeError out of the dispatch closure (WatchRunner would re-fire the
row every tick)."""
runner = type(
"R",
(),
{
"set_dispatch_fn": lambda self, ws, fn: None,
"remove_dispatch_fn": lambda self, ws, owner=None: None,
},
)()
session.set_watch_runner(runner)
session._watch_dispatch_fn({"text": 123, "watch_name": "w"}, "watch-1") # must not raise
assert session._nudge_queue.pending() == []
def test_string_typed_stop_on_error_is_honored(session):
"""One coercion dialect for every bash boolean: a string-typed
stop_on_error must add set -e in both branches, not silently drop it."""
fg = session._prepare_bash("f1", {"command": "echo hi", "stop_on_error": "true"})
assert fg["stop_on_error"] is True
bg = session._prepare_bash(
"b1", {"command": "echo hi", "run_in_background": True, "stop_on_error": "true"}
)
assert bg["stop_on_error"] is True
def test_non_dict_watch_reminder_drops_silently(session):
"""The rebuilt dispatch closure must drop a non-dict reminder like the
old code did a TypeError would make WatchRunner hold and re-fire the
row every tick."""
runner = type(
"R",
(),
{
"set_dispatch_fn": lambda self, ws, fn: None,
"remove_dispatch_fn": lambda self, ws, owner=None: None,
},
)()
session.set_watch_runner(runner)
dispatch = session._watch_dispatch_fn
dispatch("not a dict", "watch-1") # must not raise
assert session._nudge_queue.pending() == []
def test_truthy_flag_dialect_is_unified():
"""One coercion dialect file-wide — 'on' and nonzero numbers count, so a
provider quirk honored on coordinator tools is honored on bash too."""
from turnstone.core.session import _is_truthy_flag
assert _is_truthy_flag(True)
assert _is_truthy_flag("on")
assert _is_truthy_flag(2)
assert not _is_truthy_flag("off")
assert not _is_truthy_flag(0)
assert not _is_truthy_flag(None)
assert not _is_truthy_flag(False)
def test_bash_output_notes_clipped_lines_under_filter(session):
_start_background(session, "printf 'x%.0s' $(seq 1 5000); echo tail")
shell = _only_shell(session)
assert _wait_until(lambda: shell.status == "completed")
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "zzz"})
_cid, output = prepared["execute"](prepared)
assert "partially visible" in output
# ---------------------------------------------------------------------------
# task_agent scoping
# ---------------------------------------------------------------------------
def test_task_agent_shells_are_owner_scoped_and_reaped(session, monkeypatch):
seen = {}
def fake_run_agent(agent_turns, label="task", **kwargs):
out = _start_background(session, "sleep 60", call_id="sub-bash")
seen["start_output"] = out
agent_shells = session._background_shells.shells(owner="task-1")
seen["agent_shells"] = list(agent_shells)
seen["pid"] = agent_shells[0].pid if agent_shells else None
# The sub-agent's shell is invisible to the main scope.
seen["visible_to_parent"] = [s.shell_id for s in session._background_shells.shells()]
return "agent done"
monkeypatch.setattr(session, "_run_agent", fake_run_agent)
call_id, result = session._exec_task({"call_id": "task-1", "prompt": "start a server"})
assert "agent done" in result
assert seen["agent_shells"], "shell spawned inside the agent must carry its owner"
# Scope honesty in the start message: the sub-agent must not promise its
# caller a server that dies the moment it returns.
assert "terminated when the agent finishes" in seen["start_output"]
assert seen["visible_to_parent"] == []
assert seen["pid"] is not None
assert _wait_until(lambda: not _pid_alive(seen["pid"])), (
"sub-agent shells must be reaped when the agent finishes"
)
def test_task_agent_cannot_touch_parent_shells(session, monkeypatch):
_start_background(session, "sleep 60", call_id="parent-bash")
parent_shell = _only_shell(session)
seen = {}
def fake_run_agent(agent_turns, label="task", **kwargs):
prepared = session._prepare_bash_output("r", {"id": parent_shell.shell_id})
seen["read_output"] = prepared["execute"](prepared)[1]
prepared_kill = session._prepare_kill_shell("k", {"id": parent_shell.shell_id})
seen["kill_output"] = prepared_kill["execute"](prepared_kill)[1]
return "done"
monkeypatch.setattr(session, "_run_agent", fake_run_agent)
session._exec_task({"call_id": "task-1", "prompt": "snoop"})
assert "no background shell" in seen["read_output"].lower()
assert "no background shell" in seen["kill_output"].lower()
assert _pid_alive(parent_shell.pid), "agent must not be able to kill a parent shell"
def test_parent_scope_restored_after_task_agent(session, monkeypatch):
monkeypatch.setattr(session, "_run_agent", lambda *a, **k: "done")
session._exec_task({"call_id": "task-1", "prompt": "noop"})
output = _start_background(session, "sleep 30", call_id="after-task")
assert "bash_1" in output
assert _only_shell(session).owner is None
-165
View File
@@ -1,165 +0,0 @@
"""Regression tests for the bash tool hanging on a backgrounded child.
A bash command that backgrounds a long-lived process (``server &``,
``python -m http.server &``, any daemon) used to wedge the whole workstream
forever: the child inherits the tool's stdout/stderr pipe, so the foreground
read never hit EOF, and the timeout watchdog bailed the moment the tracked
``bash`` exited. ``_exec_bash`` now waits on the tracked process (not pipe
EOF) bounded by ``tool_timeout`` and kills the whole session group on exit, so
the call always returns and never leaks the background child.
"""
import threading
import time
from tests._proc_helpers import kill_pid as _kill_pid
from tests._proc_helpers import pid_alive as _pid_alive
from tests._session_helpers import NullUI, make_session
from turnstone.core.trajectory import EffectStatus
def _run_in_thread(fn, timeout):
"""Run ``fn`` in a daemon thread; return ``(finished, result)``."""
box = {}
def _target():
box["result"] = fn()
t = threading.Thread(target=_target, daemon=True)
t.start()
t.join(timeout)
return (not t.is_alive()), box.get("result")
def test_backgrounded_child_does_not_hang_and_is_reaped(tmp_path):
"""Foreground exits immediately but leaves ``sleep 60 &`` holding the pipe.
Old behaviour: infinite hang (EOF never arrives, watchdog bails once the
tracked bash exits). New behaviour: returns promptly and the background
child is reaped by the session-group kill.
"""
pidfile = str(tmp_path / "bg.pid")
# A generous tool_timeout proves the return comes from foreground-exit, not
# from the deadline firing.
session = make_session(tool_timeout=30)
command = f"sleep 60 & echo $! > {pidfile}; echo done"
bg_pid = None
try:
finished, result = _run_in_thread(
lambda: session._exec_bash({"call_id": "c1", "command": command}),
timeout=15,
)
assert finished, "_exec_bash hung on a backgrounded child"
assert result is not None
call_id, output = result
assert call_id == "c1"
assert "done" in output
# The backgrounded process must have been reaped by the group kill.
with open(pidfile) as f:
bg_pid = int(f.read().strip())
deadline = time.monotonic() + 5
while _pid_alive(bg_pid) and time.monotonic() < deadline:
time.sleep(0.05)
assert not _pid_alive(bg_pid), f"backgrounded child {bg_pid} leaked"
finally:
if bg_pid is not None:
_kill_pid(bg_pid)
def test_timeout_still_fires_with_backgrounded_child():
"""A silent foreground command plus a backgrounded child still hits the
deadline: the watchdog kills the whole group and the result reads UNKNOWN
(the ``unknown, never none`` timeout discipline)."""
session = make_session(tool_timeout=1)
command = "sleep 60 & sleep 60"
finished, result = _run_in_thread(
lambda: session._exec_bash({"call_id": "c1", "command": command}),
timeout=10,
)
assert finished, "_exec_bash did not return at its deadline"
assert result is not None
call_id, output = result
assert call_id == "c1"
assert "timed out" in output.lower()
assert "UNKNOWN" in output
assert session._tool_status.get("c1") is EffectStatus.UNKNOWN
def test_undecodable_output_is_preserved_not_swallowed():
"""Undecodable bytes on stdout must not silently vanish.
The drain's broad ``except (ValueError, OSError)`` would otherwise catch the
``UnicodeDecodeError`` (a ``ValueError``) and kill the thread before any line
was yielded dropping ALL output and reporting a clean success. ``Popen``
now decodes with ``errors="replace"`` so output always survives.
"""
session = make_session(tool_timeout=30)
# Valid lines bracketing a raw invalid-UTF-8 byte sequence.
command = r"printf 'before\n'; printf '\xff\xfe'; printf 'after\n'"
finished, result = _run_in_thread(
lambda: session._exec_bash({"call_id": "c1", "command": command}),
timeout=15,
)
assert finished
assert result is not None
_call_id, output = result
assert output != "(no output)"
assert "before" in output
assert "after" in output
def test_stdout_streams_to_ui_from_drain_thread():
"""stdout chunks are now emitted from the drain thread; they must still reach
``on_tool_output_chunk``."""
chunks: list[str] = []
class RecordingUI(NullUI):
def on_tool_output_chunk(self, call_id, chunk):
chunks.append(chunk)
session = make_session(tool_timeout=30, ui=RecordingUI())
finished, result = _run_in_thread(
lambda: session._exec_bash({"call_id": "c1", "command": "echo streamed-line"}),
timeout=15,
)
assert finished
assert any("streamed-line" in c for c in chunks)
def test_cancel_midbash_reports_unknown():
"""An external ``cancel()`` during a running bash unblocks the process-bounded
wait and reports UNKNOWN (unknown-never-none), not a clean result."""
session = make_session(tool_timeout=30)
def _cancel_soon():
time.sleep(0.5)
session.cancel()
threading.Thread(target=_cancel_soon, daemon=True).start()
finished, result = _run_in_thread(
lambda: session._exec_bash({"call_id": "c1", "command": "sleep 30"}),
timeout=15,
)
assert finished, "cancel did not unblock _exec_bash"
assert result is not None
_call_id, output = result
assert "cancelled" in output.lower()
assert session._tool_status.get("c1") is EffectStatus.UNKNOWN
def test_popen_failure_reports_cleanly(monkeypatch):
"""If ``Popen`` itself raises, the ``finally`` must not mask the real error
with ``UnboundLocalError`` ``proc`` is pre-bound to ``None``."""
from turnstone.core import session as session_mod
session = make_session(tool_timeout=30)
def _boom(*args, **kwargs):
raise OSError("cannot fork")
monkeypatch.setattr(session_mod.subprocess, "Popen", _boom)
call_id, output = session._exec_bash({"call_id": "c1", "command": "echo hi"})
assert call_id == "c1"
assert "cannot fork" in output
+3 -8
View File
@@ -13,7 +13,7 @@ from turnstone.core.session import (
ChatSession,
GenerationCancelled,
_CancelRef,
_tool_turn_meta,
_effect_status_meta,
)
from turnstone.core.trajectory import (
EffectStatus,
@@ -1183,13 +1183,8 @@ class TestEffectStatusPersistence:
effect-record appendix the ledger persists for audit)."""
def test_effect_status_meta_envelope(self):
assert _tool_turn_meta(None) is None
assert json.loads(_tool_turn_meta(EffectStatus.UNKNOWN)) == {"effect_status": "unknown"}
assert json.loads(_tool_turn_meta(None, {"kind": "web"})) == {"preview": {"kind": "web"}}
assert json.loads(_tool_turn_meta(EffectStatus.UNKNOWN, {"kind": "web"})) == {
"effect_status": "unknown",
"preview": {"kind": "web"},
}
assert _effect_status_meta(None) is None
assert json.loads(_effect_status_meta(EffectStatus.UNKNOWN)) == {"effect_status": "unknown"}
def test_reconstruct_routes_tool_effect_status(self):
from turnstone.core.storage._utils import reconstruct_turns
+11 -25
View File
@@ -41,11 +41,6 @@ def _bind_ws_event_handlers(bot, cls):
attr = getattr(cls, name)
if callable(attr):
setattr(bot, name, attr.__get__(bot, cls))
# ``_handle_stream_end`` delegates the all-cycles sweep to
# ``_pop_ws_approvals``; bind the real method too so dispatcher
# tests observe the pop instead of a spec'd AsyncMock no-op.
if hasattr(cls, "_pop_ws_approvals"):
bot._pop_ws_approvals = cls._pop_ws_approvals.__get__(bot, cls)
def _make_message(*, bot=False, guild=True, content="hello", channel=None, reference=None):
@@ -542,7 +537,7 @@ class TestApprovalVerdictDisplay:
},
}
]
event = ApproveRequestEvent(ws_id="ws-1", cycle_id="cyc-1", items=items)
event = ApproveRequestEvent(ws_id="ws-1", items=items)
_run(bot._on_ws_event("ws-1", thread, event))
# thread.send was called with an embed containing a verdict field
@@ -556,8 +551,8 @@ class TestApprovalVerdictDisplay:
assert "HIGH" in field.value
assert "85%" in field.value
# Pending approval message tracked under (ws_id, cycle_id).
assert ("ws-1", "cyc-1") in bot._pending_approval_msgs
# Pending approval message tracked
assert "ws-1" in bot._pending_approval_msgs
def test_approval_without_verdict(self):
"""ApproveRequestEvent items without verdict still work normally."""
@@ -590,11 +585,10 @@ class TestApprovalVerdictDisplay:
embed = MagicMock()
msg.embeds = [embed]
msg.edit = AsyncMock()
bot._pending_approval_msgs[("ws-1", "cyc-1")] = (msg, frozenset({"c-1"}))
bot._pending_approval_msgs["ws-1"] = msg
event = IntentVerdictEvent(
ws_id="ws-1",
call_id="c-1",
func_name="bash",
risk_level="high",
recommendation="deny",
@@ -634,10 +628,7 @@ class TestApprovalVerdictDisplay:
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {
("ws-1", "cyc-1"): (MagicMock(), frozenset()),
("ws-1", "cyc-2"): (MagicMock(), frozenset()),
}
bot._pending_approval_msgs = {"ws-1": MagicMock()}
bot._notify_reply_channels = {}
_bind_ws_event_handlers(bot, TurnstoneBot)
@@ -645,8 +636,7 @@ class TestApprovalVerdictDisplay:
event = StreamEndEvent(ws_id="ws-1")
_run(bot._on_ws_event("ws-1", thread, event))
# ALL of the ws's cycles are swept, not just one entry.
assert not bot._pending_approval_msgs
assert "ws-1" not in bot._pending_approval_msgs
class TestStreamEndBehavior:
@@ -1667,21 +1657,19 @@ class TestApprovalResolved:
bot = self._make_bot()
thread = AsyncMock()
# Set up a pending approval message with components. The event
# below carries no cycle_id (pre-multi-cycle server) — the
# legacy fallback clears the ws's single tracked entry.
# Set up a pending approval message with components.
approval_msg = MagicMock()
approval_msg.embeds = [MagicMock()]
approval_msg.components = []
approval_msg.edit = AsyncMock()
bot._pending_approval_msgs[("ws-1", "cyc-1")] = (approval_msg, frozenset())
bot._pending_approval_msgs["ws-1"] = approval_msg
event = ApprovalResolvedEvent(ws_id="ws-1", approved=False, feedback="timeout")
_run(bot._on_ws_event("ws-1", thread, event))
approval_msg.edit.assert_awaited_once()
# Pending approval message should be removed.
assert not bot._pending_approval_msgs
assert "ws-1" not in bot._pending_approval_msgs
def test_disables_buttons_on_approved(self):
from turnstone.sdk.events import ApprovalResolvedEvent
@@ -1693,11 +1681,9 @@ class TestApprovalResolved:
approval_msg.embeds = [MagicMock()]
approval_msg.components = []
approval_msg.edit = AsyncMock()
bot._pending_approval_msgs[("ws-1", "cyc-1")] = (approval_msg, frozenset())
bot._pending_approval_msgs["ws-1"] = approval_msg
# Cycle-routed resolution: the event's cycle_id selects exactly
# this tracked message.
event = ApprovalResolvedEvent(ws_id="ws-1", approved=True, cycle_id="cyc-1")
event = ApprovalResolvedEvent(ws_id="ws-1", approved=True)
_run(bot._on_ws_event("ws-1", thread, event))
approval_msg.edit.assert_awaited_once()
+3 -5
View File
@@ -87,7 +87,7 @@ class TestSendApproval:
monkeypatch.setattr(router._server, "approve", mock_approve)
await router.send_approval("ws-1", "corr-abc", approved=True, feedback="ok")
mock_approve.assert_awaited_once_with(
ws_id="ws-1", approved=True, feedback="ok", always=False, cycle_id="corr-abc"
ws_id="ws-1", approved=True, feedback="ok", always=False
)
@pytest.mark.anyio
@@ -99,7 +99,7 @@ class TestSendApproval:
monkeypatch.setattr(router._server, "approve", mock_approve)
await router.send_approval("ws-1", "corr-abc", approved=False)
mock_approve.assert_awaited_once_with(
ws_id="ws-1", approved=False, feedback=None, always=False, cycle_id="corr-abc"
ws_id="ws-1", approved=False, feedback=None, always=False
)
@pytest.mark.anyio
@@ -110,9 +110,7 @@ class TestSendApproval:
mock_approve = AsyncMock()
monkeypatch.setattr(console_router._console, "route_approve", mock_approve)
await console_router.send_approval("ws-1", "corr-abc", approved=True, always=True)
mock_approve.assert_awaited_once_with(
ws_id="ws-1", approved=True, feedback="", always=True, cycle_id="corr-abc"
)
mock_approve.assert_awaited_once_with(ws_id="ws-1", approved=True, feedback="", always=True)
class TestDeleteRoute:
+9 -24
View File
@@ -576,11 +576,10 @@ class TestApprovalOwnership:
bot, router, client = _make_bot()
ws_id = "ws-1"
bot._pending_approval[(ws_id, "corr-1")] = PendingApproval( # type: ignore[attr-defined]
bot._pending_approval[ws_id] = PendingApproval( # type: ignore[attr-defined]
channel="C01SAPU5414",
message_ts="111.222",
owner_user_id="U_OWNER",
cycle_id="corr-1",
)
body = {
@@ -599,11 +598,10 @@ class TestApprovalOwnership:
bot, router, client = _make_bot()
ws_id = "ws-1"
bot._pending_approval[(ws_id, "corr-1")] = PendingApproval( # type: ignore[attr-defined]
bot._pending_approval[ws_id] = PendingApproval( # type: ignore[attr-defined]
channel="C01SAPU5414",
message_ts="111.222",
owner_user_id="U_OWNER",
cycle_id="corr-1",
)
body = {
@@ -622,11 +620,10 @@ class TestApprovalOwnership:
bot, router, client = _make_bot()
ws_id = "ws-1"
bot._pending_approval[(ws_id, "corr-1")] = PendingApproval( # type: ignore[attr-defined]
bot._pending_approval[ws_id] = PendingApproval( # type: ignore[attr-defined]
channel="C01SAPU5414",
message_ts="111.222",
owner_user_id="U_OWNER",
cycle_id="corr-1",
)
body = {
@@ -779,9 +776,7 @@ class TestWsEventDispatch:
bot, client = self._make_ws_bot()
event = ApproveRequestEvent(
ws_id="ws-1",
cycle_id="cyc-1",
items=[{"call_id": "c-1", "func_name": "bash", "needs_approval": True}],
ws_id="ws-1", items=[{"func_name": "bash", "needs_approval": True}]
)
route = SlackRoute(channel="C1", user_id="U12345", thread_ts="123.456")
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
@@ -789,12 +784,8 @@ class TestWsEventDispatch:
client.chat_postMessage.assert_awaited_once()
call_kwargs = client.chat_postMessage.call_args[1]
assert "blocks" in call_kwargs
# Tracked under (ws_id, cycle_id) so concurrent cycles each get
# their own Slack message.
entry = bot._pending_approval[("ws-1", "cyc-1")] # type: ignore[attr-defined]
assert entry.owner_user_id == "U12345"
assert entry.cycle_id == "cyc-1"
assert entry.call_ids == frozenset({"c-1"})
assert "ws-1" in bot._pending_approval # type: ignore[attr-defined]
assert bot._pending_approval["ws-1"].owner_user_id == "U12345" # type: ignore[attr-defined]
def test_intent_verdict_updates_approval_message(self) -> None:
from turnstone.channels.slack.bot import PendingApproval
@@ -806,17 +797,14 @@ class TestWsEventDispatch:
return_value={"ok": True, "messages": [{"blocks": []}]}
)
bot._pending_approval[("ws-1", "cyc-1")] = PendingApproval( # type: ignore[attr-defined]
bot._pending_approval["ws-1"] = PendingApproval( # type: ignore[attr-defined]
channel="C1",
message_ts="999.000",
owner_user_id="U12345",
cycle_id="cyc-1",
call_ids=frozenset({"c-1"}),
)
event = IntentVerdictEvent(
ws_id="ws-1",
call_id="c-1",
func_name="bash",
risk_level="high",
confidence=0.9,
@@ -833,20 +821,17 @@ class TestWsEventDispatch:
from turnstone.sdk.events import ApprovalResolvedEvent
bot, client = self._make_ws_bot()
bot._pending_approval[("ws-1", "cyc-9")] = PendingApproval( # type: ignore[attr-defined]
bot._pending_approval["ws-1"] = PendingApproval( # type: ignore[attr-defined]
channel="C1",
message_ts="999.000",
owner_user_id="U12345",
cycle_id="cyc-9",
)
# Event WITHOUT a cycle_id (pre-multi-cycle server): the legacy
# fallback clears the ws's single tracked entry, as before.
event = ApprovalResolvedEvent(ws_id="ws-1", approved=True)
route = SlackRoute(channel="C1", user_id="U12345", thread_ts="123.456")
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
assert not bot._pending_approval # type: ignore[attr-defined]
assert "ws-1" not in bot._pending_approval # type: ignore[attr-defined]
client.chat_update.assert_awaited_once()
def test_link_prefix_does_not_hijack_regular_prompt(self) -> None:
-129
View File
@@ -1600,82 +1600,6 @@ class TestConsoleProxy:
# browser's interactive UI 403-loops on every retry.
assert sse_mock.await_args.kwargs.get("use_service_auth") is True
def test_proxy_events_global_403_without_cluster_inspect(self, mock_collector):
"""A plain authenticated user (no service scope, no
admin.cluster.inspect) cannot reach the node's cross-tenant
firehose through the proxy: elevating to the console's service
identity would bypass per-user filtering, so the path is
operator-gated. _proxy_sse must NOT be reached."""
from unittest.mock import AsyncMock, patch
from starlette.responses import Response
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
_load_static()
app = create_app(collector=mock_collector, jwt_secret=_TEST_JWT_SECRET)
user_jwt = create_jwt(
user_id="plain-user",
scopes=frozenset({"read"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
permissions=frozenset(),
)
user_client = TestClient(
app,
raise_server_exceptions=False,
headers={"Authorization": f"Bearer {user_jwt}"},
)
with patch(
"turnstone.console.server._proxy_sse",
new_callable=AsyncMock,
return_value=Response("ok", status_code=200),
) as sse_mock:
resp = user_client.get("/node/node-a/v1/api/events/global")
assert resp.status_code == 403
assert sse_mock.await_count == 0
user_client.close()
def test_proxy_events_global_allows_cluster_inspect(self, mock_collector):
"""An operator holding admin.cluster.inspect passes the gate and
reaches the SSE proxy with the service token."""
from unittest.mock import AsyncMock, patch
from starlette.responses import Response
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
_load_static()
app = create_app(collector=mock_collector, jwt_secret=_TEST_JWT_SECRET)
op_jwt = create_jwt(
user_id="operator",
scopes=frozenset({"read"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
permissions=frozenset({"admin.cluster.inspect"}),
)
op_client = TestClient(
app,
raise_server_exceptions=False,
headers={"Authorization": f"Bearer {op_jwt}"},
)
with patch(
"turnstone.console.server._proxy_sse",
new_callable=AsyncMock,
return_value=Response("ok", status_code=200),
) as sse_mock:
resp = op_client.get("/node/node-a/v1/api/events/global")
assert resp.status_code == 200
assert sse_mock.await_count == 1
assert sse_mock.await_args.kwargs.get("use_service_auth") is True
op_client.close()
def test_proxy_api_per_ws_events_uses_user_auth_not_service(self, client, mock_collector):
"""Per-ws events route uses the user's re-minted JWT, not the
service token the upstream per-ws SSE handler scopes by
@@ -2690,56 +2614,3 @@ class TestCollectorMCPAggregation:
assert overview["mcp_servers"] == 3
assert overview["mcp_resources"] == 10
assert overview["mcp_prompts"] == 7
class TestProxyGetHeaderPassThrough:
"""The generic /node/{id} GET proxy must carry the node's hardening
headers through dropping Content-Security-Policy would serve previewed
attacker HTML from the CONSOLE origin with no CSP sandbox (review
finding, preview-pane branch)."""
def test_security_headers_forwarded(self, monkeypatch):
from types import SimpleNamespace
from unittest.mock import MagicMock
import httpx
from turnstone.console import server as csrv
upstream = httpx.Response(
200,
content=b"<html>page</html>",
headers={
"content-type": "text/html; charset=utf-8",
"content-security-policy": "sandbox",
"x-content-type-options": "nosniff",
"content-disposition": 'inline; filename="p"',
"cache-control": "private, no-store",
"server": "upstream-internal", # hop metadata: must NOT pass
},
request=httpx.Request("GET", "http://n:1/x"),
)
async def _mock_get(*a, **kw):
return upstream
proxy_client = MagicMock(spec=httpx.AsyncClient)
proxy_client.get = MagicMock(side_effect=_mock_get)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(proxy_client=proxy_client)),
url=SimpleNamespace(query=""),
)
monkeypatch.setattr(csrv, "_proxy_auth_headers", lambda r: {})
resp = asyncio.run(csrv._proxy_get(request, "http://n:1", "v1/api/x"))
assert resp.status_code == 200
assert resp.headers["content-security-policy"] == "sandbox"
assert resp.headers["x-content-type-options"] == "nosniff"
assert resp.headers["content-disposition"] == 'inline; filename="p"'
assert resp.headers["cache-control"] == "private, no-store"
assert resp.headers["content-type"].startswith("text/html")
assert (
"server" not in {k.lower() for k in resp.headers}
or resp.headers.get("server") != "upstream-internal"
)
+5 -83
View File
@@ -336,88 +336,10 @@ def test_channel_default_alias_blanked_when_disabled(
def test_models_payload_strips_secret_fields(storage: SQLiteBackend) -> None:
"""Regression guard: only alias/model/provider (+ the derived
effort_ladder) land in the response, never api_key / base_url /
context_window / raw capabilities."""
"""Regression guard: only alias/model/provider land in the response,
never api_key / base_url / context_window / capabilities."""
_seed_model(storage, definition_id="m1", alias="primary")
body = _get_models(_make_client(storage))
assert len(body["models"]) == 1
entry = body["models"][0]
assert set(entry) == {"alias", "model", "provider", "effort_ladder"}
assert entry["alias"] == "primary"
assert entry["model"] == "model-x"
assert entry["provider"] == "openai-compatible"
def test_effort_ladder_parses_string_capabilities(storage: SQLiteBackend) -> None:
"""The capabilities column is a JSON STRING — the ladder must survive
the parse (regression: .items() on the raw string threw and the
guard silently dropped the field from every row)."""
storage.create_model_definition(
definition_id="m1",
alias="qwen",
model="qwen3.6-27b",
provider="anthropic-compatible",
base_url="http://localhost:8000",
api_key="dummy",
context_window=262144,
capabilities='{"thinking_mode": "manual", "thinking_param": "enable_thinking"}',
enabled=True,
created_by="admin",
)
body = _get_models(_make_client(storage))
ladder = {r["value"]: r["effective"] for r in body["models"][0]["effort_ladder"]}
assert ladder["none"] == "off"
assert ladder["medium"] == "on+medium"
assert ladder["max"] == "on+max"
def test_effort_ladder_key_survives_malformed_capabilities(
storage: SQLiteBackend,
) -> None:
"""A capabilities column that fails to parse must not drop the key —
every row carries ``effort_ladder`` (empty on failure) so clients can
index it unconditionally instead of null-checking per row."""
storage.create_model_definition(
definition_id="m1",
alias="broken",
model="model-x",
provider="openai-compatible",
base_url="http://localhost:8000/v1",
api_key="dummy",
context_window=131072,
capabilities="{not valid json",
enabled=True,
created_by="admin",
)
body = _get_models(_make_client(storage))
entry = body["models"][0]
assert set(entry) == {"alias", "model", "provider", "effort_ladder"}
assert entry["effort_ladder"] == []
def test_effort_ladder_honors_responses_api_surface(storage: SQLiteBackend) -> None:
"""server_compat.api_surface (namespaced inside the capabilities JSON)
switches the projection to the flat-param path no template toggle."""
caps = (
'{"thinking_mode": "manual", "thinking_param": "enable_thinking",'
' "reasoning_effort_values": ["low", "medium", "high"],'
' "server_compat": {"api_surface": "responses"}}'
)
storage.create_model_definition(
definition_id="m1",
alias="mistral",
model="mistral-medium",
provider="openai-compatible",
base_url="http://localhost:8000/v1",
api_key="dummy",
context_window=131072,
capabilities=caps,
enabled=True,
created_by="admin",
)
body = _get_models(_make_client(storage))
ladder = {r["value"]: r["effective"] for r in body["models"][0]["effort_ladder"]}
# Responses surface: flat param only — no "on+"/"off" toggle tokens.
assert ladder["medium"] == "medium"
assert ladder["none"] == "default"
assert body["models"] == [
{"alias": "primary", "model": "model-x", "provider": "openai-compatible"}
]
-106
View File
@@ -1,106 +0,0 @@
"""``POST /v1/api/admin/models/effort-ladder`` — live modal projection.
Pure computation over (provider, model, unsaved capability overrides,
api_surface); every malformed input must land as a 400, never a 500
the body is operator-typed form state.
"""
from __future__ import annotations
from typing import Any
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.testclient import TestClient
from tests._coord_test_helpers import _AuthMiddleware
from turnstone.console.server import admin_effort_ladder
def _make_client() -> TestClient:
app = Starlette(
routes=[Route("/v1/api/admin/models/effort-ladder", admin_effort_ladder, methods=["POST"])],
middleware=[Middleware(_AuthMiddleware)],
)
client = TestClient(app)
client.headers.update({"X-Test-User": "admin", "X-Test-Perms": "admin.models"})
return client
def _post(client: TestClient, body: Any) -> Any:
return client.post("/v1/api/admin/models/effort-ladder", json=body)
def test_valid_request_returns_ladder() -> None:
resp = _post(
_make_client(),
{
"provider": "anthropic-compatible",
"model": "qwen3.6-27b",
"capabilities": {"thinking_mode": "manual", "thinking_param": "enable_thinking"},
},
)
assert resp.status_code == 200, resp.text
ladder = {r["value"]: r["effective"] for r in resp.json()["ladder"]}
assert ladder["none"] == "off"
assert ladder["high"] == "on+high"
def test_api_surface_switches_projection() -> None:
body = {
"provider": "openai-compatible",
"model": "m",
"capabilities": {
"thinking_mode": "manual",
"reasoning_effort_values": ["low", "medium", "high"],
},
}
client = _make_client()
chat = {r["value"]: r["effective"] for r in _post(client, body).json()["ladder"]}
body["api_surface"] = "responses"
responses = {r["value"]: r["effective"] for r in _post(client, body).json()["ladder"]}
assert chat["medium"] == "on+medium" # toggle + flat on the chat surface
assert responses["medium"] == "medium" # flat only on the responses surface
def test_non_dict_json_body_is_400_not_500() -> None:
client = _make_client()
for body in (None, [], "x", 7):
resp = _post(client, body)
assert resp.status_code == 400, (body, resp.status_code, resp.text)
def test_unknown_provider_is_400() -> None:
resp = _post(_make_client(), {"provider": "nope", "model": "m"})
assert resp.status_code == 400
def test_missing_model_is_400() -> None:
resp = _post(_make_client(), {"provider": "openai", "model": ""})
assert resp.status_code == 400
def test_non_dict_capabilities_is_400() -> None:
resp = _post(_make_client(), {"provider": "openai", "model": "m", "capabilities": [1]})
assert resp.status_code == 400
def test_garbage_capability_value_types_are_400() -> None:
"""Wrong-typed override values raise inside the resolver → clean 400."""
resp = _post(
_make_client(),
{
"provider": "anthropic",
"model": "claude-fable-5",
"capabilities": {"supports_effort": True, "effort_levels": 5},
},
)
assert resp.status_code == 400
def test_requires_admin_models_permission() -> None:
client = _make_client()
client.headers.update({"X-Test-Perms": "read"})
resp = _post(client, {"provider": "openai", "model": "m"})
assert resp.status_code in (401, 403)
-16
View File
@@ -363,22 +363,6 @@ class TestClusterCreate:
assert mock_post.call_args.kwargs["json"]["project_id"] == "proj-42"
client.close()
def test_cluster_create_forwards_persona(self) -> None:
# The launcher's persona picker sends persona; the proxy selectively
# REBUILDS the forwarded body (it doesn't pass it through), so persona
# must be explicitly carried or the receiving node stamps its kind
# default instead of the operator's choice.
mock_post = _make_proxy_post(json_data={"ws_id": "p1ws"})
client = TestClient(self._app_with_node(mock_post), raise_server_exceptions=False)
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "node-a", "name": "j", "persona": "scribe"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
assert mock_post.call_args.kwargs["json"]["persona"] == "scribe"
client.close()
# ---------------------------------------------------------------------------
# Tests — route_proxy
+1
View File
@@ -855,6 +855,7 @@ class TestChunkedCompaction:
# A small but non-empty tool set so _tool_def_tokens() > 0 makes the
# assertion meaningful.
session._tool_search = None
session.creative_mode = False
session._tools = [
{
"type": "function",
+2 -7
View File
@@ -114,16 +114,11 @@ def test_coord_on_aux_usage_leaves_live_counters_untouched() -> None:
assert ui._ws_context_ratio == 0.0
def test_coord_on_content_token_accumulates(monkeypatch: pytest.MonkeyPatch) -> None:
def test_coord_on_content_token_accumulates() -> None:
"""Pre-lift coord ``on_content_token`` only enqueued; lift turns it
into the same per-ws accumulator WebUI uses so the collector
broadcast can piggyback the joined turn content on the IDLE
state-change event.
Batch window forced to 0 (per-token flush) pins the accumulator
wiring, not the batching cadence (test_sse_token_batching.py)."""
monkeypatch.setattr("turnstone.core.session_ui_base._TOKEN_BATCH_WINDOW_SECS", 0.0)
state-change event."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui.on_content_token("Hello ")
ui.on_content_token("world")
+9 -14
View File
@@ -16,10 +16,10 @@ to ``SessionUIBase`` automatically enables:
from __future__ import annotations
import threading
from typing import Any
from unittest.mock import MagicMock, patch
from tests.conftest import resolve_when_pending
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
@@ -153,7 +153,7 @@ def test_coord_heuristic_verdict_persists_to_storage() -> None:
items[0]["_heuristic_verdict"] = hv
storage = MagicMock()
timer = resolve_when_pending(ui, False)
timer = threading.Timer(0.05, lambda: ui.resolve_approval(False))
timer.start()
try:
with _patch_storage(storage):
@@ -246,8 +246,9 @@ def test_coord_pending_approval_sets_activity_tag() -> None:
def _capture_activity() -> None:
captured["activity"] = ui._ws_current_activity
captured["state"] = ui._ws_activity_state
ui.resolve_approval(False)
timer = resolve_when_pending(ui, False, before=_capture_activity)
timer = threading.Timer(0.05, _capture_activity)
timer.start()
try:
with _patch_storage(MagicMock()):
@@ -291,7 +292,7 @@ def test_coord_judge_pending_flag_dynamic_when_heuristic_present() -> None:
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
timer = resolve_when_pending(ui, False)
timer = threading.Timer(0.05, lambda: ui.resolve_approval(False))
timer.start()
try:
with _patch_storage(MagicMock()):
@@ -337,7 +338,7 @@ def test_coord_judge_pending_false_when_no_heuristic_verdict() -> None:
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
timer = resolve_when_pending(ui, False)
timer = threading.Timer(0.05, lambda: ui.resolve_approval(False))
timer.start()
try:
with _patch_storage(MagicMock()):
@@ -409,7 +410,7 @@ def test_coord_budget_override_prompts_even_under_blanket_auto_approve() -> None
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
timer = resolve_when_pending(ui, True)
timer = threading.Timer(0.05, lambda: ui.resolve_approval(True))
timer.start()
try:
with _patch_storage(MagicMock()):
@@ -452,7 +453,7 @@ def test_coord_budget_override_survives_wildcard_allow_policy() -> None:
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
timer = resolve_when_pending(ui, True)
timer = threading.Timer(0.05, lambda: ui.resolve_approval(True))
timer.start()
try:
with _patch_storage(MagicMock()), _patch_policies({"__budget_override__": "allow"}):
@@ -525,16 +526,12 @@ class TestBroadcastApprovalResolved:
collector = MagicMock()
ConsoleCoordinatorUI._collector = collector
try:
ui._broadcast_approval_resolved(
True, "lgtm", always=True, cycle_id="cyc-1", call_ids=("c-1", "c-2")
)
ui._broadcast_approval_resolved(True, "lgtm", always=True)
collector.emit_console_ws_approval_resolved.assert_called_once_with(
"coord-a",
approved=True,
feedback="lgtm",
always=True,
cycle_id="cyc-1",
call_ids=["c-1", "c-2"],
)
finally:
ConsoleCoordinatorUI._collector = None
@@ -550,8 +547,6 @@ class TestBroadcastApprovalResolved:
approved=False,
feedback="",
always=False,
cycle_id="",
call_ids=[],
)
finally:
ConsoleCoordinatorUI._collector = None
+1 -39
View File
@@ -73,7 +73,7 @@ def _make_ws(**overrides: Any) -> Workstream:
def test_emit_created_calls_collector_with_coord_fields() -> None:
adapter, collector = _make_adapter()
ws = _make_ws(project_id="p1", persona="executive")
ws = _make_ws(project_id="p1")
adapter.emit_created(ws)
collector.emit_console_ws_created.assert_called_once_with(
"coord-1",
@@ -84,8 +84,6 @@ def test_emit_created_calls_collector_with_coord_fields() -> None:
parent_ws_id=None,
# Tenancy-load-bearing: the console SSE filter gates on this.
project_id="p1",
# Display carrier: the pseudo-node row + ws_created event wear it.
persona="executive",
)
@@ -195,21 +193,6 @@ def test_emit_tolerates_collector_exception() -> None:
# ---------------------------------------------------------------------------
def test_cleanup_ui_sweeps_all_approval_cycles_on_registry_uis() -> None:
"""The real ConsoleCoordinatorUI carries the approval-cycle
registry: cleanup denies + wakes EVERY parked gate via
``resolve_all_approvals`` (parallel task agents can hold several),
not the pre-cycle single-slot kick."""
adapter, _ = _make_adapter()
ws = _make_ws()
ws.ui.resolve_all_approvals = MagicMock(return_value=2) # type: ignore[attr-defined]
adapter.cleanup_ui(ws)
ws.ui.resolve_all_approvals.assert_called_once_with( # type: ignore[attr-defined]
False, "Workstream closed"
)
assert ws.ui._fg_event.is_set() # type: ignore[attr-defined]
def test_cleanup_ui_unblocks_events_and_broadcasts_to_listeners() -> None:
adapter, _ = _make_adapter()
ws = _make_ws()
@@ -245,24 +228,6 @@ def test_cleanup_ui_tolerates_missing_session_and_ui() -> None:
ws.session = None
ws.ui = None
adapter.cleanup_ui(ws) # no crash
assert ws._closed is True # still marked dead
def test_cleanup_ui_marks_workstream_closed() -> None:
"""Every teardown path — close, close_idle, EVICTION, delete,
discard funnels through cleanup_ui, which marks the object dead
under ``ws._lock`` BEFORE the teardown body runs. The wake paths
that hold OBJECT references (the watch ``wake_fn``,
``session_worker``'s exit backstop) gate on ``_closed``, and
``session_worker.send`` re-checks it under the same lock without
this write here, a wake racing an eviction or delete (which never
set the flag) would spawn a full unattended turn on the torn-down
session."""
adapter, _ = _make_adapter()
ws = _make_ws()
assert ws._closed is False
adapter.cleanup_ui(ws)
assert ws._closed is True
# ---------------------------------------------------------------------------
@@ -324,7 +289,6 @@ class _SendSession:
) -> None:
self.send_calls: list[str] = []
self.queue_calls: list[str] = []
self.interjector_ids: list[str] = []
self._queue_full = queue_full
# When set, ``send`` blocks on this event — lets the test pin a
# worker inside session.send while a second thread races through
@@ -351,11 +315,9 @@ class _SendSession:
message: str,
attachment_ids: Any = None,
queue_msg_id: str | None = None,
interjector_user_id: str = "",
) -> None:
if self._queue_full:
raise queue.Full
self.interjector_ids.append(interjector_user_id)
self.queue_calls.append(message)
def cancel(self) -> None:
+59 -285
View File
@@ -42,7 +42,6 @@ from turnstone.console.server import (
_coord_create_post_install,
_coord_create_validate_request,
_coord_saved_loaded_lookup,
_coordinator_tenant_check,
_require_admin_coordinator,
_require_coord_mgr,
cluster_ws_detail,
@@ -84,24 +83,15 @@ def _coord_attach_owner(request, ws_id, mgr):
Kind-strict coord attachments can only be accessed for
workstreams currently held by ``coord_mgr``; no storage fallback
so cross-kind ws_ids 404 instead of leaking through storage. Also
project-tenancy-strict: mirrors ``_coord_attachment_owner`` so a
private-project coordinator's attachments 404-mask non-members.
so cross-kind ws_ids 404 instead of leaking through storage.
"""
from starlette.responses import JSONResponse
from turnstone.core.auth import WorkstreamProjectVisibility
from turnstone.core.web_helpers import auth_user_id
ws = mgr.get(ws_id)
if ws is None:
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
visibility = WorkstreamProjectVisibility.for_request(request, storage=storage)
if not visibility.ws_visible(getattr(ws, "project_id", "") or "", ws_owner=ws.user_id or ""):
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
return ws.user_id or auth_user_id(request), None
@@ -111,7 +101,7 @@ def _coord_attach_owner(request, ws_id, mgr):
_coord_endpoint_config = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=_require_coord_mgr,
tenant_check=_coordinator_tenant_check,
tenant_check=None,
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
supports_attachments=True,
@@ -531,16 +521,11 @@ def test_active_list_row_shape_includes_unified_fields(storage):
"parent_ws_id",
"user_id",
"project_id",
"persona",
}
assert row["name"] == "lifted-coord"
assert row["kind"] == "coordinator"
assert row["parent_ws_id"] is None
assert row["user_id"] == "u1"
# mgr.create without a persona kwarg stamps nothing at this layer
# (default resolution lives in the HTTP create handler), so the
# row carries the null slug — not a fabricated default.
assert row["persona"] is None
def test_create_returns_ws_id_and_records_audit(storage):
@@ -1113,7 +1098,18 @@ def test_approve_resolves_ui_event(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
assert isinstance(ws.ui, ConsoleCoordinatorUI)
cycle = _seed_pending(ws, "c-1")
ws.ui._pending_approval = {
"type": "approve_request",
"items": [
{
"call_id": "c-1",
"func_name": "spawn_workstream",
"approval_label": "spawn_workstream",
"needs_approval": True,
}
],
}
ws.ui._approval_event.clear()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
@@ -1121,46 +1117,34 @@ def test_approve_resolves_ui_event(storage):
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] == cycle.cycle_id
assert cycle.event.is_set()
assert cycle.result == (True, None)
assert ws.ui._approval_event.is_set()
assert ws.ui._approval_result == (True, None)
assert "spawn_workstream" in ws.ui.auto_approve_tools
def _seed_pending(ws, *call_ids: str, func_name: str = "spawn_workstream"):
"""Register a live ApprovalCycle on the coord UI the way its
``approve_tools`` gate does, returning the cycle for direct
event/result assertions (the pre-cycle singleton
``_approval_event`` / ``_approval_result`` slots are gone)."""
from turnstone.core.session_ui_base import ApprovalCycle
items = [
{
"call_id": cid,
"func_name": func_name,
"approval_label": func_name,
"needs_approval": True,
}
for cid in call_ids
]
card = {
def _seed_pending(ws, *call_ids: str) -> None:
ws.ui._pending_approval = {
"type": "approve_request",
"cycle_id": f"cyc-{'-'.join(call_ids)}",
"items": ws.ui._serialize_approval_items(items),
"judge_pending": False,
"items": [
{
"call_id": cid,
"func_name": "spawn_workstream",
"approval_label": "spawn_workstream",
"needs_approval": True,
}
for cid in call_ids
],
}
cycle = ApprovalCycle(items, card, None)
ws.ui._register_approval_cycle(cycle)
return cycle
ws.ui._approval_event.clear()
def test_approve_409_on_stale_call_id(storage):
"""Body call_id doesn't match any pending item → 409 with the
current primary call_id + cycle_id so the UI can re-render
against the new round."""
current primary call_id so the UI can re-render against the
new round."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
cycle = _seed_pending(ws, "c-current")
_seed_pending(ws, "c-current")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
@@ -1171,17 +1155,17 @@ def test_approve_409_on_stale_call_id(storage):
body = resp.json()
assert body["error"] == "stale call_id"
assert body["current_call_id"] == "c-current"
assert body["current_cycle_id"] == cycle.cycle_id
# The live cycle must NOT have been resolved.
assert not cycle.event.is_set()
# Approval event must NOT be set — no resolve_approval ran.
assert not ws.ui._approval_event.is_set()
def test_approve_409_when_no_pending_and_call_id_sent(storage):
"""Body sends a call_id but the UI has no live cycle — 409 with
current_call_id=None so the UI knows to clear the row."""
"""Body sends a call_id but the UI has no pending approval —
409 with current_call_id=None so the UI knows to clear the row."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
# No cycle registered.
# No _pending_approval seeded → ui._pending_approval is None.
ws.ui._approval_event.clear()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
@@ -1190,18 +1174,18 @@ def test_approve_409_when_no_pending_and_call_id_sent(storage):
)
assert resp.status_code == 409
body = resp.json()
assert body["error"] == "stale call_id"
assert body["error"] == "no pending approval"
assert body["current_call_id"] is None
assert body["current_cycle_id"] is None
assert not ws.ui._approval_event.is_set()
def test_approve_no_call_id_preserves_backward_compat(storage):
"""Existing clients (CLI, channel adapters) that omit call_id
must still resolve approvals a selector-less body lands on the
oldest live cycle."""
must still resolve approvals the guard only kicks in when
call_id is present in the body."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
cycle = _seed_pending(ws, "c-1")
_seed_pending(ws, "c-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
@@ -1209,18 +1193,18 @@ def test_approve_no_call_id_preserves_backward_compat(storage):
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] == cycle.cycle_id
assert cycle.event.is_set()
assert ws.ui._approval_event.is_set()
def test_approve_no_call_id_no_pending_resolves_nothing(storage):
"""Legacy clients (no call_id) calling approve with no live cycle:
200 with ``cycle_id: null`` the handler resolves NOTHING rather
than racing a cycle that registers between its lookup and its
resolve (the client can't have been looking at one)."""
def test_approve_no_call_id_no_pending_falls_through(storage):
"""Legacy clients (no call_id) calling approve when pending is
None hit the existing resolve_approval no-op path the new
guard must not change that behavior. Regression guard for the
legacy code path that the call_id check intentionally bypasses."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
# No cycle registered.
ws.ui._approval_event.clear()
# No _pending_approval seeded.
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
@@ -1228,7 +1212,7 @@ def test_approve_no_call_id_no_pending_resolves_nothing(storage):
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] is None
assert ws.ui._approval_event.is_set()
def test_approve_call_id_matches_any_item_in_multi_envelope(storage):
@@ -1237,7 +1221,7 @@ def test_approve_call_id_matches_any_item_in_multi_envelope(storage):
one-boolean semantics of resolve_approval."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
cycle = _seed_pending(ws, "c-1", "c-2", "c-3")
_seed_pending(ws, "c-1", "c-2", "c-3")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
@@ -1245,61 +1229,7 @@ def test_approve_call_id_matches_any_item_in_multi_envelope(storage):
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert cycle.event.is_set()
def test_selectorless_always_whitelists_only_the_resolved_oldest_cycle(storage):
"""sweep-3 regression: with several live cycles, a selector-less
"Approve + Always" must whitelist the tools of the cycle it
actually resolved (the oldest) not a sibling's."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
oldest = _seed_pending(ws, "a-1", func_name="spawn_workstream")
newer = _seed_pending(ws, "b-1", func_name="send_message")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True, "always": True}, # no selector
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] == oldest.cycle_id
assert oldest.event.is_set()
assert not newer.event.is_set()
assert "spawn_workstream" in ws.ui.auto_approve_tools
assert "send_message" not in ws.ui.auto_approve_tools
def test_approve_always_skips_whitelist_when_pinned_cycle_lost_the_race(storage):
"""sweep-3 regression: the handler collects always-names from the
cycle its lookup pinned; if that cycle is resolved by someone else
(gate timeout, peer tab) between lookup and resolve, the whitelist
must NOT grow approving a card that already resolved must not
auto-approve anything."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
_seed_pending(ws, "a-1", func_name="spawn_workstream")
ui = ws.ui
real_find = ui.find_approval_cycle
def racing_find(**kwargs):
card = real_find(**kwargs)
if card is not None:
# A concurrent resolver wins the gap between the handler's
# lookup and its (pinned) resolve.
ui.resolve_approval(False, "raced", cycle_id=card["cycle_id"])
return card
ui.find_approval_cycle = racing_find
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True, "always": True},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] is None
assert "spawn_workstream" not in ws.ui.auto_approve_tools
assert ws.ui._approval_event.is_set()
# ---------------------------------------------------------------------------
@@ -1418,110 +1348,6 @@ def test_history_any_admin_coordinator_caller_can_read(storage):
assert resp.json()["ws_id"] == ws.id
def test_history_private_project_hidden_from_non_member(storage):
# admin.coordinator gates the surface, but a coordinator in a private
# project the caller isn't a member of is 404-masked — the conversation
# does not leak to a non-member operator.
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
storage.save_message("c" * 32, "user", "secret plan")
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{'c' * 32}/history",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_history_private_project_visible_to_member(storage):
storage.create_project("proj-secret", "Secret", "alice")
storage.add_project_member("proj-secret", "member-bob")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
storage.save_message("c" * 32, "user", "secret plan")
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{'c' * 32}/history",
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
assert any(m.get("content") == "secret plan" for m in resp.json()["messages"])
def test_export_private_project_hidden_from_non_member(storage):
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
storage.save_message("c" * 32, "user", "secret plan")
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{'c' * 32}/export",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_children_private_project_hidden_from_non_member(storage):
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{'c' * 32}/children",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_open_private_project_hidden_from_non_member(storage):
# `open` rehydrates + returns the auto-titled name, so an ungated open is a
# private-project existence/metadata oracle AND an unauthorized resurrection.
# The tenant_check must fire before the already-loaded shortcut and mgr.open.
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{'c' * 32}/open",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_coord_attachments_private_project_hidden_from_non_member(storage):
# Attachment list/serve resolves the owner as the coord owner and only
# enforced cross-kind before — a non-member operator could enumerate and
# download the owner's staged blobs. Now 404-masked by project tenancy.
storage.create_project("proj-secret", "Secret", "alice")
mgr = _build_mgr(storage)
ws = mgr.create(user_id="alice", project_id="proj-secret")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{ws.id}/attachments",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_coord_attachments_private_project_visible_to_member(storage):
storage.create_project("proj-secret", "Secret", "alice")
storage.add_project_member("proj-secret", "member-bob")
mgr = _build_mgr(storage)
ws = mgr.create(user_id="alice", project_id="proj-secret")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{ws.id}/attachments",
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
def test_history_serves_storage_only_workstream(storage):
"""Persisted-but-not-loaded coordinators (closed / evicted) are still
readable via /history without rehydrating. Mirrors the pre-lift
@@ -1690,19 +1516,15 @@ def test_export_404_when_kind_interactive(storage):
def test_cancel_resolves_pending_approval(storage):
"""Cancel addresses the workstream, not one batch — EVERY live
cycle resolves (parallel task agents can hold several gates)."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
assert isinstance(ws.ui, ConsoleCoordinatorUI)
first = _seed_pending(ws, "c-1")
second = _seed_pending(ws, "c-2")
ws.ui._pending_approval = {"type": "approve_request", "items": []}
ws.ui._approval_event.clear()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(f"/v1/api/workstreams/{ws.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert first.event.is_set()
assert second.event.is_set()
assert first.result == (False, "Cancelled by user")
assert ws.ui._approval_event.is_set()
def test_cancel_response_always_includes_dropped_key(storage):
@@ -2222,10 +2044,6 @@ def test_open_any_admin_coordinator_caller_succeeds_in_memory(storage):
def test_open_rehydrates_when_not_in_memory(storage, monkeypatch):
mgr = _build_mgr(storage)
# The tenancy gate resolves the row from storage before rehydrating, so a
# legitimately-openable coordinator must exist there (it always does in
# production — open rehydrates a persisted row).
storage.register_workstream("coord-rehy", kind="coordinator", user_id="user-1")
rehydrated = MagicMock()
rehydrated.id = "coord-rehy"
rehydrated.name = "rehydrated"
@@ -2259,7 +2077,6 @@ def test_open_503_on_coord_mgr_unavailable(storage):
def test_open_correlation_id_on_factory_failure(storage, monkeypatch):
mgr = _build_mgr(storage)
storage.register_workstream("bad-ws", kind="coordinator", user_id="user-1")
monkeypatch.setattr(mgr, "open", MagicMock(side_effect=RuntimeError("boom")))
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post("/v1/api/workstreams/bad-ws/open", headers=_COORD_HEADERS)
@@ -2270,7 +2087,6 @@ def test_open_correlation_id_on_factory_failure(storage, monkeypatch):
def test_open_503_when_open_raises_value_error(storage, monkeypatch):
"""ValueError from the factory surfaces as 503 with the remediation text."""
mgr = _build_mgr(storage)
storage.register_workstream("bad-ws", kind="coordinator", user_id="user-1")
monkeypatch.setattr(mgr, "open", MagicMock(side_effect=ValueError("coord registry missing")))
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post("/v1/api/workstreams/bad-ws/open", headers=_COORD_HEADERS)
@@ -2436,8 +2252,7 @@ def test_cluster_inspect_invalid_ws_id_400(storage):
def test_cluster_inspect_any_inspect_caller_sees_detail(storage):
# A project-less workstream has no tenancy to enforce, so any
# admin.cluster.inspect caller sees it (trusted-team default).
# Trusted-team visibility: admin.cluster.inspect sees every row.
mgr = _build_mgr(storage)
ws = mgr.create(user_id="owner")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
@@ -2449,46 +2264,6 @@ def test_cluster_inspect_any_inspect_caller_sees_detail(storage):
assert resp.json()["persisted"]["ws_id"] == ws.id
def test_cluster_inspect_private_project_hidden_from_non_member(storage):
# admin.cluster.inspect gates the surface, but a workstream in a
# private project the caller isn't a member of is masked as 404 —
# no private-project oracle even for a cluster admin.
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32,
node_id="console",
user_id="alice",
kind="coordinator",
project_id="proj-secret",
)
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/cluster/ws/{'c' * 32}/detail",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.cluster.inspect"},
)
assert resp.status_code == 404
def test_cluster_inspect_private_project_visible_to_member(storage):
# A project member (even a non-owner) still sees the persisted row.
storage.create_project("proj-secret", "Secret", "alice")
storage.add_project_member("proj-secret", "member-bob")
storage.register_workstream(
"c" * 32,
node_id="console",
user_id="alice",
kind="coordinator",
project_id="proj-secret",
)
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/cluster/ws/{'c' * 32}/detail",
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.cluster.inspect"},
)
assert resp.status_code == 200
assert resp.json()["persisted"]["ws_id"] == "c" * 32
def test_cluster_inspect_coordinator_self_path(storage):
"""A coordinator row returns live from the in-process manager."""
mgr = _build_mgr(storage)
@@ -2619,7 +2394,6 @@ def test_cluster_inspect_node_backed_pending_approval_detail_passes_through(stor
ws_id = "f0" * 16
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
detail = {
"cycle_id": "cyc-bash",
"call_id": "c-bash",
"judge_pending": False,
"items": [
@@ -2648,7 +2422,7 @@ def test_cluster_inspect_node_backed_pending_approval_detail_passes_through(stor
"activity_state": "approval",
"activity": "awaiting approval",
"tokens": 100,
"pending_approval_details": [detail],
"pending_approval_detail": detail,
}
]
}
@@ -2659,7 +2433,7 @@ def test_cluster_inspect_node_backed_pending_approval_detail_passes_through(stor
assert resp.status_code == 200
live = resp.json()["live"]
assert live["pending_approval"] is True # derived bool, existing behavior
assert live["pending_approval_details"] == [detail] # full payload passthrough
assert live["pending_approval_detail"] == detail # full payload, new behavior
def test_cluster_inspect_node_backed_pending_approval_synthesized(storage):
+3 -28
View File
@@ -313,17 +313,17 @@ def test_coordinator_js_handle_child_state_no_longer_reads_sse_pending_approval_
)
# The merge body must preserve BOTH pending_approval and
# pending_approval_details from prev — preserving only one would
# pending_approval_detail from prev — preserving only one would
# render a row with a phantom badge but no buttons (or vice versa).
merge_body = re.search(
r"mergedLive\s*=\s*Object\.assign\(\s*\{\}\s*,\s*live\s*,\s*\{"
r"[^}]*pending_approval:\s*prev\.live\.pending_approval[^}]*"
r"pending_approval_details:\s*prev\.live\.pending_approval_details",
r"pending_approval_detail:\s*prev\.live\.pending_approval_detail",
body,
)
assert merge_body is not None, (
"Merge body must preserve both pending_approval AND "
"pending_approval_details from prev.live — preserving only one "
"pending_approval_detail from prev.live — preserving only one "
"creates a half-rendered approval row."
)
@@ -666,28 +666,3 @@ def test_coord_child_links_open_interactive_pane():
assert 'data-node-id="' in coord_js
# The /node/{id}/?ws_id= href fallback must remain for the standalone page.
assert '"/node/"' in coord_js
def test_coordinator_js_gates_send_on_cross_user_busy():
"""The coordinator pane mirrors the interactive pane's shared-workstream
send gate: while another participant's turn is in flight it blocks this
viewer's send (the UX complement to the server-side 409). String-presence
guard coord.js has no JS test framework."""
from pathlib import Path
coord_js = (
Path(__file__).resolve().parent.parent
/ "turnstone/console/static/coordinator/coordinator.js"
).read_text(encoding="utf-8")
# tracks the acting user from state_change, clears on settle
assert "actingUserId = ev.acting_user_id;" in coord_js
assert "actingUserId = null;" in coord_js
# compares against the viewer's own id and drives the composer hard block
assert 'sessionStorage.getItem("ts.user_id")' in coord_js
assert "actingUserId !== me" in coord_js
assert "composer.setSendBlocked(" in coord_js
assert "function reconcileSendBlock()" in coord_js
# reactive 409 fallback
assert "r.status === 409" in coord_js
assert 'status: "cross_user_interjection"' in coord_js
assert 'data.status === "cross_user_interjection"' in coord_js
+5 -40
View File
@@ -35,14 +35,7 @@ class _StubUI:
def on_error(self, msg: str) -> None:
self.errors.append(msg)
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
is_error: bool = False,
preview: dict[str, Any] | None = None,
) -> None:
def on_tool_result(self, call_id: str, name: str, output: str, is_error: bool = False) -> None:
self.tool_results.append((call_id, name, output, is_error))
# Other SessionUI methods — only stubs, not exercised here.
@@ -205,23 +198,6 @@ def test_spawn_prepare_needs_approval(coord_session):
assert item["skill"] == "s"
def test_spawn_prepare_denies_high_risk_skill(coord_session):
"""Review fix: the high/critical-risk gate that blocks skills(load) also
blocks spawn_workstream(skill=), so a child spawn can't route around it."""
sess, _coord, _ui = coord_session
with patch("turnstone.core.session.get_storage") as gs:
gs.return_value.get_prompt_template_by_name.return_value = {
"name": "danger",
"risk_level": "critical",
}
item = sess._prepare_tool(
_tc("spawn_workstream", {"initial_message": "go", "skill": "danger"})
)
assert "error" in item
assert "/skill danger" in item["error"]
assert item.get("needs_approval") is not True
def test_spawn_exec_calls_client_and_returns_summary(coord_session):
sess, coord, _ui = coord_session
coord.spawn.return_value = {
@@ -1528,9 +1504,6 @@ def _stub_judge_for_evaluate_intent(monkeypatch, sess):
fake_judge = MagicMock()
# judge.evaluate(items, messages, callback=, cancel_event=) → list[verdict]
fake_judge.evaluate.side_effect = lambda items, *_args, **_kw: [fake_verdict] * len(items)
# arg_budget_chars() feeds honest_truncate in the projection loop and must
# be a real int, not a MagicMock; large enough that nothing truncates.
fake_judge.arg_budget_chars.return_value = 200_000
monkeypatch.setattr(sess, "_ensure_judge", lambda: fake_judge)
return fake_judge
@@ -1572,10 +1545,7 @@ def test_spawn_batch_evaluate_intent_projects_all_children(coord_session, monkey
def test_spawn_batch_evaluate_intent_truncates_long_messages(coord_session, monkeypatch):
sess, _coord, _ui = coord_session
fake_judge = _stub_judge_for_evaluate_intent(monkeypatch, sess)
# Each child's initial_message is truncated to its share of the judge's
# arg budget (window-based), not a fixed cap, and the omission is honest.
fake_judge.arg_budget_chars.return_value = 300 # 1 child → 300 chars/child
_stub_judge_for_evaluate_intent(monkeypatch, sess)
long_msg = "x" * 500
item = sess._prepare_tool(
_tc("spawn_batch", {"children": [{"initial_message": long_msg, "skill": "researcher"}]})
@@ -1584,9 +1554,9 @@ def test_spawn_batch_evaluate_intent_truncates_long_messages(coord_session, monk
children = item["func_args"]["children"]
assert len(children) == 1
msg = children[0]["initial_message"]
assert msg.startswith("x" * 300)
assert "200 of 500 chars omitted" in msg
# Cap is 200 chars — same shape every other coord-tool projection uses.
assert len(children[0]["initial_message"]) == 200
assert children[0]["initial_message"] == "x" * 200
def test_spawn_batch_evaluate_intent_handles_empty_children_defensively(coord_session, monkeypatch):
@@ -1639,15 +1609,10 @@ def test_tasks_update_without_title_evaluates_intent_cleanly(coord_session, monk
# The crash trigger: item["title"] is None after _prepare_tasks.
assert item["title"] is None
sess._evaluate_intent([item])
# title collapses None → "" (truncatable text); status is projected so the
# judge can see what state is being set; child_ws_id passes through as None
# ("unchanged"), never sliced.
assert item["func_args"] == {
"action": "update",
"task_id": "tsk_1",
"title": "",
"status": "in_progress",
"child_ws_id": None,
}
-239
View File
@@ -1,239 +0,0 @@
"""Tests for the effective effort-ladder projection.
The ladder must mirror the request-time mapping functions exactly
equal ``effective`` tokens promise byte-identical effort behavior on
the wire, which is what the UI annotations lean on.
"""
from __future__ import annotations
from turnstone.core.providers._protocol import ModelCapabilities
from turnstone.core.providers.effort_ladder import (
KNOB_VALUES,
effort_ladder,
effort_ladder_for_model,
)
def _as_map(ladder: list[dict[str, str]]) -> dict[str, str]:
assert [r["value"] for r in ladder] == list(KNOB_VALUES)
return {r["value"]: r["effective"] for r in ladder}
class TestLocalLanes:
def test_toggle_engaged_carries_graded_value_per_position(self) -> None:
"""No declared effort key: the toggle rides the knob AND the graded
value is forwarded under the fallback template key the user's
effort setting always reaches the wire (a template that doesn't
reference the kwarg ignores it), so every position is distinct."""
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
eff = _as_map(effort_ladder("anthropic-compatible", caps))
assert eff["none"] == "off"
assert eff["minimal"] == "on+minimal"
assert eff["max"] == "on+max"
assert len({eff[k] for k in KNOB_VALUES}) == len(KNOB_VALUES)
def test_freeform_effort_param_forwards_each_value(self) -> None:
"""deepseek-style config: toggle + verbatim effort per position."""
caps = ModelCapabilities(
thinking_mode="manual",
thinking_param="thinking",
effort_param="reasoning_effort",
)
eff = _as_map(effort_ladder("anthropic-compatible", caps))
assert eff["none"] == "off"
assert eff["low"] == "on+low"
assert eff["max"] == "on+max"
def test_validated_effort_param_shows_snapping(self) -> None:
"""Off-list positions round up onto the declared values; above the
ceiling they ride the ceiling never the (possibly lower) default."""
caps = ModelCapabilities(
thinking_mode="manual",
thinking_param="enable_thinking",
effort_param="reasoning_effort",
reasoning_effort_values=("low", "medium", "high"),
default_reasoning_effort="medium",
)
eff = _as_map(effort_ladder("openai-compatible", caps))
assert eff["minimal"] == "on+low"
assert eff["high"] == "on+high"
assert eff["xhigh"] == "on+high"
assert eff["max"] == "on+high"
def test_openai_compatible_flat_param_without_effort_param(self) -> None:
caps = ModelCapabilities(
reasoning_effort_values=("low", "medium", "high"),
default_reasoning_effort="medium",
)
eff = _as_map(effort_ladder("openai-compatible", caps))
assert eff["none"] == "default"
assert eff["high"] == "high"
assert eff["xhigh"] == "high" # ceiling, not default
def test_adaptive_local_never_off(self) -> None:
caps = ModelCapabilities(thinking_mode="adaptive", thinking_param="enable_thinking")
eff = _as_map(effort_ladder("openai-compatible", caps))
assert eff["none"] == "on"
assert eff["max"] == "on"
class TestNativeAnthropicLane:
def test_adaptive_with_effort_levels(self) -> None:
caps = ModelCapabilities(
thinking_mode="adaptive",
supports_effort=True,
effort_levels=("low", "medium", "high", "xhigh", "max"),
)
eff = _as_map(effort_ladder("anthropic", caps))
assert eff["none"] == "adaptive" # thinking on, model decides
assert eff["minimal"] == "low" # rounds up onto the declared levels
assert eff["low"] == "low"
assert eff["max"] == "max"
def test_sonnet_5_registry_row(self) -> None:
"""claude-sonnet-5: adaptive + full effort ladder incl. xhigh/max —
every knob level above none is a distinct wire behavior."""
eff = _as_map(effort_ladder_for_model("anthropic", "claude-sonnet-5", None))
assert eff["none"] == "adaptive"
assert eff["minimal"] == "low" # rounds up onto declared levels
assert eff["low"] == "low"
assert eff["xhigh"] == "xhigh"
assert eff["max"] == "max"
def test_sonnet_4_6_xhigh_rides_max(self) -> None:
"""Sonnet 4.6 declares (low, medium, high, max) — no xhigh, so the
knob's xhigh snaps up onto max rather than down onto high."""
eff = _as_map(effort_ladder_for_model("anthropic", "claude-sonnet-4-6", None))
assert eff["high"] == "high"
assert eff["xhigh"] == "max"
assert eff["max"] == "max"
def test_manual_budget_ladder(self) -> None:
"""Budgets are monotone over the whole knob domain."""
caps = ModelCapabilities(thinking_mode="manual")
eff = _as_map(effort_ladder("anthropic", caps))
assert eff["none"] == "off"
assert eff["minimal"] == eff["low"] == "budget:1024" # 1024 = API floor
assert eff["medium"] == "budget:4096"
assert eff["high"] == "budget:16384"
assert eff["xhigh"] == "budget:32768"
assert eff["max"] == "budget:65536"
class TestFlatParamLanes:
def test_google_default_caps(self) -> None:
eff = _as_map(effort_ladder_for_model("google", "gemini-3-flash", None))
assert eff["none"] == "default"
assert eff["minimal"] == "minimal"
assert eff["high"] == "high"
assert eff["xhigh"] == eff["max"] == "high"
def test_google_override_routes_through_chat_lane(self) -> None:
"""GoogleProvider inherits _finalize_extra_body — a thinking_mode
override changes real requests, and the ladder must mirror it."""
eff = _as_map(
effort_ladder_for_model(
"google",
"gemini-3-flash",
{"thinking_mode": "manual", "thinking_param": "enable_thinking"},
)
)
assert eff["none"] == "off"
assert eff["medium"] == "on+medium" # toggle + inherited flat param
def test_responses_surface_projects_flat_only(self) -> None:
caps_overrides = {
"thinking_mode": "manual",
"reasoning_effort_values": ["low", "medium", "high"],
}
chat = _as_map(effort_ladder_for_model("openai-compatible", "m", caps_overrides))
responses = _as_map(
effort_ladder_for_model(
"openai-compatible", "m", caps_overrides, api_surface="responses"
)
)
assert chat["medium"] == "on+medium"
assert responses["medium"] == "medium"
assert responses["none"] == "default"
def test_xai_projects_flat_only(self) -> None:
"""grok-4.3 declares values (none/low/medium/high, default low);
knob positions above the ceiling ride the ceiling (high). The
declared "none" IS forwarded for the knob's off position (xAI
documents it as disabling reasoning) but is never a snap target
for other positions."""
eff = _as_map(effort_ladder_for_model("xai", "grok-4.3", None))
assert eff["none"] == "none" # explicit disable, declared by grok
assert eff["minimal"] == "low"
assert eff["low"] == "low"
assert eff["high"] == "high"
assert eff["xhigh"] == eff["max"] == "high"
def test_xai_ignores_template_overrides(self) -> None:
"""XAIProvider subclasses OpenAIResponsesProvider, which drops
extra_body a thinking_mode/effort_param override cannot change
an xai request, so it must not change the ladder either."""
eff = _as_map(
effort_ladder_for_model(
"xai",
"grok-4.3",
{
"thinking_mode": "manual",
"thinking_param": "enable_thinking",
"effort_param": "reasoning_effort",
},
)
)
assert eff["none"] == "none" # flat channel, not an "off" toggle
assert eff["medium"] == "medium"
assert all("+" not in v and v not in ("on", "off") for v in eff.values())
def test_openai_gpt55_registry_row(self) -> None:
"""gpt-5.5 declares none/low/medium/high/xhigh with default medium:
knob none sends the explicit "none" level (server default is
MEDIUM, so omission would not disable), max rides the xhigh
ceiling, minimal rounds up to low."""
eff = _as_map(effort_ladder_for_model("openai", "gpt-5.5", None))
assert eff["none"] == "none"
assert eff["minimal"] == "low"
assert eff["xhigh"] == "xhigh"
assert eff["max"] == "xhigh"
def test_openai_o3_registry_row(self) -> None:
"""o-series (except o1-mini) accept low/medium/high; no declared
"none" level, so the knob's off position omits the param."""
eff = _as_map(effort_ladder_for_model("openai", "o3", None))
assert eff["none"] == "default"
assert eff["minimal"] == "low"
assert eff["medium"] == "medium"
assert eff["xhigh"] == eff["max"] == "high"
def test_openai_codex_max_has_xhigh(self) -> None:
"""gpt-5.1-codex-max must not prefix-fall onto the gpt-5.1 row
(which lacks xhigh) xhigh reaches the wire verbatim."""
eff = _as_map(effort_ladder_for_model("openai", "gpt-5.1-codex-max", None))
assert eff["xhigh"] == "xhigh"
assert eff["max"] == "xhigh"
def test_anthropic_effort_applies_even_with_thinking_mode_none(self) -> None:
"""output_config gates on supports_effort alone at request time."""
caps = ModelCapabilities(
thinking_mode="none",
supports_effort=True,
effort_levels=("low", "medium", "high"),
)
eff = _as_map(effort_ladder("anthropic", caps))
assert eff["high"] == "high"
assert eff["none"] == "default"
def test_overrides_merge_and_unknown_keys_ignored(self) -> None:
eff = _as_map(
effort_ladder_for_model(
"google",
"gemini-3-flash",
{"reasoning_effort_values": [], "not_a_field": True},
)
)
# Operator cleared the values → nothing effort-related is sent.
assert set(eff.values()) == {"default"}
-410
View File
@@ -1,410 +0,0 @@
"""Ladder↔wire parity harness — the effort ladder must tell the truth.
``effort_ladder`` *projects* the session effort knob through the same
mapping functions the providers use at request time. This suite proves
that projection against the REAL request path: for every provider lane
and capability shape, each knob position is driven through the actual
provider ``create_streaming`` against a recording fake client (the same
SDK-seam capture the wire-payload goldens use), the effort-relevant
subset of the captured kwargs is extracted, and it must equal what the
ladder token decodes to. Two invariants per shape:
1. **Semantics** each ladder token decodes to an expected wire subset
(``on``/``off`` the chat-template toggle, ``budget:N`` Anthropic
thinking budget, a bare level the lane's flat/effort channel) and
the observed wire subset must match it exactly.
2. **Grouping** the ladder's core promise: two knob positions carry
equal ``effective`` tokens if and only if they produce identical
effort-relevant wire payloads.
A failure here means the UI annotates behavior the wire does not have
the bug class that shipped xai in the ladder's chat-lane set even though
``XAIProvider`` rides the Responses surface, which drops ``extra_body``.
The harness goes through ``create_provider`` (not direct classes) so the
provider ROUTING the ladder assumes e.g. ``api_surface="responses"``
selecting the Responses adapter is itself under test.
"""
from __future__ import annotations
import contextlib
import dataclasses
import itertools
from typing import Any
import pytest
from tests._wire_capture import RecordingClient
from turnstone.core.providers import create_provider
from turnstone.core.providers._protocol import (
EFFORT_TEMPLATE_FALLBACK_PARAM,
ModelCapabilities,
)
from turnstone.core.providers.effort_ladder import KNOB_VALUES, effort_ladder
# Above the largest manual-mode thinking budget (max: 65536) so the
# request path's budget<max_tokens clamp never fires — the ladder
# documents budgets unclamped, so the capture must be too. (At small
# per-request max_tokens the clamp can genuinely alias adjacent budget
# tiers on the wire; that is the ladder's documented approximation, not
# a parity break.)
_MAX_TOKENS = 128_000
@dataclasses.dataclass(frozen=True)
class Shape:
"""One (provider lane, capability shape) point of the parity matrix."""
id: str
provider: str
caps: ModelCapabilities
api_surface: str = ""
model: str = "m"
# Real registry rows for the lanes whose defaults carry effort values —
# parity should cover what ships, not only synthetic shapes.
_GEMINI_CAPS = create_provider("google").get_capabilities("gemini-3-flash")
_GROK_CAPS = create_provider("xai").get_capabilities("grok-4.3")
_GPT55_CAPS = create_provider("openai").get_capabilities("gpt-5.5")
SHAPES: tuple[Shape, ...] = (
# -- anthropic-compatible (vLLM /v1/messages): template channel only --
Shape(
"compat-toggle-manual",
"anthropic-compatible",
ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking"),
),
Shape(
"compat-toggle-adaptive",
"anthropic-compatible",
ModelCapabilities(thinking_mode="adaptive", thinking_param="enable_thinking"),
),
Shape(
"compat-freeform-effort",
"anthropic-compatible",
ModelCapabilities(
thinking_mode="manual",
thinking_param="thinking",
effort_param="reasoning_effort",
),
),
Shape(
# DeepSeek-V4 official contract: toggle + effort in {high, max}.
"compat-validated-effort",
"anthropic-compatible",
ModelCapabilities(
thinking_mode="manual",
thinking_param="thinking",
effort_param="reasoning_effort",
reasoning_effort_values=("high", "max"),
default_reasoning_effort="high",
),
),
Shape(
"compat-inert",
"anthropic-compatible",
ModelCapabilities(thinking_mode="none"),
),
# -- openai-compatible on the Chat Completions surface: both channels --
Shape(
"oc-toggle-only",
"openai-compatible",
ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking"),
),
Shape(
"oc-toggle-plus-flat",
"openai-compatible",
ModelCapabilities(
thinking_mode="manual",
thinking_param="enable_thinking",
reasoning_effort_values=("low", "medium", "high"),
default_reasoning_effort="medium",
),
),
Shape(
"oc-effort-param-suppresses-flat",
"openai-compatible",
ModelCapabilities(
thinking_mode="manual",
thinking_param="enable_thinking",
effort_param="reasoning_effort",
reasoning_effort_values=("low", "medium", "high"),
default_reasoning_effort="medium",
),
),
Shape(
"oc-flat-only",
"openai-compatible",
ModelCapabilities(
reasoning_effort_values=("low", "medium", "high"),
default_reasoning_effort="medium",
),
),
Shape(
"oc-adaptive",
"openai-compatible",
ModelCapabilities(thinking_mode="adaptive", thinking_param="enable_thinking"),
),
# -- openai-compatible pinned to the Responses surface: template caps
# become inert and only the native flat channel remains --
Shape(
"oc-responses-surface",
"openai-compatible",
ModelCapabilities(
thinking_mode="manual",
thinking_param="enable_thinking",
effort_param="reasoning_effort",
reasoning_effort_values=("low", "medium", "high"),
default_reasoning_effort="medium",
),
api_surface="responses",
),
# -- commercial flat lanes --
Shape(
# Real registry row: none/low/medium/high/xhigh, default medium.
# Knob none must send the EXPLICIT "none" level (omission would
# leave the server default medium reasoning on); knob max rides
# the xhigh ceiling.
"openai-gpt-5.5",
"openai",
_GPT55_CAPS,
model="gpt-5.5",
),
Shape("google-default", "google", _GEMINI_CAPS, model="gemini-3-flash"),
Shape(
# GoogleProvider subclasses the chat provider, so a template
# override DOES change real requests — hybrid toggle + flat.
"google-manual-override",
"google",
dataclasses.replace(_GEMINI_CAPS, thinking_mode="manual", thinking_param="enable_thinking"),
model="gemini-3-flash",
),
Shape("xai-default", "xai", _GROK_CAPS, model="grok-4.3"),
Shape(
# XAIProvider rides the Responses surface: template overrides are
# inert on the wire, and the ladder must not pretend otherwise.
"xai-template-override-inert",
"xai",
dataclasses.replace(
_GROK_CAPS,
thinking_mode="manual",
thinking_param="enable_thinking",
effort_param="reasoning_effort",
),
model="grok-4.3",
),
# -- native Anthropic --
Shape(
"anthropic-adaptive-effort",
"anthropic",
ModelCapabilities(
thinking_mode="adaptive",
supports_effort=True,
effort_levels=("low", "medium", "high", "xhigh", "max"),
),
model="claude-fable-5",
),
Shape(
"anthropic-adaptive-plain",
"anthropic",
ModelCapabilities(thinking_mode="adaptive"),
model="claude-fable-5",
),
Shape(
"anthropic-manual-budgets",
"anthropic",
ModelCapabilities(thinking_mode="manual"),
model="claude-3-7-sonnet-latest",
),
Shape(
"anthropic-manual-plus-effort",
"anthropic",
ModelCapabilities(
thinking_mode="manual",
supports_effort=True,
effort_levels=("low", "medium", "high"),
),
model="claude-3-7-sonnet-latest",
),
Shape(
"anthropic-none-effort",
"anthropic",
ModelCapabilities(
thinking_mode="none",
supports_effort=True,
effort_levels=("low", "medium", "high"),
),
model="claude-3-5-haiku-latest",
),
Shape(
"anthropic-inert",
"anthropic",
ModelCapabilities(thinking_mode="none"),
model="claude-3-5-haiku-latest",
),
)
# --------------------------------------------------------------------------- #
# Wire capture + effort-subset extraction
# --------------------------------------------------------------------------- #
def _wire_payload(shape: Shape, knob: str) -> dict[str, Any]:
"""Drive the real provider request path; return the captured SDK kwargs."""
provider = create_provider(shape.provider, api_surface=shape.api_surface or None)
client = RecordingClient()
gen = provider.create_streaming(
client=client,
model=shape.model,
messages=[{"role": "user", "content": "hi"}],
max_tokens=_MAX_TOKENS,
reasoning_effort=knob,
capabilities=shape.caps,
)
# kwargs are recorded eagerly during the call above; close the
# unconsumed iterator so stream-manager cleanup runs on the stub.
close = getattr(gen, "close", None)
if callable(close):
with contextlib.suppress(Exception):
close()
assert "payload" in client.captured, f"{shape.id}: provider made no SDK call"
return dict(client.captured["payload"])
def _effort_wire_subset(payload: dict[str, Any], shape: Shape) -> dict[str, Any]:
"""Every effort-related lever in *payload*, normalized across lanes.
Keys: ``thinking`` (native Anthropic param), ``output_effort``
(Anthropic ``output_config.effort``), ``flat`` (Chat Completions
``reasoning_effort`` / Responses ``reasoning.effort``), ``toggle``
and ``template_effort`` (``extra_body.chat_template_kwargs`` the
graded key is ``caps.effort_param``, else the fallback template key
on the anthropic-compatible lane, whose only effort channel is the
template).
"""
caps = shape.caps
effort_key = caps.effort_param or (
EFFORT_TEMPLATE_FALLBACK_PARAM if shape.provider == "anthropic-compatible" else ""
)
subset: dict[str, Any] = {}
if "thinking" in payload:
subset["thinking"] = payload["thinking"]
output_config = payload.get("output_config")
if isinstance(output_config, dict) and "effort" in output_config:
subset["output_effort"] = output_config["effort"]
if "reasoning_effort" in payload:
subset["flat"] = payload["reasoning_effort"]
reasoning = payload.get("reasoning")
if isinstance(reasoning, dict) and "effort" in reasoning:
subset["flat"] = reasoning["effort"]
extra_body = payload.get("extra_body")
ctk = extra_body.get("chat_template_kwargs") if isinstance(extra_body, dict) else None
if isinstance(ctk, dict):
known = {caps.thinking_param, effort_key} - {""}
unexpected = set(ctk) - known
assert not unexpected, f"unexpected chat_template_kwargs keys: {unexpected}"
if caps.thinking_param in ctk:
subset["toggle"] = ctk[caps.thinking_param]
if effort_key and effort_key in ctk:
subset["template_effort"] = ctk[effort_key]
return subset
# --------------------------------------------------------------------------- #
# Ladder-token decoding — the token grammar, made executable
# --------------------------------------------------------------------------- #
def _decode_token(shape: Shape, token: str) -> dict[str, Any]:
"""Expected effort wire subset for a ladder ``effective`` token."""
caps = shape.caps
if shape.provider == "anthropic":
return _decode_native(caps, token)
if shape.provider in ("openai", "xai") or shape.api_surface == "responses":
return {} if token == "default" else {"flat": token}
return _decode_template(shape.provider, caps, token)
def _decode_native(caps: ModelCapabilities, token: str) -> dict[str, Any]:
if caps.thinking_mode == "adaptive":
# Thinking is unconditionally adaptive; a non-"adaptive" token is
# the output_config effort level riding on top.
expected: dict[str, Any] = {"thinking": {"type": "adaptive"}}
if token != "adaptive":
expected["output_effort"] = token
return expected
if token in ("default", "off"):
return {}
effort, sep, budget = token.partition("·budget:")
if sep:
return {
"output_effort": effort,
"thinking": {"type": "enabled", "budget_tokens": int(budget)},
}
if token.startswith("budget:"):
budget_tokens = int(token.removeprefix("budget:"))
return {"thinking": {"type": "enabled", "budget_tokens": budget_tokens}}
return {"output_effort": token}
def _decode_template(provider: str, caps: ModelCapabilities, token: str) -> dict[str, Any]:
if token == "default":
return {}
parts = token.split("+")
expected: dict[str, Any] = {}
if parts[0] in ("on", "off"):
expected["toggle"] = parts[0] == "on"
parts = parts[1:]
if parts:
assert len(parts) == 1, f"unparseable ladder token: {token!r}"
if caps.effort_param or provider == "anthropic-compatible":
# Declared graded key, or the anthropic-compatible fallback
# template key — that lane has no flat channel, so a graded
# part there is always template-borne.
expected["template_effort"] = parts[0]
else:
expected["flat"] = parts[0]
return expected
# --------------------------------------------------------------------------- #
# The parity tests
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("shape", SHAPES, ids=lambda s: s.id)
def test_ladder_tokens_match_wire(shape: Shape) -> None:
"""Invariant 1: each token's decoded meaning equals the captured wire."""
ladder = effort_ladder(shape.provider, shape.caps, shape.api_surface)
assert [row["value"] for row in ladder] == list(KNOB_VALUES)
for row in ladder:
knob, token = row["value"], row["effective"]
observed = _effort_wire_subset(_wire_payload(shape, knob), shape)
expected = _decode_token(shape, token)
assert observed == expected, (
f"{shape.id}/knob={knob}: ladder says {token!r} which decodes to "
f"{expected}, but the wire carries {observed}"
)
@pytest.mark.parametrize("shape", SHAPES, ids=lambda s: s.id)
def test_equal_tokens_iff_equal_wire(shape: Shape) -> None:
"""Invariant 2: token equality ⇔ effort-wire equality, per shape."""
tokens = {
row["value"]: row["effective"]
for row in effort_ladder(shape.provider, shape.caps, shape.api_surface)
}
subsets = {knob: _effort_wire_subset(_wire_payload(shape, knob), shape) for knob in KNOB_VALUES}
for a, b in itertools.combinations(KNOB_VALUES, 2):
same_token = tokens[a] == tokens[b]
same_wire = subsets[a] == subsets[b]
assert same_token == same_wire, (
f"{shape.id}: knobs {a!r}/{b!r} have "
f"{'equal' if same_token else 'distinct'} tokens "
f"({tokens[a]!r} vs {tokens[b]!r}) but "
f"{'identical' if same_wire else 'different'} wire subsets "
f"({subsets[a]} vs {subsets[b]})"
)
-43
View File
@@ -225,22 +225,6 @@ class TestRoles:
assert resp.status_code == 200, resp.json()
assert "model.skills.write" in resp.json()["permissions"]
def test_create_role_with_persona_permissions(self, client):
"""``persona.{create,read,write}`` (migration 063) are enumerated in
``_VALID_PERMISSIONS`` and pass role-create validation. Before the fix
they 400'd — a custom role could never carry a persona grant."""
resp = client.post(
"/v1/api/admin/roles",
json=_role_payload(
name="personaeditor",
permissions="read,persona.create,persona.read,persona.write",
),
)
assert resp.status_code == 200, resp.json()
perms = resp.json()["permissions"]
for p in ("persona.create", "persona.read", "persona.write"):
assert p in perms
def test_permission_sections_js_covers_valid_permissions(self):
"""F-5: ``_PERMISSION_SECTIONS`` in governance.js mirrors
``_VALID_PERMISSIONS`` in console/server.py. A new perm added
@@ -371,19 +355,6 @@ class TestRoles:
assert role["display_name"] == "Senior Analyst"
assert role["permissions"] == "read,write,approve"
def test_update_role_accepts_persona_permissions(self, client):
"""Editing a custom role to carry ``persona.*`` must validate (they were
rejected before 063 added them to ``_VALID_PERMISSIONS``)."""
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
role_id = create_resp.json()["role_id"]
resp = client.put(
f"/v1/api/admin/roles/{role_id}",
json={"permissions": "read,persona.read,persona.write"},
)
assert resp.status_code == 200, resp.json()
perms = resp.json()["permissions"]
assert "persona.read" in perms and "persona.write" in perms
def test_update_nonexistent_role(self, client):
resp = client.put(
"/v1/api/admin/roles/nonexistent",
@@ -480,20 +451,6 @@ class TestRoleOverrides:
assert "model.skills.write" in body["effective"]
assert body["grants"] == ["model.skills.write"]
def test_overrides_grant_persona_write(self, client, storage):
# persona.write is admin-default (063) but grantable to any builtin
# role via the overrides layer — the endpoint must accept it, not 400
# it as an unknown permission.
_seed_builtin_admin(storage, "read,write,admin.roles")
resp = client.put(
"/v1/api/admin/roles/builtin-admin/overrides",
json={"grant": ["persona.write"], "revoke": []},
)
assert resp.status_code == 200, resp.json()
body = resp.json()
assert "persona.write" in body["effective"]
assert body["grants"] == ["persona.write"]
def test_overrides_replace_semantics(self, client, storage):
_seed_builtin_admin(storage, "read,write,admin.roles")
client.put(
-10
View File
@@ -208,16 +208,6 @@ class TestRolePermissionOverrides:
db.set_role_overrides("r1", {"approve", "model.skills.write"}, {"write"})
assert db.get_user_permissions("u1") == {"read", "approve", "model.skills.write"}
def test_get_user_permissions_applies_persona_write_overlay(self, db):
# persona.write is admin-default (migration 063), but the override layer
# can grant it to any NON-admin builtin role — the grant must flow
# through get_user_permissions like any other overlay perm.
db.create_role("r1", "editor", "Editor", "read,write", builtin=True, org_id="")
db.create_user("u1", "alice", "Alice", "$2b$hash")
db.assign_role("u1", "r1")
db.set_role_overrides("r1", {"persona.write"}, set())
assert db.get_user_permissions("u1") == {"read", "write", "persona.write"}
def test_get_user_permissions_ignores_overlay_on_custom_role(self, db):
# Overrides only apply to builtin rows. A custom role with stray
# override rows (defensive case — should never happen via the API)
+1 -186
View File
@@ -28,10 +28,8 @@ from unittest.mock import MagicMock, patch
import pytest
from tests._helpers import wait_until as _wait_until
from tests.test_session_manager import FakeStorage
from turnstone.core import session_worker
from turnstone.core.idle_nudge_watcher import IdleNudgeWatcher, wake_workstream_if_pending
from turnstone.core.idle_nudge_watcher import IdleNudgeWatcher
from turnstone.core.session import ChatSession
from turnstone.core.session_manager import SessionManager
from turnstone.core.trajectory import dicts_from_turns, turn_from_dict
@@ -301,72 +299,6 @@ def test_idle_event_with_empty_queue_does_not_dispatch_wake(real_mgr, tmp_db):
watcher.shutdown()
def test_watch_fire_on_already_idle_session_drives_wake_send(real_mgr, tmp_db):
"""A watch firing on an ALREADY-idle workstream sees no IDLE
transition, so :class:`IdleNudgeWatcher` never re-checks the queue
the dispatch closure's ``wake_fn`` must drive the wake itself.
Boundary path under test (only the LLM stream is patched):
dispatch closure (real, built by ``set_watch_runner``)
NudgeQueue.enqueue (real)
wake_fn wake_workstream_if_pending (real)
session_worker.send (real) daemon thread
ChatSession.deliver_wake_nudge_from_queue (real)
ChatSession.send("") watch_triggered system turn in history
"""
mgr, _adapter = real_mgr
ws = mgr.create(user_id="u1", name="watch-wake-int", skill=None)
assert ws.session is not None
captured: dict[str, Any] = {}
class _StubRunner:
def set_dispatch_fn(self, ws_id: str, fn: Any) -> None:
captured["fn"] = fn
# Production wiring shape (server.py): wake_fn closes over the
# Workstream OBJECT — not its id — so eviction+restore id drift
# can't strand the wake.
ws.session.set_watch_runner(
_StubRunner(), wake_fn=lambda: wake_workstream_if_pending(ws, trigger="watch-fire")
)
with (
patch.object(ws.session, "_create_stream_with_retry", return_value=iter([])),
patch.object(
ws.session,
"_stream_response",
return_value={"role": "assistant", "content": "ok"},
),
patch.object(ws.session, "_update_token_table"),
patch.object(ws.session, "_print_status_line"),
patch.object(ws.session, "_visible_memory_count", return_value=0),
patch("turnstone.core.session.save_message"),
):
ws.session._title_generated = True
# Idle all along — no worker, and no state transition coming.
assert ws.state is WorkstreamState.IDLE
# Simulate the WatchRunner poll thread delivering a fire.
captured["fn"]({"type": "watch_triggered", "text": "deploy finished: OK"}, "watch-1")
_wait_for_worker_done(ws)
# Queue drained by the wake — not parked until the next user message.
assert len(ws.session._nudge_queue) == 0
msgs = dicts_from_turns(ws.session.messages)
user_msgs = [m for m in msgs if m.get("role") == "user"]
assert user_msgs, "expected a synthesized user message from the wake"
assert user_msgs[-1]["content"] == ""
assert user_msgs[-1].get("_source") == "system_nudge"
sys_turns = [m for m in msgs if m.get("role") == "system"]
assert any(
m.get("_source") == "watch_triggered" and "deploy finished: OK" in m.get("content", "")
for m in sys_turns
), f"expected a watch_triggered system turn, got {sys_turns!r}"
@pytest.fixture
def coord_mgr() -> tuple[SessionManager, _BuildRealSessionAdapter, FakeStorage]:
"""Real coord-side SessionManager with the adapter's kind set to
@@ -479,120 +411,3 @@ def test_coord_idle_with_active_children_emits_envelope_via_real_managers(coord_
finally:
watcher.shutdown()
observer.shutdown()
def test_coord_idle_emitted_from_worker_thread_still_wakes(coord_mgr, tmp_db):
"""The production-shaped race the test above does NOT exercise: in
production, IDLE is emitted from INSIDE the worker (``set_state``
subscribers fire on the calling thread the coord's send emits IDLE
before its worker exits). The watcher's wake dispatch therefore
lands on ``session_worker.send``'s reuse path while the
transitioning worker still owns the flag, and no-ops. Without the
ownership-clear backstop the ``idle_children`` nudge strands until
the next user message a coord that forgot ``wait_for_workstream``
never revives.
Boundary path under test:
worker thread: mgr.set_state(IDLE)
observer enqueues (real) watcher wake no-ops (worker owns flag)
run() returns session_worker._runner finally clears the flag
_retry_pending_wake wake_workstream_if_pending (real)
wake daemon deliver_wake_nudge_from_queue send("")
idle_children system turn in history
"""
from turnstone.console.coordinator_idle_observer import CoordinatorIdleObserver
from turnstone.core.workstream import WorkstreamKind as _Kind
mgr, adapter, storage = coord_mgr
observer = CoordinatorIdleObserver(mgr, storage)
observer.start()
watcher = IdleNudgeWatcher(mgr)
watcher.start()
try:
coord = mgr.create(user_id="u1", name="parent-coord-2", skill=None)
assert coord.session is not None
storage.register_workstream(
"child-x",
user_id="u1",
name="crawl-docs",
kind=_Kind.INTERACTIVE,
parent_ws_id=coord.id,
state="running",
)
coord.session.messages.append(turn_from_dict({"role": "user", "content": "spawn 1"}))
coord.session.messages.append(turn_from_dict({"role": "assistant", "content": "ok"}))
with (
patch.object(coord.session, "_create_stream_with_retry", return_value=iter([])),
patch.object(
coord.session,
"_stream_response",
return_value={"role": "assistant", "content": "ack"},
),
patch.object(coord.session, "_full_messages", return_value=[]),
patch.object(coord.session, "_update_token_table"),
patch.object(coord.session, "_print_status_line"),
patch.object(coord.session, "_visible_memory_count", return_value=0),
patch("turnstone.core.session.save_message"),
):
coord.session._title_generated = True
# Drive the IDLE transition from INSIDE a session_worker
# worker, as production does.
ok = session_worker.send(
coord,
enqueue=lambda: None,
run=lambda: mgr.set_state(coord.id, WorkstreamState.IDLE),
thread_name="coord-send-sim",
)
assert ok is True
# Without the backstop the queue never drains (the watcher's
# transition-time wake no-opped against the sim worker) and
# this poll times out. Queue-empty implies the wake worker's
# drain ran, so the follow-up flag poll waits for ITS exit.
_wait_until(lambda: len(coord.session._nudge_queue) == 0)
_wait_for_worker_done(coord)
# Queue drained by the wake, not waiting on the next user message.
assert len(coord.session._nudge_queue) == 0
msgs = dicts_from_turns(coord.session.messages)
user_msgs = [m for m in msgs if m.get("role") == "user"]
wake_msg = user_msgs[-1]
assert wake_msg["content"] == ""
assert wake_msg.get("_source") == "system_nudge"
idle_turns = [
m for m in msgs if m.get("role") == "system" and m["_source"] == "idle_children"
]
assert len(idle_turns) == 1
assert "crawl-docs" in idle_turns[0]["content"]
assert "wait_for_workstream" in idle_turns[0]["content"]
finally:
watcher.shutdown()
observer.shutdown()
def test_wake_delivery_contains_generation_cancelled(tmp_db):
"""A close/force-cancel racing the wake turn raises
``GenerationCancelled`` (a BaseException) out of ``send("")`` the
wake method must contain it: it IS the wake worker's ``run()``
closure, and ``session_worker._runner`` catches only ``Exception``,
so an escape would land in ``threading.excepthook`` as stderr noise
on every close-vs-wake race."""
from tests._helpers import make_chat_session
from turnstone.core.session import GenerationCancelled
session = make_chat_session()
session._nudge_queue.enqueue("idle_children", "kids waiting", "any")
def _cancelled_send(*_a: Any, **_k: Any) -> None:
raise GenerationCancelled
session.send = _cancelled_send # type: ignore[method-assign]
session.deliver_wake_nudge_from_queue() # must not raise
assert session._wake_source_tag == ""
assert session._wake_drained_reminders is None
+1 -154
View File
@@ -9,14 +9,13 @@ module-level function to capture calls without spawning real threads.
from __future__ import annotations
import contextlib
import logging
import threading
from typing import Any
from unittest.mock import patch
import pytest
from turnstone.core.idle_nudge_watcher import IdleNudgeWatcher, wake_workstream_if_pending
from turnstone.core.idle_nudge_watcher import IdleNudgeWatcher
from turnstone.core.nudge_queue import NudgeQueue
from turnstone.core.workstream import WorkstreamState
@@ -33,7 +32,6 @@ class _FakeSession:
class _FakeWorkstream:
def __init__(self, ws_id: str = "ws-test") -> None:
self.id = ws_id
self.state = WorkstreamState.IDLE
self.session: _FakeSession | None = _FakeSession()
self._lock = threading.Lock()
self._worker_running = False
@@ -165,154 +163,3 @@ class TestIdleNudgeWatcher:
watcher.start()
watcher.shutdown()
watcher.shutdown() # no error
class TestWakeWorkstreamIfPending:
"""Direct tests for the shared wake gate.
The IDLE-transition path (via the watcher) is covered above; these
pin the gates the watch dispatch closure relies on when it calls
the helper directly, with no state event involved.
"""
def test_wakes_idle_ws_with_pending_entry(self, fake_mgr_and_ws):
_mgr, ws = fake_mgr_and_ws
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
with patch("turnstone.core.session_worker.send", return_value=True) as mock_send:
assert wake_workstream_if_pending(ws) is True
assert mock_send.call_count == 1
kwargs = mock_send.call_args.kwargs
assert kwargs["enqueue"]() is None
kwargs["run"]()
assert ws.session.deliver_wake_nudge_from_queue_called == 1
assert kwargs["thread_name"].startswith("wake-nudge-")
def test_skips_session_none(self, fake_mgr_and_ws):
_mgr, ws = fake_mgr_and_ws
ws.session = None
with patch("turnstone.core.session_worker.send") as mock_send:
assert wake_workstream_if_pending(ws) is False
assert mock_send.call_count == 0
def test_skips_closed_ws(self, fake_mgr_and_ws):
"""A workstream mid-``close()`` must not get a wake spawned on
its torn-down session, even while its ``state`` field still
reads IDLE (there is no CLOSED member close uses the
``_closed`` tombstone)."""
_mgr, ws = fake_mgr_and_ws
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
ws._closed = True
with patch("turnstone.core.session_worker.send") as mock_send:
assert wake_workstream_if_pending(ws) is False
assert mock_send.call_count == 0
def test_skips_non_idle_states(self, fake_mgr_and_ws):
"""Busy states imply a live worker that drains at its own seams;
ERROR stays parked for the operator neither gets a wake."""
_mgr, ws = fake_mgr_and_ws
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
with patch("turnstone.core.session_worker.send") as mock_send:
for state in (
WorkstreamState.RUNNING,
WorkstreamState.THINKING,
WorkstreamState.ATTENTION,
WorkstreamState.ERROR,
):
ws.state = state
assert wake_workstream_if_pending(ws) is False
assert mock_send.call_count == 0
def test_skips_tool_only_entries(self, fake_mgr_and_ws):
"""Tool-channel entries belong to the next tool-result seam — a
synthetic empty user turn can't drain them, so no wake."""
_mgr, ws = fake_mgr_and_ws
ws.session._nudge_queue.enqueue("tool_error", "check memories", "tool")
with patch("turnstone.core.session_worker.send") as mock_send:
assert wake_workstream_if_pending(ws) is False
assert mock_send.call_count == 0
def test_refuses_non_nudgequeue_stub(self, fake_mgr_and_ws):
"""The gate refuses on TYPE, not just presence: a mock session's
auto-created ``_nudge_queue`` answers ``has_pending`` truthily
while its ``deliver_wake_nudge_from_queue`` consumes nothing
with the worker-exit backstop re-running this gate after every
exit, one worker on such a session would respawn wake workers
forever (the storm that took down the full-suite CI run). Only
a real :class:`NudgeQueue` carries the drain semantics the wake
contract needs."""
from unittest.mock import MagicMock
_mgr, ws = fake_mgr_and_ws
ws.session._nudge_queue = MagicMock() # truthy has_pending, no real drain
with patch("turnstone.core.session_worker.send") as mock_send:
assert wake_workstream_if_pending(ws) is False
assert mock_send.call_count == 0
def test_dispatched_path_logs_trigger(self, fake_mgr_and_ws, caplog):
"""A fresh spawn — ``send`` returns True without touching the
passed ``enqueue`` emits ``nudge_wake.dispatched`` tagged with
the trigger label (structlog renders the event name + ``%s``
placeholders into ``msg``; substring-match like the sibling
nudge_queue tests)."""
_mgr, ws = fake_mgr_and_ws
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
with (
patch("turnstone.core.session_worker.send", return_value=True) as mock_send,
caplog.at_level(logging.INFO, logger="turnstone.core.idle_nudge_watcher"),
):
assert wake_workstream_if_pending(ws, trigger="idle-transition") is True
assert mock_send.call_count == 1
dispatched = [r for r in caplog.records if "nudge_wake.dispatched" in r.getMessage()]
assert len(dispatched) == 1
assert dispatched[0].levelno == logging.INFO
assert "trigger=" in dispatched[0].getMessage()
# The reuse-path drop line must not appear on a fresh spawn.
assert not any("nudge_wake.deferred_worker_busy" in r.getMessage() for r in caplog.records)
def test_deferred_path_logs_worker_busy(self, fake_mgr_and_ws, caplog):
"""The reuse path — ``send`` invokes the passed ``enqueue`` and
returns True emits ``nudge_wake.deferred_worker_busy`` instead
of ``dispatched``. The entry stays owed to the owning worker's
exit backstop; the return value is still True."""
_mgr, ws = fake_mgr_and_ws
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
def _reuse_send(_ws: Any, *, enqueue: Any, run: Any, thread_name: Any) -> bool:
# Mimic a live worker owning the workstream: send routes the
# wake to the no-op enqueue rather than spawning a daemon.
enqueue()
return True
with (
patch("turnstone.core.session_worker.send", side_effect=_reuse_send) as mock_send,
caplog.at_level(logging.INFO, logger="turnstone.core.idle_nudge_watcher"),
):
assert wake_workstream_if_pending(ws, trigger="idle-transition") is True
assert mock_send.call_count == 1
deferred = [
r for r in caplog.records if "nudge_wake.deferred_worker_busy" in r.getMessage()
]
assert len(deferred) == 1
assert deferred[0].levelno == logging.INFO
assert "trigger=" in deferred[0].getMessage()
assert not any("nudge_wake.dispatched" in r.getMessage() for r in caplog.records)
def test_refused_path_logs_refusal(self, fake_mgr_and_ws, caplog):
"""``send`` refusing outright — its authoritative under-lock
``_closed`` re-check caught a teardown the gate's lockless peek
missed emits ``nudge_wake.refused``: a dropped wake must stay
traceable to its trigger, not vanish silently."""
_mgr, ws = fake_mgr_and_ws
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
with (
patch("turnstone.core.session_worker.send", return_value=False) as mock_send,
caplog.at_level(logging.INFO, logger="turnstone.core.idle_nudge_watcher"),
):
assert wake_workstream_if_pending(ws, trigger="watch-fire") is False
assert mock_send.call_count == 1
refused = [r for r in caplog.records if "nudge_wake.refused" in r.getMessage()]
assert len(refused) == 1
assert refused[0].levelno == logging.INFO
assert "trigger=" in refused[0].getMessage()
assert not any("nudge_wake.dispatched" in r.getMessage() for r in caplog.records)
assert not any("nudge_wake.deferred_worker_busy" in r.getMessage() for r in caplog.records)
+55 -281
View File
@@ -15,8 +15,6 @@ from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_INTERACTIVE = _ROOT / "turnstone/shared_static/interactive.js"
_COMPOSER = _ROOT / "turnstone/shared_static/composer.js"
_AUTH = _ROOT / "turnstone/shared_static/auth.js"
_APP = _ROOT / "turnstone/ui/static/app.js"
_UI_INDEX = _ROOT / "turnstone/ui/static/index.html"
@@ -349,290 +347,66 @@ def test_per_token_hot_path_avoids_container_scans() -> None:
assert helper in body, f"missing lookup-cache helper: {helper!r}"
# -- Shared-workstream cross-user send gate -----------------------------------
#
# The UX complement to the server-side CrossUserInterjectionError (a 409): while
# another participant's turn is in flight, this viewer's send button is disabled
# so they can't interject under the initiator's credentials / be misattributed.
# The wiring spans three modules; these string-presence guards catch the silent
# one-line regression the way the rest of this file does (no JS test framework).
_INTERACTIVE_CSS = _ROOT / "turnstone/shared_static/interactive.css"
_UI_STYLE_CSS = _ROOT / "turnstone/ui/static/style.css"
def test_composer_exposes_hard_send_block() -> None:
"""The composer has an independent hard-block axis, reconciled with busy,
so a caller can disable send even in queueWhileBusy (queue) mode."""
body = _COMPOSER.read_text(encoding="utf-8")
assert "Composer.prototype.setSendBlocked = function" in body
assert "Composer.prototype._reconcileDisabled = function" in body
assert "this._sendBlocked = false;" in body
# setBusy must route the disabled write through the reconciler (not clobber
# the block with a direct sendBtn.disabled assignment).
stripped = _strip_comments(body)
setbusy = stripped.index("Composer.prototype.setBusy = function")
setbusy_end = stripped.index("Composer.prototype._reconcileDisabled")
assert "this._reconcileDisabled();" in stripped[setbusy:setbusy_end]
assert "this.sendBtn.disabled =" not in stripped[setbusy:setbusy_end], (
"setBusy must not write sendBtn.disabled directly — reconcile owns it"
def test_transcript_scroller_is_block_flow_with_containment() -> None:
"""P2 (perf audit): the messages scroller is BLOCK flow — a column
flexbox relayouts every row when the streaming row's height changes,
O(rows) per token with native scroll anchoring disabled (the pane owns
bottom pinning, and the browser's anchor node lives inside the
innerHTML-replaced live bubble). Off-screen rows carry
content-visibility:auto with `auto`-keyword intrinsic sizing; the live
tail (last two children) is exempt so the streaming bubble never toggles
skip-state mid-stream."""
css = _INTERACTIVE_CSS.read_text(encoding="utf-8")
rule = css.index(".pane--embedded .pane-messages {")
body = css[rule : css.index("}", rule)]
assert "display: flex" not in body, "scroller must be block flow"
assert "overflow-anchor: none" in body
assert ".pane--embedded .pane-messages > * + *" in css, (
"inter-row rhythm must come from sibling margins, not flex gap"
)
assert "content-visibility: auto" in css
assert "contain-intrinsic-size: auto" in css
assert ":nth-last-child(-n + 2)" in css, "live tail must be exempt"
ui = _UI_STYLE_CSS.read_text(encoding="utf-8")
ui_rule = ui.index(".pane-messages {")
ui_body = ui[ui_rule : ui.index("}", ui_rule)]
assert "display: flex" not in ui_body, "ui/static duplicate must match"
assert "overflow-anchor: none" in ui_body
def test_auth_retains_user_id_for_gate() -> None:
"""whoami's opaque user_id is retained (separately from the display
username) so the pane can compare it against the acting-user id."""
body = _AUTH.read_text(encoding="utf-8")
assert 'sessionStorage.setItem("ts.user_id", data.user_id);' in body
assert 'sessionStorage.removeItem("ts.user_id");' in body
def test_pane_gates_send_on_cross_user_busy() -> None:
"""The pane tracks the acting user from state_change, compares it against
the viewer's own id, and blocks send while another participant is busy."""
def test_transcript_is_windowed_with_pager() -> None:
"""P2 (perf audit): full re-renders paint only the most recent
_HISTORY_WINDOW_STEP messages, cut FORWARD to a user-turn boundary so an
assistant tool_calls message is never split from the tool results that
anchor to it; hidden content sits behind the .msg-history-pager button
(click grows the window and refetches with a scroll-anchor restore).
Live appends are bounded at the idle edge by _LIVE_ROW_CAP, trimming
only while pinned (a scrolled-up user is reading the rows a trim would
remove) and sweeping detached agent-card entries."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert "_reconcileSendBlock() {" in body
# tracks the acting user from the state_change event...
assert "this._actingUserId = evt.acting_user_id;" in body
assert "this._actingUserId = null;" in body # cleared when the turn settles
# ...compares against the viewer's own id from /whoami...
assert 'sessionStorage.getItem("ts.user_id")' in body
assert "this._actingUserId !== me" in body
# ...and drives the composer's hard block, re-run on every busy edge.
assert "this.composer.setSendBlocked(" in body
stripped = _strip_comments(body)
setbusy = stripped.index("setBusy(b) {")
assert "this._reconcileSendBlock();" in stripped[setbusy : setbusy + 600]
def test_pane_handles_cross_user_409() -> None:
"""The reactive fallback: a 409 (button not yet disabled) surfaces a clean
message, not the generic 'Connection error' catch."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert "r.status === 409" in body
assert 'status: "cross_user_interjection"' in body
assert 'data.status === "cross_user_interjection"' in body
def test_sync_approval_state_prunes_orphan_cycles() -> None:
"""``_syncApprovalState`` prunes cycles whose block elements are no longer
in the living DOM (``.isConnected === false``). This covers the rare case
where an ``approve_request`` event is processed between a DOM wipe
(``clear_ui`` / ``replay_truncated`` / ``replaceChildren``) and the
refetch-restore the cycle card lives in a detached subtree, the matching
``approval_resolved`` never arrives, and the send button stays disabled
forever without this guard. The pin guards against a future refactor that
drops the orphan prune but doesn't otherwise break ``_syncApprovalState``."""
body = _INTERACTIVE.read_text(encoding="utf-8")
fn_start = body.index("_syncApprovalState() {")
assert "entry.blockEls && !entry.blockEls.some((el) => el.isConnected)" in body, (
"orphan pruning must check .isConnected on block elements"
assert "const _HISTORY_WINDOW_STEP = 300;" in body
assert "const _LIVE_ROW_CAP = 900;" in body
replay = body.index("replayHistory(messages) {")
seg = body[replay : replay + 4200]
assert 'messages[start].role !== "user"' in seg, (
"the window cut must land on a user-turn boundary"
)
tail = body[fn_start : body.index("_oldestCycleId()", fn_start)]
assert "this.approvalCycles.delete(cid);" in tail, (
"orphan pruning must delete the cycle from the Map"
assert "_addHistoryPager" in seg
assert "for (let i = start; i < messages.length; i++)" in seg
assert 'pager.className = "msg-history-pager";' in body
assert "this._historyWindow += _HISTORY_WINDOW_STEP;" in body
trim = body.index("_trimLiveTranscript() {")
trim_seg = body[trim : trim + 2600]
assert "if (!this._nearBottom) return;" in trim_seg, (
"live trim must only run while pinned to the bottom"
)
# ---------------------------------------------------------------------------
# SSE overflow recovery + close-on-hide (fast-stream corruption fixes)
# ---------------------------------------------------------------------------
def test_stream_overflow_case_counts_and_rate_limits() -> None:
"""The server closes an overflowed stream after an id-less
``stream_overflow`` frame; the pane must count it (field
instrumentation for the drop-vs-render-wedge diagnosis) and route it
through the reconnect limiter so a persistently slow consumer trips
the degraded catch-up instead of churning reconnect/replay cycles."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert 'case "stream_overflow":' in body
assert "this._noteStreamOverflow();" in body
assert "_streamHealth = { overflows: 0, renderThrows: 0, malformedFrames: 0 }" in body
# Both wedge-class catch sites increment the render-throw counter,
# and the malformed-frame drop counts too — the C-OVERDETERMINED
# instrumentation that tells drops apart from wedges in the field.
assert body.count("this._streamHealth.renderThrows += 1;") == 2
assert "this._streamHealth.malformedFrames += 1;" in body
assert "this._streamHealth.overflows += 1;" in body
def test_degraded_catchup_stops_live_stream_and_retries() -> None:
"""Degraded catch-up contract: close the stream FIRST (which also
clears any earlier degraded timer disconnectSSE owns that), show a
plain-language status, then arm the retry timer with a doubling
cooldown. The retry must defer to the show edge when the tab is
hidden (reopening into a throttled tab would overflow again)."""
body = _INTERACTIVE.read_text(encoding="utf-8")
m = re.search(r"_enterDegradedCatchup\(\)\s*\{(.*?)\n \}", body, re.S)
assert m is not None, "_enterDegradedCatchup method not found"
method = m.group(1)
# Order matters: disconnect before arming the timer, or the fresh
# timer would be cancelled by its own disconnect.
assert method.index("this.disconnectSSE()") < method.index("this._degradedTimer = setTimeout")
assert "Connection is slow" in method, "degraded state must use plain language"
assert "DEGRADED_COOLDOWN_MAX_MS" in method
assert "document.hidden" in method
# disconnectSSE owns the timer teardown (ws-switch / giveUp / destroy
# all supersede a pending degraded retry through it).
dis = re.search(r"disconnectSSE\(\)\s*\{(.*?)\n \}", body, re.S)
assert dis is not None
assert "clearTimeout(this._degradedTimer)" in dis.group(1)
def test_visibilitychange_closes_on_hide_reconnects_on_show() -> None:
"""Close-on-hide / replay-on-show: a hidden tab's throttled drain is
the likeliest slow consumer behind server-side overflow (the old
"PR-G closes those connections on hide" comment described a handler
that never existed). The pane installs one visibilitychange
listener, marks ITS OWN hide-closes via ``_hiddenDisconnect`` so a
show edge never resurrects a deliberately-closed stream, and the
factory's destroy removes the listener (it strongly references the
pane)."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert 'document.addEventListener("visibilitychange", this._visHandler);' in body
assert 'document.removeEventListener("visibilitychange", this._visHandler);' in body
vis = re.search(r"_onVisibilityChange\(\)\s*\{(.*?)\n \}", body, re.S)
assert vis is not None, "_onVisibilityChange method not found"
method = vis.group(1)
assert "this.disconnectSSE();" in method
assert "this._hiddenDisconnect = true;" in method
assert "this.connectSSE(this.wsId);" in method
# Reconnect only consumes OUR hide-close marker.
assert "else if (this._hiddenDisconnect)" in method
# Teardown: the factory controller removes the listener on destroy.
assert "pane._removeVisibilityHandler();" in body
# The streaming buffers survive a hide-close: disconnectSSE stays
# transport-only (no contentBuffer wipe) so the visible tail is
# intact when the tab returns.
dis = re.search(r"disconnectSSE\(\)\s*\{(.*?)\n \}", body, re.S)
assert dis is not None
assert "contentBuffer" not in dis.group(1)
def test_no_global_sse_gap_detector() -> None:
"""Live event ids are NOT strictly monotonic across concurrent
tool+content emit (the fan-out runs outside the listeners lock), so
a naive ``id !== lastEventId + 1`` gap check would false-positive.
Recovery is server-signalled (``stream_overflow``) + reconnect
replay instead. This tripwire pins the absence of the naive
arithmetic if gap detection is ever added, it must be scoped to
the content stream only (content-vs-content never reorders)."""
code = _strip_comments(_INTERACTIVE.read_text(encoding="utf-8"))
assert not re.search(r"_lastEventId\s*[+\-]\s*1", code), (
"found lastEventId +/- 1 arithmetic — a global gap detector "
"false-positives on legal concurrent tool/content id inversion"
)
def test_overflow_helpers_extracted_to_shared_module() -> None:
"""The storm-guard constants + the two pure helpers were extracted to the
shared ``sse_overflow.js`` module (its own runtime probes live in
``test_sse_overflow_js.py``) so the interactive and coordinator panes can't
drift. Pin that the pane IMPORTS them rather than re-declaring a local
copy: a stray local ``function overflowWindowTripped`` / ``const
OVERFLOW_TRIP_COUNT`` would silently fork the trip math again."""
body = _INTERACTIVE.read_text(encoding="utf-8")
m = re.search(
r"import \{([^}]*)\} from \"\./sse_overflow\.js\";",
body,
re.S,
)
assert m is not None, "interactive pane must import the shared overflow helpers"
imported = m.group(1)
for name in (
"OVERFLOW_TRIP_COUNT",
"OVERFLOW_TRIP_WINDOW_MS",
"DEGRADED_COOLDOWN_BASE_MS",
"DEGRADED_COOLDOWN_MAX_MS",
"DEGRADED_COOLDOWN_RESET_MS",
"overflowWindowTripped",
"degradedCooldownStep",
):
assert name in imported, f"{name} must be imported from sse_overflow.js"
# No local fork of the extracted definitions.
assert not re.search(r"^function overflowWindowTripped\(", body, re.M), (
"overflowWindowTripped must be imported, not re-declared locally"
)
assert not re.search(r"^function degradedCooldownStep\(", body, re.M), (
"degradedCooldownStep must be imported, not re-declared locally"
)
assert not re.search(r"^const OVERFLOW_TRIP_COUNT\s*=", body, re.M), (
"the trip constants must be imported, not re-declared locally"
)
def test_note_stream_overflow_does_not_reset_cooldown() -> None:
"""The exact finding [0] bug shape must not regress: _noteStreamOverflow
only counts + trips; it must NOT touch _degradedCooldownMs (the reset
that defeated the ladder lived here). The ladder decision lives solely
in _enterDegradedCatchup, keyed off _lastDegradedAt via
degradedCooldownStep."""
body = _INTERACTIVE.read_text(encoding="utf-8")
note = re.search(r"_noteStreamOverflow\(\)\s*\{(.*?)\n \}", body, re.S)
assert note is not None, "_noteStreamOverflow not found"
assert "_degradedCooldownMs" not in note.group(1), (
"_noteStreamOverflow must not write _degradedCooldownMs — that reset "
"was the bug that stopped the ladder escalating"
)
enter = re.search(r"_enterDegradedCatchup\(\)\s*\{(.*?)\n \}", body, re.S)
assert enter is not None
assert "degradedCooldownStep(" in enter.group(1)
assert "this._lastDegradedAt = now" in enter.group(1)
def test_recover_beat_defers_reconnect_when_tab_hidden() -> None:
"""Review round-2 finding [1]: the factory's transient-error recovery
beat (recoverTimer) must NOT reopen an EventSource into a hidden tab
that re-creates the throttled slow-consumer overflow that close-on-hide
exists to prevent. It guards on document.hidden and defers to the
visibilitychange show edge (marking _hiddenDisconnect)."""
body = _INTERACTIVE.read_text(encoding="utf-8")
beat = re.search(r"recoverTimer = setTimeout\(\(\) => \{(.*?)\n \}, 5000\);", body, re.S)
assert beat is not None, "recoverTimer setTimeout body not found"
b = beat.group(1)
assert "document.hidden" in b, "recovery beat must guard on document.hidden"
assert "pane._hiddenDisconnect = true" in b, (
"recovery beat must defer to the show edge when hidden"
)
# The hidden guard must precede the reconnect (connectSSE) so it can't fall
# through to reopening the stream.
assert b.index("document.hidden") < b.index("pane.connectSSE(pane.wsId)")
def test_giveup_removes_visibility_handler() -> None:
"""Review round-2 finding [3]: giveUp() (markDead) must detach the
visibility handler and clear _hiddenDisconnect, or a tab hidden before
the give-up resurrects the dead controller's stream on return (the show
edge would connectSSE the closed ws and 404-reconnect it forever)."""
body = _INTERACTIVE.read_text(encoding="utf-8")
give = re.search(r"const giveUp = function \(\) \{(.*?)\n \};", body, re.S)
assert give is not None, "giveUp function body not found"
g = give.group(1)
assert "pane._removeVisibilityHandler();" in g, (
"giveUp must remove the visibility handler so a show edge can't resurrect a dead controller"
)
# _removeVisibilityHandler also clears _hiddenDisconnect (pinned in its body).
rvh = re.search(r"_removeVisibilityHandler\(\)\s*\{(.*?)\n \}", body, re.S)
assert rvh is not None
assert "this._hiddenDisconnect = false" in rvh.group(1)
def test_connectsse_defers_open_when_tab_hidden() -> None:
"""PR #805 review (Copilot + R3): connectSSE is the single connect
chokepoint and must not open an EventSource into a hidden tab. The
fresh-connect path (_loadHistoryThenConnect) has no timer guard, so a
first load in a background tab would otherwise open a throttled stream
the slow-consumer overflow this PR exists to prevent. The guard sits
AFTER the visibilitychange-handler install (so the show edge can
reconnect) and AFTER the wsId assignment (so it targets the right ws),
and BEFORE `new EventSource` (so nothing opens)."""
body = _INTERACTIVE.read_text(encoding="utf-8")
start = body.index("connectSSE(wsId) {")
open_at = body.index("new EventSource(evtUrl)", start)
head = body[start:open_at] # connectSSE up to the EventSource open
assert "if (document.hidden) {" in head, (
"connectSSE must guard on document.hidden BEFORE opening the stream"
)
assert "this._hiddenDisconnect = true;" in head, (
"the deferred connect must mark _hiddenDisconnect so the show edge reconnects"
)
assert head.index("this.wsId = wsId;") < head.index("if (document.hidden) {")
assert head.index('addEventListener("visibilitychange"') < head.index("if (document.hidden) {")
assert "card.wrap.isConnected" in trim_seg, "live trim must sweep detached agent-card entries"
# Rewind/edit turn math is tail-relative (counts user rows at-or-AFTER
# the clicked one), which is what makes hiding EARLIER rows safe — pin
# the tail-relative form so a refactor to absolute indexing fails here
# and gets re-checked against windowing.
assert body.count("userMsgs.length - idx") >= 2
+4 -179
View File
@@ -71,7 +71,7 @@ def _make_judge(
session_provider=provider,
session_client=client,
session_model="test-model",
session_capabilities=MagicMock(context_window=100_000),
context_window=100_000,
)
@@ -476,74 +476,6 @@ class TestContextPreparation:
assert "Conversation context:" in result[1]["content"]
class TestArgBudget:
"""The projected ``func_args`` and the conversation transcript share the
judge model's context window; large arguments are honestly truncated to it
rather than blind-capped."""
def test_positive_window_coerces_zero_and_non_int(self):
from turnstone.core.judge import _DEFAULT_JUDGE_CONTEXT_WINDOW, _positive_window
assert _positive_window(50_000) == 50_000
assert _positive_window(0, 40_000) == 40_000 # 0 falls through to next
assert _positive_window(None, 0, 32_000) == 32_000 # None + 0 fall through
assert _positive_window(-5, floor=1_000) == 1_000
assert _positive_window(0) == _DEFAULT_JUDGE_CONTEXT_WINDOW # floor default
def test_honest_truncate_verbatim_when_it_fits(self):
from turnstone.core.judge import honest_truncate
assert honest_truncate("short", 100) == "short"
def test_honest_truncate_reports_exact_omitted_count(self):
from turnstone.core.judge import honest_truncate
out = honest_truncate("A" * 5000, 1000)
assert out.startswith("A" * 1000)
assert "4,000 of 5,000 chars omitted" in out
def test_arg_budget_scales_with_context_window_uncapped(self):
"""The judge-prompt budget scales with the real window and is NOT
ceilinged a big-window judge gets a proportionally big budget so args
lower whole; only a genuine overflow truncates."""
from turnstone.core.judge import _ARG_CONTEXT_RATIO, _CHARS_PER_TOKEN
judge = _make_judge()
judge._judge_context_window = 40_000
small = judge.arg_budget_chars()
judge._judge_context_window = 200_000
big = judge.arg_budget_chars()
assert small == int(40_000 * _ARG_CONTEXT_RATIO * _CHARS_PER_TOKEN)
assert big == int(200_000 * _ARG_CONTEXT_RATIO * _CHARS_PER_TOKEN) # no ceiling
def test_verdict_record_copy_is_capped_by_oh_crap_backstop(self):
"""The func_args stored on the verdict (persisted + streamed) is bounded
by _VERDICT_ARG_CAP even when the args are enormous the judge PROMPT
is bounded separately by the window, not by this cap."""
from turnstone.core.judge import _VERDICT_ARG_CAP, evaluate_heuristic
v = evaluate_heuristic("write_file", {"content": "Z" * 40_000}, "write_file", "c1")
assert len(v.func_args) <= _VERDICT_ARG_CAP + 80 # payload + honest marker
assert "chars omitted" in v.func_args
def test_large_args_shrink_the_history_they_share_the_window_with(self):
"""A big write/edit must eat into the transcript budget, not push the
prompt past the window."""
judge = _make_judge()
# One anchor user turn (the judge trims to the last user message
# onward), then many assistant turns that compete for the budget.
messages: list[dict[str, Any]] = [{"role": "user", "content": "anchor"}]
messages += [{"role": "assistant", "content": "x" * 1000} for _ in range(50)]
small = judge._prepare_context(_make_item(func_args={"command": "ls"}), messages)
big = judge._prepare_context(
_make_item(func_name="write_file", func_args={"content": "Z" * 200_000}), messages
)
# Each included history turn renders one "ASSISTANT:" line; the
# big-argument call fits strictly fewer of them.
assert big[1]["content"].count("ASSISTANT:") < small[1]["content"].count("ASSISTANT:")
# ---------------------------------------------------------------------------
# Confidence arbitration
# ---------------------------------------------------------------------------
@@ -892,83 +824,15 @@ class TestModelAliasResolution:
alias_provider: MagicMock,
alias_client: MagicMock,
underlying_model: str,
*,
capabilities: dict[str, Any] | None = None,
) -> MagicMock:
registry = MagicMock()
cfg = MagicMock()
cfg.context_window = 50_000
cfg.capabilities = capabilities if capabilities is not None else {}
registry.has_alias.side_effect = lambda a: a == alias
registry.resolve.return_value = (alias_client, underlying_model, cfg)
registry.get_provider.return_value = alias_provider
return registry
def test_alias_capabilities_merged_and_threaded_to_wire(self):
"""#823: a judge alias's model-definition ``capabilities`` are merged
onto the provider base AND passed to ``create_completion`` the same
contract as the session / utility / sub-agent lanes. Without threading,
operator overrides (effort passthrough, tool support) were silently
ignored on judge calls; deleting ``capabilities=self._capabilities`` from
the call site, or breaking the merge, must fail here."""
from turnstone.core.providers._protocol import ModelCapabilities
base = ModelCapabilities(supports_tools=True, effort_passthrough=False)
alias_provider = _make_mock_provider(response_content=_good_verdict_json())
alias_provider.get_capabilities = MagicMock(return_value=base)
registry = self._make_alias_registry(
"judge-mini",
alias_provider,
MagicMock(base_url="https://a/v1", api_key="k"),
"local-9b",
capabilities={"supports_tools": False, "effort_passthrough": True},
)
judge = IntentJudge(
config=JudgeConfig(enabled=True, model="judge-mini"),
session_provider=_make_mock_provider(),
session_client=MagicMock(base_url="https://s/v1", api_key="s"),
session_model="session-model",
session_capabilities=MagicMock(context_window=100_000),
model_registry=registry,
)
# Merged at construction: overrides applied, untouched fields survive.
assert judge._capabilities.supports_tools is False
assert judge._capabilities.effort_passthrough is True
assert judge._capabilities.context_window == base.context_window
# ...and the SAME merged object reaches the wire.
judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "x"}],
cancel_event=None,
client=MagicMock(),
)
passed = alias_provider.create_completion.call_args.kwargs["capabilities"]
assert passed is judge._capabilities
def test_fallback_threads_session_capabilities_to_wire(self):
"""No judge alias → the judge inherits the session model AND the
session's resolved capabilities, threaded to ``create_completion``."""
from turnstone.core.providers._protocol import ModelCapabilities
sess_caps = ModelCapabilities(context_window=54_321, effort_passthrough=True)
provider = _make_mock_provider(response_content=_good_verdict_json())
judge = IntentJudge(
config=JudgeConfig(enabled=True, model=""), # no alias → fallback
session_provider=provider,
session_client=MagicMock(base_url="https://s/v1", api_key="s"),
session_model="session-model",
session_capabilities=sess_caps,
)
assert judge._capabilities is sess_caps
assert judge._judge_context_window == 54_321
judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "x"}],
cancel_event=None,
client=MagicMock(),
)
assert provider.create_completion.call_args.kwargs["capabilities"] is sess_caps
def test_alias_uses_registry_provider_not_session_provider(self):
"""Judge with model=alias should resolve via registry — provider, client,
and concrete model name all come from the alias."""
@@ -1000,6 +864,7 @@ class TestModelAliasResolution:
session_provider=session_provider,
session_client=session_client,
session_model="session-default-model",
context_window=100_000,
model_registry=registry,
)
@@ -1010,47 +875,6 @@ class TestModelAliasResolution:
assert judge._client_factory_args["api_key"] == "alias-key"
assert judge._client_factory_args["provider_name"] == "openai"
def test_alias_window_comes_from_registry_config_not_provider_caps(self):
"""The judge window must come from the registry's ModelConfig
(cfg.context_window=50_000 here), NOT provider.get_capabilities(), which
returns a static 200000 for every local model and would over-budget a
small local judge into overflow."""
alias_provider = _make_mock_provider()
alias_provider.provider_name = "openai"
# If the code (wrongly) consulted caps, it'd read this fictitious 200k.
alias_provider.get_capabilities = MagicMock(return_value=MagicMock(context_window=200_000))
alias_client = MagicMock(base_url="https://alias/v1", api_key="k")
registry = self._make_alias_registry("judge-mini", alias_provider, alias_client, "local-9b")
judge = IntentJudge(
config=JudgeConfig(enabled=True, model="judge-mini"),
session_provider=_make_mock_provider(),
session_client=MagicMock(base_url="https://s/v1", api_key="s"),
session_model="session-model",
model_registry=registry,
)
assert judge._judge_context_window == 50_000
def test_alias_zero_context_window_falls_back_to_session(self):
"""config.toml can hand back a ModelConfig with context_window=0 (that
path lacks the DB loader's 0→inherit normalization); a 0 window would
zero every budget and make honest_truncate drop everything, so it must
fall back to the session window."""
cfg = MagicMock()
cfg.context_window = 0
registry = MagicMock()
registry.has_alias.side_effect = lambda a: a == "judge-mini"
registry.resolve.return_value = (MagicMock(base_url="http://a", api_key="k"), "m", cfg)
registry.get_provider.return_value = _make_mock_provider()
judge = IntentJudge(
config=JudgeConfig(enabled=True, model="judge-mini"),
session_provider=_make_mock_provider(),
session_client=MagicMock(base_url="http://s", api_key="s"),
session_model="session-model",
session_capabilities=MagicMock(context_window=100_000),
model_registry=registry,
)
assert judge._judge_context_window == 100_000 # session window, not 0
def test_unknown_alias_inherits_session_model(self):
"""``judge.model`` is alias-only. A value that doesn't resolve
through the registry inherits the session model (same path as
@@ -1074,7 +898,7 @@ class TestModelAliasResolution:
session_provider=session_provider,
session_client=session_client,
session_model="session-default-model",
session_capabilities=MagicMock(context_window=100_000),
context_window=100_000,
model_registry=registry,
)
@@ -1097,6 +921,7 @@ class TestModelAliasResolution:
session_provider=session_provider,
session_client=session_client,
session_model="session-default-model",
context_window=100_000,
)
assert judge._provider is session_provider
-248
View File
@@ -11,17 +11,12 @@ this pins the behaviour the old ``_anthropic`` ``pc_tool_ids`` /
from __future__ import annotations
import json
from typing import Any
from turnstone.core.lowering import (
CANCELLED_TOOL_RESULT,
_find_orphaned_tool_calls,
repair_wire_messages,
restore_provider_tool_ids,
sanitize_tool_call_arguments,
tool_args_preview,
wire_valid_arguments,
)
@@ -185,246 +180,3 @@ def test_repair_does_not_mutate_input() -> None:
repair_wire_messages(msgs)
assert len(msgs) == original_len # caller's list untouched
assert "tool_calls" in msgs[0]
# --------------------------------------------------------------------------- #
# wire_valid_arguments — the shared "is this renderable" predicate
# --------------------------------------------------------------------------- #
def test_wire_valid_arguments_accepts_json_objects() -> None:
assert wire_valid_arguments("{}") is True
assert wire_valid_arguments('{"command": "ls -la"}') is True
assert wire_valid_arguments(' { "a": 1 }\n') is True # surrounding whitespace ok
def test_wire_valid_arguments_rejects_unrenderable() -> None:
assert wire_valid_arguments('{"command": "cat /va') is False # unterminated (the incident)
assert wire_valid_arguments("") is False # empty (no-arg call) — json.loads raises
assert wire_valid_arguments("[]") is False # array, not object
assert wire_valid_arguments("5") is False # bare scalar
assert wire_valid_arguments('"hi"') is False # bare string
assert wire_valid_arguments(None) is False # missing
assert wire_valid_arguments({"a": 1}) is False # raw dict — not a string on the wire
def test_wire_valid_arguments_totals_on_deeply_nested_json() -> None:
# Deeply-nested JSON makes json.loads raise RecursionError (not a ValueError);
# the predicate must return False, not propagate and crash the send.
deep = "[" * 5000 + "]" * 5000
assert wire_valid_arguments(deep) is False
def test_tool_args_preview_stringifies_and_caps() -> None:
assert tool_args_preview("x" * 500) == "x" * 120
assert tool_args_preview(None) == "None"
assert tool_args_preview({"a": 1}) == "{'a': 1}"
def test_tool_args_preview_redacts_credentials() -> None:
# Secrets in tool args (bash commands, tokens) must not reach logs — the preview
# runs output_guard.redact_credentials over the full value first (PR #778 review).
out = tool_args_preview('{"command": "aws configure set key AKIAIOSFODNN7EXAMPLE"}')
assert "AKIAIOSFODNN7EXAMPLE" not in out
assert "[REDACTED:api_key]" in out
def test_tool_args_preview_is_single_line() -> None:
# Control chars (LF/CR/TAB) collapse to spaces so the preview stays one log line.
raw = "line1" + chr(10) + "line2" + chr(13) + "end" + chr(9) + "z"
out = tool_args_preview(raw)
assert chr(10) not in out and chr(13) not in out and chr(9) not in out
assert "line1" in out and "end" in out
# --------------------------------------------------------------------------- #
# sanitize_tool_call_arguments — the legalize pass
# --------------------------------------------------------------------------- #
def _call(call_id: str, arguments: Any, name: str = "bash") -> dict[str, Any]:
return {"id": call_id, "type": "function", "function": {"name": name, "arguments": arguments}}
def _assistant_calls(*calls: dict[str, Any]) -> dict[str, Any]:
return {"role": "assistant", "content": "", "tool_calls": list(calls)}
def test_sanitize_identity_when_all_valid() -> None:
msgs = [_assistant_calls(_call("c1", "{}"), _call("c2", '{"a": 1}')), _tool("c1"), _tool("c2")]
# Every arguments already a JSON object → same object returned (allocation-free).
assert sanitize_tool_call_arguments(msgs) is msgs
def test_sanitize_identity_when_no_tool_calls() -> None:
msgs = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}]
assert sanitize_tool_call_arguments(msgs) is msgs
def test_sanitize_legalizes_unterminated_arguments() -> None:
# The production incident: deepseek-v4-flash emitted an unterminated args string
# with a non-``length`` finish reason, so it was committed and replayed verbatim.
msgs = [_assistant_calls(_call("c1", '{"command": "cat /va')), _tool("c1", "retry")]
out = sanitize_tool_call_arguments(msgs)
assert out is not msgs # copied on repair
assert out[0]["tool_calls"][0]["function"]["arguments"] == "{}"
assert json.loads(out[0]["tool_calls"][0]["function"]["arguments"]) == {}
def test_sanitize_legalizes_empty_arguments() -> None:
# A no-arg tool call sends ``""``; json.loads("") raises, so deepseek_v4 would 400.
out = sanitize_tool_call_arguments([_assistant_calls(_call("c1", ""))])
assert out[0]["tool_calls"][0]["function"]["arguments"] == "{}"
def test_sanitize_legalizes_non_object_json() -> None:
out = sanitize_tool_call_arguments([_assistant_calls(_call("c1", "[]"), _call("c2", "5"))])
assert [tc["function"]["arguments"] for tc in out[0]["tool_calls"]] == ["{}", "{}"]
def test_sanitize_serializes_raw_dict_arguments() -> None:
out = sanitize_tool_call_arguments([_assistant_calls(_call("c1", {"command": "ls"}))])
got = out[0]["tool_calls"][0]["function"]["arguments"]
assert isinstance(got, str) and json.loads(got) == {"command": "ls"}
def test_sanitize_falls_back_when_dict_not_serializable() -> None:
# Defensive branch: a dict arguments carrying a non-JSON-encodable value
# (a set) makes json.dumps raise TypeError — it collapses to "{}", not a crash.
out = sanitize_tool_call_arguments([_assistant_calls(_call("c1", {"x": {1, 2, 3}}))])
assert out[0]["tool_calls"][0]["function"]["arguments"] == "{}"
def test_sanitize_touches_only_the_offending_call() -> None:
good = _call("c1", '{"a": 1}')
bad = _call("c2", "{oops")
out = sanitize_tool_call_arguments([_assistant_calls(good, bad)])
# Valid sibling preserved by identity; only the bad call is rebuilt.
assert out[0]["tool_calls"][0] is good
assert out[0]["tool_calls"][1]["function"]["arguments"] == "{}"
def test_sanitize_does_not_mutate_input() -> None:
raw = '{"command": "cat /va'
bad = _call("c1", raw)
msgs = [_assistant_calls(bad)]
sanitize_tool_call_arguments(msgs)
assert bad["function"]["arguments"] == raw # caller's dict untouched
assert msgs[0]["tool_calls"][0] is bad
# --------------------------------------------------------------------------- #
# legalize ∘ repair — the two send-time validity passes compose
# --------------------------------------------------------------------------- #
def test_legalize_then_repair_answered_call() -> None:
# Malformed-but-answered (the poison-pill shape): args legalized, no orphan added.
msgs = [_assistant_calls(_call("c1", "{bad")), _tool("c1", "retry with valid JSON")]
out = repair_wire_messages(sanitize_tool_call_arguments(msgs))
assert [m["role"] for m in out] == ["assistant", "tool"]
assert json.loads(out[0]["tool_calls"][0]["function"]["arguments"]) == {}
def test_legalize_then_repair_orphaned_call() -> None:
# Malformed AND unanswered: legalized args + a synthesized cancellation result.
msgs = [_assistant_calls(_call("c1", "{bad"))]
out = repair_wire_messages(sanitize_tool_call_arguments(msgs))
assert [m["role"] for m in out] == ["assistant", "tool"]
assert json.loads(out[0]["tool_calls"][0]["function"]["arguments"]) == {}
assert out[1]["content"] == CANCELLED_TOOL_RESULT
def test_pipeline_every_emitted_arguments_is_a_json_object() -> None:
# The end-state invariant a strict renderer relies on.
msgs = [
_assistant_calls(_call("c1", ""), _call("c2", "{oops"), _call("c3", '{"ok": true}')),
_tool("c1"),
_tool("c2"),
_tool("c3"),
]
out = repair_wire_messages(sanitize_tool_call_arguments(msgs))
for m in out:
for tc in m.get("tool_calls", []):
assert isinstance(json.loads(tc["function"]["arguments"]), dict)
# --------------------------------------------------------------------------- #
# restore_provider_tool_ids — the agent-wire id map (minted → provider-original).
#
# Sub-agent tool ids are minted "{parent}::r{run}s{step}::{provider_id}" for
# session-unique correlation (registry / DOM / recall). On the wire the pass
# maps them BACK to the provider's own ids from the per-run mint map, so the
# provider-native tool_use block (replayed verbatim, id never rewritten), the
# top-level tool_calls mirror, and the tool_result all agree on every request.
# --------------------------------------------------------------------------- #
def test_restore_ids_identity_on_empty_map() -> None:
msgs = [_assistant_calls(_call("task-1::r1s1::call_0", "{}")), _tool("task-1::r1s1::call_0")]
assert restore_provider_tool_ids(msgs, {}) is msgs
def test_restore_ids_identity_when_nothing_matches() -> None:
msgs = [_assistant_calls(_call("call_1", "{}")), _tool("call_1")]
assert restore_provider_tool_ids(msgs, {"task-1::r1s1::call_0": "call_0"}) is msgs
def test_restore_ids_maps_call_and_result_to_provider_original() -> None:
minted = "task-1::r1s1::toolu_01AB"
msgs = [_assistant_calls(_call(minted, "{}")), _tool(minted)]
out = restore_provider_tool_ids(msgs, {minted: "toolu_01AB"})
assert out[0]["tool_calls"][0]["id"] == "toolu_01AB"
assert out[1]["tool_call_id"] == "toolu_01AB" # pairing restored on both sides
# Copy-on-write: the input messages (the canonical-adjacent dicts) are unmutated.
assert msgs[0]["tool_calls"][0]["id"] == minted
assert msgs[1]["tool_call_id"] == minted
def test_restore_ids_recovers_originals_containing_the_mint_delimiter() -> None:
# Recovery is by MAP, not by string-splitting the mint suffix: a provider
# id that itself contains "::" round-trips exactly.
original = "srv::call::0"
minted = f"task-1::r1s1::{original}"
msgs = [_assistant_calls(_call(minted, "{}")), _tool(minted)]
out = restore_provider_tool_ids(msgs, {minted: original})
assert out[0]["tool_calls"][0]["id"] == original
assert out[1]["tool_call_id"] == original
def test_restore_ids_duplicate_originals_across_turns() -> None:
# A local server reissuing "call_0" every turn: two distinct minted ids
# both restore to "call_0" — the proven prior wire shape, each round
# pairing with its adjacent result.
m1, m2 = "task-1::r1s1::call_0", "task-1::r1s2::call_0"
msgs = [
_assistant_calls(_call(m1, "{}")),
_tool(m1),
_assistant_calls(_call(m2, "{}")),
_tool(m2),
]
out = restore_provider_tool_ids(msgs, {m1: "call_0", m2: "call_0"})
assert out[0]["tool_calls"][0]["id"] == "call_0"
assert out[1]["tool_call_id"] == "call_0"
assert out[2]["tool_calls"][0]["id"] == "call_0"
assert out[3]["tool_call_id"] == "call_0"
def test_restore_ids_leaves_unmapped_siblings_untouched() -> None:
minted = "task-1::r1s2::call_1"
msgs = [
_assistant_calls(_call("call_ok", "{}"), _call(minted, "{}")),
_tool("call_ok"),
_tool(minted),
]
out = restore_provider_tool_ids(msgs, {minted: "call_1"})
assert out[0]["tool_calls"][0]["id"] == "call_ok"
assert out[1]["tool_call_id"] == "call_ok"
assert out[0]["tool_calls"][1]["id"] == "call_1"
assert out[2]["tool_call_id"] == "call_1"
def test_restore_ids_skips_empty_and_non_string() -> None:
# Empty ids belong to repair_wire_messages' back-fill; non-strings are
# someone else's malformation — neither is this pass's to invent.
msgs = [
_assistant_calls(
{"id": "", "type": "function", "function": {"name": "b", "arguments": "{}"}}
),
{"role": "tool", "tool_call_id": None, "content": "x"},
]
out = restore_provider_tool_ids(msgs, {"task-1::r1s1::x": "x"})
assert out[0]["tool_calls"][0]["id"] == ""
assert out[1]["tool_call_id"] is None
+73 -1145
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More