Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0252-http-steer-endpoint.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR 0252 — HTTP steer endpoint: `POST /v1/sessions/{id}/steer`

- Status: Proposed
- Status: Accepted
- Date: 2026-08-31
- Scope: `internal/adapter/server` (`http.go`), the RFC 9457 error registry
([ADR 0248](./0248-sdk-compatibility-and-error-contract.md)), mecatl's HTTP
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/api-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ v1 enforces required checks in the Go server (protovalidate runtime is deferred)
| `POST /v1/sessions/{id}/approve` | `Run.Approve` | resolves the paused ask (verdict or legacy `allow`) |
| `POST /v1/sessions/{id}/cancel` | `Run.Cancel` | cancels the in-flight run |
| `POST /v1/sessions/{id}/cancel-child` | `Run.CancelChild` | cancels ONE child of the in-flight run |
| `POST /v1/sessions/{id}/steer` | `Service.SteerEnqueue` | unary mid-run steer: enqueue to the live run's inbox (`accepted`/`appended`/`too_late`); never promotes — on `too_late` the caller keeps the text and drives its own follow-up prompt (the gRPC `steer` frame's in-server promote stays gRPC-only) |
| `POST /v1/sessions/{id}/cancel-steer` | `Service.CancelSteer` | retracts the pending (un-drained) steer (`retracted`/`none_pending`) |
| `POST /v1/sessions/{id}/adoption:preflight` | `PreflightSessionAdoption` | caller-owned eligibility and explicit-binding preflight; no source mutation |
| `POST /v1/sessions/{id}/adopt` | `AdoptSession` | idempotent atomic new-main copy; source remains inspect-only |
| `DELETE /v1/sessions/{id}` | `CloseSession` | frees the per-session engine slot |
Expand Down
50 changes: 34 additions & 16 deletions docs/design/IMPLEMENTATION-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8147,19 +8147,38 @@ costs no prompt-cache rebuild beyond normal history growth). The drain emits
`EvSteer` carrying the committed text and media parts — the authoritative echo;
the client renders the echoed truth (recorded == streamed == model-view).

