Skip to content

feat(studio): chat — sessions, streamed runs, controls, and the chat workspace - #1756

Merged
samuv merged 1 commit into
plan/studio-chatfrom
studio/chat
Sep 23, 2026
Merged

samuv merged 1 commit into
plan/studio-chatfrom
studio/chat

Conversation

@samuv

@samuv samuv commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

What lands

  • BFF: session inventory and lifecycle (paged list with inspect-only filtering and relayed capabilities, create with mode / model / effort / tool access, detail with cumulative usage, rename, delete, mode, compaction, fork, clear, transcript); runs, activity replay, and retries as SSE carrying Studio's own union, unknown SDK kinds forwarded flagged and raw; cancel, steer, and permission verdicts addressed to the exact durable run (409 stale_run_control otherwise). Mutations, the two POST streams included, sit behind the CSRF and session gates. The bootstrap's stub stream schema is replaced.
  • Web: the chat workspace (sidebar, transcript reducer, composer with images, approvals, steering, model and effort menus, folders, queue, diffs, code highlighting, voice input) plus the shortcuts provider it depends on; browser persistence under studio.* keys; Chats is the nav's first item and / redirects to it.
  • The README states what Studio now ships, without weakening its early-access warning; the image keeps org.stacklok.mecatl.studio.stability=early-access. A first usable feature is not a stability promise.

Bounded, resumable activity replay

Implements the amended contract (see the plan layer below). apps/server/src/mecatl/chat.ts takes an ActivityRequest (cursor, replayMax, signal) and yields each frame beside the durable cursor it projects; it counts only phase: "replay" envelopes against the bound, so a long live turn is never truncated. apps/server/src/routes/chat.ts reads Last-Event-ID or resumeFrom, pulls the first delivery before any SSE header is committed so an unusable cursor is an ordinary 400 instead of an error frame inside a 200, writes each cursor as the SSE id, and gates concurrent streams per session with 429 plus Retry-After. The browser handles the new run.truncated frame by loading the authoritative transcript; the reducer treats it as a stream boundary, not transcript content.

The resume query parameter is named resumeFrom rather than cursor on purpose: the client generator reads cursor as a pagination marker and emits an infinite-query wrapper that cannot type a Server-Sent Events response.

Six new proofs cover it: bounded replay with the truncation cursor, exact resume with no duplication, the durable-gap stop, live events staying unbounded, header-versus-query precedence with id stamping, and the invalid-cursor plus stream-cap behaviour.

Amended after review

A review finding: this layer hard-coded an empty model list, and both the draft composer and the session controls only disabled their model and effort menu when that list was empty, so the control was visible and permanently dead while the plan advertised model selection. The inventory comes from GET /api/v1/settings/runtime, which the settings plan owns, so this layer has no source for one.

Both call sites now render the control only when an inventory exists, so this layer ships no false affordance; the settings layer supplies the inventory and the control appears. The plan's narrative no longer claims model selection for this layer, records the deferral under Human decisions with its rationale, lists it under Out of scope, and adds AC4.7 for the guard.

Verification

55 server and 98 web Vitest proofs (24 ACs, 0 missing under task ac-trace); task studio:check, task docs, task lint:actions pass; smoke against a spawned mecated --mock: create, six-frame SSE run, transcript, rename, stale control.

Further amendments

Implements the amended chat contract (see the plan layer below):

  • Activity route. It commits its headers at the replay boundary through a delivery-less attach marker and sends a keep-alive comment every 15 seconds. It follows an already-aborted request signal and refuses the SDK's start/now sentinels and oversized cursors.

  • Adapter. It maps a daemon activity_gap error to a gap truncation. run.error messages go through sanitizeUpstreamDetail, and the base64 regex is replaced by a linear check.

  • Web. Each stream carries the session it belongs to. Switching chats detaches it, and controls, approvals, and the queue address the owning session. A bound truncation reattaches from its cursor with a truthful notice. The stored UI scale is applied at startup.

  • Docs. docs/architecture.md lists run.truncated in the event union. The README no longer lists features this layer does not ship.

  • Server. The resume, bound, and boundary rules follow the amended contract. The replay-bound test now counts envelope pulls, and a new test covers a request that was already disconnected. A body-limit middleware rejects oversized requests before JSON validation.

  • Web. Side threads reattach on truncation through the shared run-stream.ts helpers. The client treats run.started as idempotent, and a durable event from another run moves the controls to that run. URL-only images render as links. groupModels has one implementation, and each panel stores its own width.

  • Plan status. This layer's diff moves its acceptance plan to landed (header and index), per the plan lifecycle; the transition is authoritative only when this PR merges, and task ac-trace-strict now gates the plan.

Re-verified on the rebased stack: task studio:check passes (80 server + 121 web Vitest), and task ac-trace-strict passes with every Studio plan gated as landed (141 ACs, 0 failures).

Development stage

  • Plan / Interface
  • Implementation
  • Combined
  • Spike / Routine / Cleanup

Contract linkage

  • Work classification: Bounded
  • Classification rationale: adds the chat product surface (/api/v1/sessions… and the run SSE union) inside the boundary ADR 0351 fixed; no new durable architecture decision
  • Decision record: None — see the plan layer
  • Human waiver of spine: Yes — the directing human chose stacked (gh stack) delivery for the whole Studio series: nothing merges until every layer is reviewed, so the plan's approval-by-merge event is deferred to the end (recorded in each plan's Delivery line), and each implementation layer records the exact plan commit it built against.
  • Acceptance plan: docs/acceptance/studio-chat.md
  • Human decisions resolved and recorded: Yes
  • Plan / Interface PR: docs(plan): Mecatl Studio chat acceptance plan #1755
  • Approved commit baseline: 310fc4b681f679033a801a7162ad342d2d71f4d0 (the plan layer below; not yet merged — stacking exception above)
  • Combined/exemption rationale: N/A