**Wire (gRPC-only v1).** A `steer`/`steer_cancel` oneof arm on the bidi `Converse`
stream, the `ServerCapabilities.steer` runtime gate, and the `EvSteer` echo. Mecatui
uses native multimodal steer when the bit is true; otherwise every mid-run input
stays in the local merge queue. The routing has ONE owner —
`Service.Steer`/`Service.CancelSteer` (`internal/adapter/server/service.go`); the
gRPC handler is a dumb frame→Service mapper. **Correlation (watermark).** Every
**Wire.** A `steer`/`steer_cancel` oneof arm on the bidi `Converse` stream
(gRPC, v1), a `ServerCapabilities.steer` bit (additive grow, computed once in
composition, mirrored onto the HTTP capabilities echo's `steer` field), the
`EvSteer` echo, and — the ADR-0232 named follow-up, landed — the unary HTTP pair
`POST /v1/sessions/{id}/steer` / `POST /v1/sessions/{id}/cancel-steer`
(ADR 0252; `internal/adapter/server/http.go` (`steer`)/(`steerCancel`),
mirroring `approve`/`cancel`/`cancel-child`). The HTTP body carries the SAME contract as
the gRPC frame — text and/or multimodal `parts` (decoded through the one
`toContentParts` choke point) plus an optional strict `expected_run_id` — and
routes into the SAME `Service.Steer` the gRPC handler invokes.
`Service.Steer` owns enqueue + promote and `Service.CancelSteer` owns the
retract (`internal/adapter/server/service.go`); both wire handlers are dumb
frame/body→Service mappers. **Promotion over HTTP follows ADR 0252:** an
UNQUALIFIED too_late steer is promoted and the follow-up run is relayed as SSE
on the same response (or background-drained into the durable event log behind
a `{"outcome":"too_late","promoted":true}` ack when the writer cannot stream);
a STRICT steer (`expected_run_id` set) never promotes — a mismatch or a named
run already terminal answers `409` `stale_run_control` and the caller keeps
the text. The pair self-describes as `http_steer` in the feature registry. **Correlation (watermark).** Every
frame carries a client-minted `message_id`; the ack lane echoes its own frame's
id on each outcome. The engine inbox parks text and media together, while the Service keeps a
per-session FIFO of the ordered frame ids (`trackSteerMessageID`/
`LookupSteerMessageID`/`dropSteerMessageID`); on drain the relay pops the whole
list and stamps the `EvSteer` echo with the LATEST (tail) id — the **watermark**
the client splits its ordered queue on (positional, never text-match — pinned by
`TestLookupSteerMessageIDExactUnderDuplicateTexts`). Ids are clamped to a 64-rune
id on each outcome (HTTP: the response body's `message_id`). The engine inbox
parks text and media parts together, and the Service keeps a per-session FIFO of the ordered frame
ids (`trackSteerMessageID`/`LookupSteerMessageID`/`dropSteerMessageID`); on
drain the relay pops the whole list and stamps the `EvSteer` echo with the
LATEST (tail) id — the **watermark** the client splits its ordered queue on
(positional, never text-match — pinned by
`TestLookupSteerMessageIDExactUnderDuplicateTexts`). The stamp + the
correlated-INFO/uncorrelated-WARN diagnostics have ONE owner —
`Service.stampSteerEcho` — called by BOTH relays (gRPC `sendEvent`, HTTP
`relayRunSSE`), so the two wires cannot drift. Ids are clamped to a 64-rune
Mecatui uses native multimodal steer when the bit is true; otherwise every
mid-run input stays in the local merge queue.
prefix at track before touching the FIFO or any log (CWE-770). **Lost terminal
race → auto-promote + sequential handoff:** a steer arriving for a session whose
run is already terminal is promoted to a fresh follow-up run through the hardened
Expand All @@ -8174,10 +8193,9 @@ is not goroutine-safe), the control target (`ResumeApproval`/`Cancel`/
`CancelChild`) swaps to the promoted run atomically before its relay starts, and
the promoted run is `FinishRun`-deregistered before the RPC returns (its terminal
outcome is reported inline as the `steer.outcome` ack, `promoted=true`).
**HTTP/SSE and ACP steer are deferred** (no client→server mid-run channel; a
unary `POST .../steer` mirroring `approve`/`cancel` is the cheap follow-up
shape), as is **steer-to-child** (needs a richer parent→child channel than
`CancelChild`).
**HTTP/SSE steer is LANDED** (the unary pair above, no auto-promote). **ACP
steer stays deferred** (no client→server mid-run channel), as is
**steer-to-child** (needs a richer parent→child channel than `CancelChild`).

**mecatui.** Reads the `steer` capability off the CreateSession echo: present →
`enter` mid-run sends a `steer` frame (each `enter` mints a fresh `message_id`,
Expand Down
64 changes: 54 additions & 10 deletions docs/usage/http-sse-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ compatibility. `build_id` is not a semantic-version API.
| `POST /v1/sessions/{id}/plan:approve` | `{"target_mode": "default" \| "accept_edits" \| "plan", "note": "..."}` | `200` `text/event-stream` — atomically resolve a parked **plan-approval** ask ([ADR 0069](../adr/0069-plan-approval-gate.md)): on `default`/`accept_edits` resume the parked run AND start the continuation run (both streamed); on `plan`/`""` iterate (no continuation). `409` on a precondition failure (live run / not awaiting / not a plan ask), `404` on an unknown session |
| `POST /v1/sessions/{id}/cancel` | — | `204` |
| `POST /v1/sessions/{id}/cancel-child` | `{child_id}` | `204`; `404` for an unknown / already-finished child |
| `POST /v1/sessions/{id}/steer` | `{text, message_id?}` | `200` `{outcome, message_id}` — enqueue a mid-run steer into the live run's inbox (`accepted` \| `appended` \| `too_late`; on `too_late` the caller keeps the text — see the steer section) |
| `POST /v1/sessions/{id}/cancel-steer` | — | `200` `{outcome}` — retract the pending (un-drained) steer (`retracted` \| `none_pending`) |
| `POST /v1/sessions/{id}/fork` | `{"title": "...", "reasoning_effort": "..."}` (both optional; empty/absent inherits the source's) | `201` `{session_id}` — create a peer session from `{id}`'s conversation history snapshot (ADR 0065); same provider/model only, with the ONE optional selector delta a reasoning-effort override (ADR 0068); `412` if `{id}` is not an idle/terminal main chat or is live in this process, `409` when another replica holds its lease |
| `POST /v1/sessions/{id}/adoption:preflight` | `{workspace, environment_kind, environment_id, provider_id, model_id, profile?}` | `200` `{eligible, reason_code, bindings}`. Requires authenticated caller ownership; absent and foreign IDs are both `404`. Every binding is explicit and unresolved bindings return `binding_unresolved` rather than selecting a default |
| `POST /v1/sessions/{id}/adopt` | the same explicit bindings plus `idempotency_key` | `201` `{session_id, source_session_id, capabilities, resolved_model}`. Revalidates under the source mutation lease; a retry returns the same complete target. The legacy source is unchanged |
Expand Down Expand Up @@ -342,18 +344,60 @@ $ curl -s -X POST http://127.0.0.1:8081/v1/sessions/<id>/cancel
The run terminates with a `result` whose `stop` is `cancelled`. No in-flight run
→ `404` `{"error":"no in-flight run for session"}`.

### Mid-run steer is gRPC-only (v1)
### Mid-run steer

The **steer** capability (steer-while-running, issue #512 — inject an operator
instruction into an *in-flight* run, drained at the next turn boundary) rides the
bidi gRPC `Converse` stream as a `steer` / `steer_cancel` request arm. The HTTP/SSE
run path has **no mid-run client→server channel** — `POST /v1/sessions/{id}/runs`
streams server→client only — so an HTTP/SSE client **cannot steer** in v1. Read the
`steer` bit off the `CreateSession` capabilities echo: when present/true a gRPC
client may send `steer` frames; when absent/false the server reports `too_late`
(and, over gRPC, auto-promotes the text to a fresh follow-up run). A unary
`POST /v1/sessions/{id}/steer` endpoint is a possible cheap follow-up (mirroring
`approve`/`cancel`), deferred.
instruction into an *in-flight* run, drained at the next turn boundary) is
available on both wires: over bidi gRPC it rides the `Converse` stream as a
`steer` / `steer_cancel` request arm; over HTTP it is a pair of unary endpoints
(mirroring `approve`/`cancel` — the prompt SSE stream itself is server→client
only). Gate the affordance on the `steer` bit of the `POST /v1/sessions`
capabilities echo: when absent/false the server's steer knob is off and every
steer reports `too_late`.

Enqueue a steer while the prompt SSE stream is still open (a **second**
connection, like `approve`):

```console
$ curl -s -X POST http://127.0.0.1:8081/v1/sessions/<id>/steer \
-d '{"text":"also check b.go","message_id":"m-1"}'
{"outcome":"accepted","message_id":"m-1"}
```

The `outcome` is the engine's authoritative verdict, verbatim:

- `"accepted"` — the text parked in the run's (empty) steer inbox; the run
drains it at the next turn boundary as an ordinary user message.
- `"appended"` — the inbox already held a pending steer; the text merged into
that pending bundle (they drain together as one message).
- `"too_late"` — the run is already terminal, no run is live, or the server's
steer capability is off. **The text was NOT enqueued and is NOT promoted**:
unlike the gRPC `steer` frame (whose text has no other home once the ack is
sent, so the server auto-promotes it into a fresh follow-up run), the HTTP
caller still holds the text — re-send it as an ordinary
`POST /v1/sessions/{id}/prompt` follow-up. Never drop it silently.

`text` is required (`400`); an unknown session is `404`; the body is bounded
like `/prompt` (oversized → `413`).

`message_id` is an optional client-minted correlation id: it is echoed verbatim
on the response (the ack-side echo), and when the steer lands the run's SSE
stream emits a `steer` event — the drain echo, carrying the committed text plus
the `message_id` **watermark** (the latest contributing send's id of the bundle
that drained; sends up to and including it drained, later sends are still
pending), so a client correlates positionally, never by text-match.

Retract a still-pending (un-drained) steer:

```console
$ curl -s -X POST http://127.0.0.1:8081/v1/sessions/<id>/cancel-steer
{"outcome":"retracted"}
```

`"retracted"` means the pending bundle was cleared before it drained (no `steer`
echo will land); `"none_pending"` means there was nothing to retract — the steer
already drained at a boundary (it is ordinary recorded history now) or no run is
live. Unknown session → `404`.

### ACP over stdio (`mecated acp`)

Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/server/classification.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ var serviceAccessTable = map[string]ClassificationEntry{
"ApprovePlan": {KindCallerOwned, "authorizes the session before resolving the parked plan ask"},
"Cancel": {KindCallerOwned, "authorizes via GetSession before signalling the in-flight run"},
"CancelChild": {KindCallerOwned, "authorizes the PARENT session via GetSession before reaching into its child registry"},
"Steer": {KindCallerOwned, "authorizes via GetSession before enqueueing to the live run's inbox or promoting through StartRunContent"},
"Steer": {KindCallerOwned, "authorizes via GetSession before enqueueing to the live run's steer inbox, promoting through StartRunContent when no live run can take it"},
"CancelSteer": {KindCallerOwned, "authorizes via GetSession before reaching into the live run's steer inbox"},
"Persist": {KindCallerOwned, "authorizes via GetSession before consulting the live run registry"},

Expand Down
7 changes: 7 additions & 0 deletions internal/adapter/server/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ const (
// mcp_servers HERE" — which is the only useful form of the answer, since a
// build-only claim would be true on a daemon that refuses every such request.
FeatureMCPServersOnCreate = "mcp_servers_on_create"

// FeatureHTTPSteer is the unary HTTP steer pair (ADR 0252):
// POST /v1/sessions/{id}/steer (text + multimodal parts +
// expected_run_id strict mode, promoted follow-ups relayed as SSE) and
// POST /v1/sessions/{id}/cancel-steer.
FeatureHTTPSteer = "http_steer"
)

// FeatureScope is what the DEPLOYMENT permits, as distinct from what the build
Expand Down Expand Up @@ -95,6 +101,7 @@ type FeatureScope struct {
// repeated string and a client must treat it as a set, but a stable order keeps
// diffs and golden fixtures readable.
var allFeatures = []string{
FeatureHTTPSteer,
FeatureMCPServersOnCreate,
FeatureServerInfo,
FeatureWatchSessionEvents,
Expand Down
23 changes: 4 additions & 19 deletions internal/adapter/server/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,25 +573,10 @@ func (h *HarnessServer) sendEvent(rl *runRelay, ev session.Event) {
return // log-only event: consumed by the durable log, not relayed to the client wire
}
proto := toProto(ev)
if ev.Type == session.EvSteer && proto.GetSteer() != nil {
// The EvSteer drain echo echoes the client-minted message_id of the
// Steer frame that parked this text: the engine inbox carries text only,
// so the id lives at the Service's wire-correlation FIFO — popped here
// positionally (the TAIL). An unmatched echo (an id-less steer) rides
// with "".
id := h.svc.LookupSteerMessageID(rl.id)
if id == "" {
// The correlation FAILED: the echo carries "" and the client cannot
// match it to the frame it sent (the queue can stall — the exact
// symptom this WARN exists to make visible). No session.Event owns a
// correlation miss, so it goes to diagnostics, text clamped to a prefix.
h.svc.Diagnostics().Log(rl.logCtx, port.LevelWarn, "steer echo uncorrelated (no message_id for drained text)", "session", string(rl.id), "text_prefix", valid(firstRunes(ev.Steer.Text, 40)))
} else {
h.svc.Diagnostics().Log(rl.logCtx, port.LevelInfo, "steer drain echo correlated",
"session", string(rl.id), "message_id", id, "text_len", len(ev.Steer.Text))
}
proto.GetSteer().MessageId = valid(id)
}
// The EvSteer drain-echo message_id stamp + its correlation diagnostics live
// in the ONE shared Service.stampSteerEcho (the HTTP SSE relay calls the
// same helper) — a non-steer event is a no-op inside it.
h.svc.stampSteerEcho(rl.logCtx, rl.id, ev, proto)
if err := rl.snd.Send(&mecatlv1.ConverseResponse{Event: proto}); err != nil {
rl.sendErr = err
}
Expand Down
Loading
Loading