Interface conformance

  • Matches the plan's contract.

Issue relationship

Relates to #1747 (parent #1736; stack #1766). Non-closing: the step completes when both of its layers merge.

Type of change

  • Behavioral/interface plan
  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation/process

Test plan

Baseline checks

  • Acceptance-plan checker
  • Linting (task lint)
  • Offline test suite (task test)
  • Offline demo (go run ./cmd/mecademo)
  • Markdown changed: docs generation/link checks (task docs)

Studio gates (this workspace is not covered by task lint / task test, which are Go-only): task studio:check (Biome, typecheck, Vitest, generated-drift), task ac-trace, task lint:actions — all pass; see Verification above.

User-facing change

Studio gains chat: sessions, streamed runs with images, approvals, steering, model and effort selection.

Special notes for reviewers

Part of a fourteen-layer stack; review bottom-up. Nothing merges until the whole series is reviewed.

@samuv
samuv added this pull request to stack #1766 September 22, 2026 08:01
@samuv samuv self-assigned this Sep 22, 2026
@samuv samuv mentioned this pull request Sep 22, 2026
4 of 15 tasks
@samuv

samuv commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Review finding addressed: model and effort selection was permanently unavailable

Confirmed and fixed. This layer hard-coded const models: ComposerModelOption[] = [], and both call sites only disabled the menu when that list was empty, so the control was visible and could never work while the plan claimed model selection as a chat capability.

The inventory comes from GET /api/v1/settings/runtime, which the settings plan owns, so this layer genuinely has no source for one. Taking the second option you offered: the control is deferred rather than stubbed.

  • chat-composer.tsx and chat-session-controls.tsx render ModelEffortMenu only when models.length > 0, so there is no dead affordance here.
  • The plan no longer claims model or effort selection for this layer. The deferral is recorded under Human decisions with its rationale, listed under Out of scope, and covered by a new AC4.7 for the guard.
  • The settings layer (feat(studio): settings — runtime inventory, storage health, settings workspace #1762) supplies the inventory, which is where the control appears and where its own plan already recorded the re-wiring.

@samuv

samuv commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up addressed: the mobile sheet and the side-thread panel

You were right that the first fix was partial. Two more call sites rendered the control with an empty inventory:

  • MobileConfigurationSheet in chat-composer.tsx showed a disabled Model row beside a fully enabled Effort row, which was worse than the desktop case: the user could set an effort for a model they could not choose.
  • side-thread-panel.tsx disabled its ModelEffortMenu on an empty inventory instead of omitting it.

Both now follow the same rule as the desktop menu and the session controls: the control is rendered only when a deployment model inventory exists. The mobile sheet omits the Model and Effort rows together, since effort without a selectable model is the same false affordance in a different shape.

There are now four call sites and no remaining models.length === 0 disable anywhere in the feature; grep for it in apps/web/src returns nothing outside tests. The commit message names all four so the next reader does not have to re-derive the list.

…workspace

Implementation layer of docs/acceptance/studio-chat.md, built against
plan commit 310fc4b.

BFF: the session inventory and lifecycle routes under /api/v1/sessions
(paged list with inspect-only filtering and relayed capabilities,
create with mode / model / effort / toolAccess, detail with cumulative
usage, rename, delete, mode, compaction, fork, clear, transcript); runs,
activity replay, and retries as Server-Sent Events carrying Studio's
run.started / run.event / run.error union, with unknown SDK kinds
forwarded flagged and raw; cancel, steer, and permission verdicts
addressed to the exact durable run (409 stale_run_control otherwise).
Every mutation, the two POST streams included, sits behind the
bootstrap's CSRF and session gates. The bootstrap's stub stream schema
is replaced by the chat union.

Activity replay is BOUNDED and RESUMABLE, answering a blocking review
finding against an unconditional replay of a session's whole durable
history: every run.event frame carries its durable cursor in the SSE id
field, a request resumes exactly after Last-Event-ID (or the resumeFrom
query parameter, the header winning), a no-cursor replay stops after
STUDIO_ACTIVITY_REPLAY_MAX durable replay events with a run.truncated
frame pointing the client at the authoritative transcript, a durable gap
ends the stream instead of crossing the hole, an unusable cursor is 400
invalid_cursor decided before any SSE header is committed, and
STUDIO_ACTIVITY_MAX_STREAMS bounds concurrent streams per session with
429. Live events stay unbounded so a long turn streams in full.

Web: the chat workspace ported from the prototype (sidebar, transcript
reducer, composer with images, approvals, steering, model and effort
menus, folders, queue, diffs, code highlighting, voice input) plus the
shortcuts provider it depends on. The model and reasoning-effort control
renders only when a deployment model inventory exists, at every one of
its four call sites (the desktop menu, the session controls, the mobile
chat-options sheet, and the side-thread panel), so this layer ships no
dead affordance: the inventory arrives with the settings surface, which
owns GET /settings/runtime. browser persistence moved to the
studio.* key namespace; Chats is the nav's first item and / redirects
to it.

55 server and 98 web Vitest proofs; smoke-tested end to end against a
spawned mecated --mock. The experimental image label and README banner
are removed now that Studio has a usable feature.

Tracking: #1736

Signed-off-by: Samuele Verzi <samu@stacklok.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant