diff --git a/.claude/rules/grpc.md b/.claude/rules/grpc.md index 0d84d1f..5958b5f 100644 --- a/.claude/rules/grpc.md +++ b/.claude/rules/grpc.md @@ -17,18 +17,20 @@ dictated by the specs and MUST match exactly. |---|---|---|---| | Model | `StreamCompletion` | server-streaming + cancellation | `docs/specifications/model/README.md#transport--lifecycle` (explicitly *not* bidi) | | Tool | `Invoke` | server-streaming | `docs/specifications/tool/protocol.md` | -| Frontend | `Attach` | **bidirectional** streaming | `docs/specifications/frontend/frontend-protocol.md` | -| Widget | `Attach` | server-streaming only | `docs/specifications/frontend/widget-protocol.md` | +| Frontend | *(no Attach)* | category triple only; I/O via callback channel | `docs/specifications/frontend/frontend-protocol.md` | +| Widget | *(no Attach)* | category triple only; metadata via callback | `docs/specifications/frontend/widget-protocol.md` | | Slashcommand | `Invoke` | server-streaming | `docs/specifications/slashcommand/protocol.md` (same shape as Tool's `Invoke` — a direct-invoke command is a tool-shaped operation) | | Kernel callback | `RunSession`, `CountTokens` | bidirectional (go-plugin's native plugin→kernel channel) | `docs/specifications/kernel-callbacks.md` | | Kernel callback | `Subscribe` | server-streaming | `docs/specifications/kernel-callbacks.md#subscribe` (event-bus fan-out; see `docs/specifications/event-bus.md`) | | Kernel callback | `ReadEvents` | server-streaming | `docs/specifications/kernel-callbacks.md#readevents` | -Frontend `Attach` and the kernel-callback channel are the **only** two -genuinely bidirectional RPCs in the whole protocol — that channel's own -`Subscribe`/`ReadEvents` additions are server-streaming, not a second bidi -RPC on it. Do not default a new RPC to bidi streaming because it "might need -it later" — pick the narrowest shape the spec calls for. +The kernel-callback channel is the **only** genuinely bidirectional +transport surface in the whole protocol (the plugin is the gRPC client on +that connection). Application RPCs on it — including `Subscribe`, +`ReadEvents`, and `StreamDeltas` — are unary or server-streaming, not bidi. +Frontend and widget no longer expose an `Attach` stream. Do not default a +new RPC to bidi streaming because it "might need it later" — pick the +narrowest shape the spec calls for. - **A backend that has no real streaming to do still implements the streaming RPC shape** and emits exactly one terminal message. Do not add a diff --git a/.dev/README.md b/.dev/README.md new file mode 100644 index 0000000..20b38e0 --- /dev/null +++ b/.dev/README.md @@ -0,0 +1,44 @@ +# Local dev run + +Scaffolding for running the kernel against **locally built plugin binaries** instead of published releases. Nothing here is part of the build; it exists so the frontend-hosted path can actually be exercised end to end. + +## How it resolves + +Two files, two different roles: + +| File | Role | Found via | +|---|---|---| +| `agent/config.hcl` | Global config — carries `dev_overrides` | `$XDG_CONFIG_HOME/agent/config.hcl` | +| `agent.hcl` | Project config — `required_providers`, settings, profile | `-config` | + +The global config is normally per-user at `~/.config/agent/config.hcl`, and its path is not a flag — `internal/xdg.Resolve` computes it. But it reads `XDG_CONFIG_HOME`, so pointing that at this directory makes the whole run repo-local without any code change. + +`dev_overrides` is the mechanism that matters: a name listed there resolves straight to a binary on disk, skipping registry resolution, the lock file, and checksum verification entirely. Identity comes from the plugin's own `Describe` RPC, which is what `dev_overrides` exists for. + +## Run it + +Build the plugins first, then: + +```sh +XDG_CONFIG_HOME=$PWD/.dev \ +XDG_STATE_HOME=$PWD/.dev/state \ +XDG_CACHE_HOME=$PWD/.dev/cache \ +XDG_DATA_HOME=$PWD/.dev/data \ + ./bin/agent -config .dev/agent.hcl 2>.dev/agent.log +``` + +All four XDG vars are redirected so a dev run writes no sessions, caches, or data into your real home. + +**No `-prompt`.** That is what selects frontend-hosted mode: the kernel brings every provider up, installs the frontend host, and waits while the frontend drives sessions over the callback channel. It exits when the frontend's subprocess does — quitting the TUI ends the kernel — or on Ctrl-C. + +With `-prompt` you get the old behavior instead: one non-interactive session, final message on stdout, exit. Useful for checking a model provider without involving a frontend. + +## `2>.dev/agent.log` is not optional + +The frontend owns the terminal — it opens `/dev/tty` directly, precisely because under go-plugin stdin/stdout belong to the handshake. The kernel logs to stderr. Point both at the same terminal and kernel log lines paint straight over the UI. Redirect stderr, then `tail -f .dev/agent.log` in a second terminal. + +## Before it will work + +Edit `agent/config.hcl` — the paths there are absolute and machine-specific — and set a real model id in `agent.hcl`. + +A session cannot start without a model provider: `internal/session` resolves the profile's model chain against the live catalog and fails with `ErrNoDefaultModel` when nothing answers. A frontend alone brings the kernel up and gives you a UI, but the first submitted prompt will fail. diff --git a/.dev/agent.hcl b/.dev/agent.hcl new file mode 100644 index 0000000..b1d600d --- /dev/null +++ b/.dev/agent.hcl @@ -0,0 +1,60 @@ +# Project config for a local dev run. Not the repository's own config — +# this repo is the kernel, not a project — so it lives under .dev/ and is +# passed explicitly with -config. +# +# Both providers resolve through dev_overrides in .dev/agent/config.hcl, so +# the source/version below are never fetched. They still have to be +# declared: required_providers is what creates the local name a +# dev_overrides entry, a provider block, and an agent_profile all refer to. + +required_providers { + tui = { + source = "github.com/pluggableharness/plugin-frontend-tui" + version = "~> 0.1" + } + xai = { + source = "github.com/pluggableharness/plugin-provider-xai" + version = "~> 0.1" + } +} + +settings { + default_frontend = "tui" + log_level = "debug" + telemetry = false +} + +provider "xai" { + # Pinned off deliberately, and the reason is a real protocol gap rather + # than a preference. + # + # pluginhost fetches GetCapabilities at bring-up step 5, BEFORE Configure + # at step 8 — it has to, because the ConfigSchema that decoding the + # provider block needs arrives with that advertisement. So a provider + # whose roster depends on Configure-time state advertises its built-in + # roster and only then swaps in the remote one, leaving the kernel + # holding a catalog the provider itself no longer honors: the kernel + # offers "grok-4-3" while StreamCompletion rejects it as unknown, since + # the live catalog names it "grok-4.3". + # + # Capabilities are never re-fetched (providercatalog is built once, in + # bringUp), so nothing reconciles them later. Until a refresh path + # exists, keeping the roster static is what makes advertisement and + # enforcement agree. + fetch_models = "false" +} + +agent_profile "default" { + model { + primary { + # In the compiled-in roster AND a valid API id, which is what makes + # this the one combination that works. xAI resolves it server-side + # to grok-4.3 and says so on the wire — the remap StreamMetadata's + # actual_model now captures. Note the roster's other ids are + # hyphenated (grok-4-3) and the API rejects them outright; the live + # catalog spells that model grok-4.3. + provider = "xai" + id = "grok-4" + } + } +} diff --git a/.dev/agent/config.hcl b/.dev/agent/config.hcl new file mode 100644 index 0000000..0e0a588 --- /dev/null +++ b/.dev/agent/config.hcl @@ -0,0 +1,21 @@ +# Global config for a local dev run — $XDG_CONFIG_HOME/agent/config.hcl, +# with XDG_CONFIG_HOME pointed at .dev/ (see .dev/README.md). +# +# dev_overrides maps a required_providers local name to a binary on disk. +# The kernel uses that binary directly and skips the whole registry path: +# no version constraint, no lock-file row, no checksum. Identity comes from +# the plugin's own Describe RPC instead, which is exactly what +# dev_overrides exists for +# (docs/specifications/configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry). +# +# Paths must be absolute — edit these to match your checkouts. + +dev_overrides { + tui = "/home/steven/pluggableharness/plugin-frontend-tui/bin/frontend_tui" + + # A session cannot start without a model provider: internal/session + # resolves the profile's model chain against the live catalog and fails + # with ErrNoDefaultModel if nothing answers. This one authenticates from + # ~/.grok/auth.json, so a `grok login` session is enough — no API key. + xai = "/home/steven/pluggableharness/plugin-provider-xai/bin/provider_xai" +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f07d7c1..14e5c6e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -20,7 +20,6 @@ # --- Tier 1: top-level surfaces --------------------------------------------- /api/ @pluggableharness/protocol-maintainers /pkg/ @pluggableharness/sdk-maintainers -/examples/ @pluggableharness/sdk-maintainers /internal/ @pluggableharness/kernel-maintainers /cmd/ @pluggableharness/kernel-maintainers /docs/ @pluggableharness/docs-maintainers @@ -35,14 +34,13 @@ # The plugin-author surface. Third parties compile against it. /docs/first-party/ @pluggableharness/sdk-maintainers -# The terminal shell: different skill set, and the TTY-ownership constraint -# that follows from a frontend being a go-plugin subprocess. +# The frontend-facing protocol surface. The reference terminal shell itself +# now lives in pluggableharness/plugin-frontend-tui, so what is left here is +# the contract every frontend builds against, not one implementation. /docs/first-party/frontends/ @pluggableharness/frontend-maintainers /pkg/frontend/ @pluggableharness/frontend-maintainers @pluggableharness/sdk-maintainers /pkg/widget/ @pluggableharness/frontend-maintainers @pluggableharness/sdk-maintainers /pkg/render/ @pluggableharness/frontend-maintainers @pluggableharness/sdk-maintainers -/internal/tui/ @pluggableharness/frontend-maintainers -/cmd/tui/ @pluggableharness/frontend-maintainers # release.yml hands whatever a v* tag points at to GoReleaser. /.github/workflows/release.yml @pluggableharness/release-engineering diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5fb1623..543cbb1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,16 +19,13 @@ version: 2 updates: # Every Go module in the tree needs its own entry — Dependabot resolves a - # directory, not a repository. examples/provider is a separate module by - # design (it exists to prove pkg/ builds from outside the main one), so a - # bump to the root go.mod leaves its pinned versions untouched. That is - # not cosmetic: CI runs `go mod tidy` there and fails on any diff, so - # omitting it turns every future root-module bump into a red main. - # Same failure mode the composite-action note below describes. + # directory, not a repository. The root module is currently the only one; + # adding a second module anywhere in the tree means adding its directory + # here too, or a root-module bump silently leaves its pinned versions + # untouched. Same failure mode the composite-action note below describes. - package-ecosystem: "gomod" directories: - "/" - - "/examples/provider" schedule: interval: "weekly" cooldown: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcc5657..350e353 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,24 +96,6 @@ jobs: exit 1 fi - # Builds the example provider from its own module, against this - # commit via a replace directive. This is the only check that proves - # pkg/ is genuinely usable from outside the main module: the - # depguard rule on internal/anthropic only simulates that isolation, - # and a simulation cannot catch an unexported type leaking through - # an exported signature. - - name: Build the standalone example provider - working-directory: examples/provider - run: | - go mod tidy - git diff --exit-code -- go.mod go.sum - go build ./... - go vet ./... - # Also runs the example's own conformance test, which proves - # pkg/model/modeltest is reachable and usable by a third party — - # the premise of shipping a conformance suite in pkg/ at all. - go test ./... - # --------------------------------------------------------------------------- # test — race-enabled tests on every platform we release for. # diff --git a/.gitignore b/.gitignore index ee2197a..73b21a0 100644 --- a/.gitignore +++ b/.gitignore @@ -31,11 +31,15 @@ __debug_bin* # the working directory, which the extension patterns above do not catch on # Linux or macOS. These are the ones this repo can actually produce, listed # by name because a broad pattern here would ignore real source files. +# +# Both shapes are covered on purpose: `go build ./cmd/agent` from the repo +# root lands at /agent, while `cd cmd/agent && go build` lands beside the +# main package. The second used to be uncovered, which is exactly how a +# 21 MB providerconform reached a commit. /agent -/anthropic -/tui /providerconform -examples/*/agent-example-provider +/cmd/agent/agent +/cmd/providerconform/providerconform # --- Go: test, coverage & profiling artifacts -------------------------------- *.out @@ -118,3 +122,12 @@ ehthumbs.db Desktop.ini $RECYCLE.BIN/ *.lnk + +# --- Local dev run ----------------------------------------------------------- +# .dev/ holds committed scaffolding for running the kernel against locally +# built plugins (.dev/README.md). The config files are committed as +# templates; everything the run *writes* is not. +/.dev/state/ +/.dev/cache/ +/.dev/data/ +/.dev/*.log diff --git a/.golangci.yml b/.golangci.yml index c26bbd5..145d6af 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -22,33 +22,6 @@ linters: - unconvert - unparam - wastedassign - - depguard - settings: - depguard: - rules: - # internal/anthropic is the reference model-provider plugin. It - # runs out of process and is meant to be indistinguishable from a - # plugin a third party could write against pkg/ alone, so it may - # import only pkg/... and the standard library. Reaching into any - # other internal/ package would make it a privileged in-tree - # shortcut rather than a proof that the published SDK is - # sufficient — and the whole point of building it was to find out - # whether pkg/ really is. - # - # Scoped to non-test files: the integration tier legitimately - # imports internal/pluginruntime to launch the built binary the - # way the kernel does, which is the kernel-launches-plugin - # direction, not a dependency of the plugin itself. - anthropic-plugin-isolation: - list-mode: lax - files: - - "**/internal/anthropic/**" - - "!$test" - allow: - - github.com/pluggableharness/agent/internal/anthropic - deny: - - pkg: github.com/pluggableharness/agent/internal - desc: internal/anthropic is a reference plugin — it may import only pkg/... and the standard library, never another internal/ package (see internal/anthropic/CLAUDE.md) exclusions: rules: - path: _test\.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7923fc6..82cb246 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,13 +1,8 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json version: 2 -# Two binaries: the kernel and the reference Anthropic model provider. -# Both are stamped through -ldflags rather than reading their identity -# from a file at runtime, matching how internal/pluginhost's integration -# fixture is built (`-X main.fixtureName=...`) — a plugin's Describe RPC -# has to answer from the running process, since a dev_overrides binary has -# no lock-file entry to read identity from -# (configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry). +# One binary: the kernel. Every model, tool, and frontend provider ships +# from its own repository, so nothing else is built here. builds: - id: agent main: ./cmd/agent @@ -20,19 +15,6 @@ builds: - -s -w - -X main.version={{ .Version }} - - id: anthropic - main: ./cmd/anthropic - binary: agent-provider-anthropic - env: [CGO_ENABLED=0] - goos: [linux, darwin, windows] - goarch: [amd64, arm64] - flags: [-trimpath] - ldflags: - - -s -w - # pluginVersion is what this plugin reports through Describe; the - # source stays the -X-able default in main.go for a checkout build. - - -X main.pluginVersion={{ .Version }} - archives: - formats: [tar.gz] format_overrides: diff --git a/CLAUDE.md b/CLAUDE.md index 5e56b31..28fca36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +19,9 @@ An AI coding harness built as a Go microkernel: the kernel owns plugin lifecycle ## Current state -Kernel-side packages in `internal/` and the `pkg/` SDK are real, tested Go. Three `cmd/` binaries exist: `agent` (the kernel, currently non-interactive — no REPL), `anthropic` (the reference model-provider plugin, and the template for any new plugin binary), and `tui` (the reference terminal shell, currently driven by a scripted demo source because no kernel-side frontend-attach path exists yet). Most other plugin categories exist only as spec. Implementation is spec-first: before writing code, confirm the relevant spec exists and is settled; if it has open questions bearing on the task, raise them instead of coding against an assumption. Don't start new implementation work without being asked. +Kernel-side packages in `internal/` and the `pkg/` SDK are real, tested Go. Two `cmd/` binaries exist: `agent` (the kernel) and `providerconform` (a CLI driving `pkg/model/modeltest` against a built provider binary). **No plugin ships from this repository** — every model, tool, and frontend provider lives in its own repo under the same org and is consumed through `pkg/`, exactly as a third-party plugin would be. Most plugin categories still exist only as spec. Implementation is spec-first: before writing code, confirm the relevant spec exists and is settled; if it has open questions bearing on the task, raise them instead of coding against an assumption. Don't start new implementation work without being asked. -The terminal shell's design — region layout, focus model, keymap layers, and the TTY-ownership constraint that follows from a frontend being a go-plugin subprocess — is [`docs/first-party/frontends/tui.md`](docs/first-party/frontends/tui.md). It is descriptive, not normative: the protocol deliberately leaves focus, keybindings, resize, and scrollback to each frontend. +The reference terminal shell is **not in this repository**. It lives at [`pluggableharness/plugin-frontend-tui`](https://github.com/pluggableharness/plugin-frontend-tui), versioned and released on its own cadence like any other plugin, and it is where a frontend's layout, focus model, keymap, and TTY-ownership decisions are documented. None of that is protocol: the spec deliberately leaves focus, keybindings, resize, and scrollback to each frontend, so a change there is not a change here. ## Toolchain, testing, and CI diff --git a/api/pluggableharness/content/v1/types.proto b/api/pluggableharness/content/v1/types.proto index 172a225..0ab2df6 100644 --- a/api/pluggableharness/content/v1/types.proto +++ b/api/pluggableharness/content/v1/types.proto @@ -154,6 +154,25 @@ message ImageBlock { // The image's MIME type, e.g. "image/png". string media_type = 2; + + // How much detail the model should spend on this image, where the + // vendor exposes the choice. + // + // It is a cost control, not a rendering hint: vendors bill high-detail + // image input at a multiple of low, so a caller sending many + // screenshots for a coarse question has a real reason to say so. + // UNSPECIFIED leaves the vendor's own default. + ImageDetail detail = 3; +} + +// ImageDetail names how much resolution a model should spend on an image. +enum ImageDetail { + // The vendor's own default applies. + IMAGE_DETAIL_UNSPECIFIED = 0; + // Prefer fewer tokens over fidelity. + IMAGE_DETAIL_LOW = 1; + // Prefer fidelity over token cost. + IMAGE_DETAIL_HIGH = 2; } // ThinkingBlock is the model's extended-reasoning output, when diff --git a/api/pluggableharness/frontend/v1/errors.proto b/api/pluggableharness/frontend/v1/errors.proto index 41cf987..e3478b5 100644 --- a/api/pluggableharness/frontend/v1/errors.proto +++ b/api/pluggableharness/frontend/v1/errors.proto @@ -4,23 +4,27 @@ package pluggableharness.frontend.v1; option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; -// The frontend provider protocol's error taxonomy. +// The frontend provider protocol's error taxonomy. Session-lifecycle and +// input errors that used to ride Attach as in-band ServerEvent.error now +// surface as gRPC statuses on the corresponding KernelCallbackService +// RPCs; this taxonomy remains for Configure and any residual +// frontend-local failures (e.g. a RenderTree this frontend cannot paint). -// FrontendErrorCategory classifies a FrontendError, per the error taxonomy -// in frontend.md §7. +// FrontendErrorCategory classifies a FrontendError. enum FrontendErrorCategory { // Zero value. Never valid for a real error; its presence on the wire // means a caller forgot to set the field. FRONTEND_ERROR_CATEGORY_UNSPECIFIED = 0; - // A RenderTree or PlacedContent could not be displayed. + // A RenderTree could not be displayed. FRONTEND_ERROR_CATEGORY_RENDER_FAILED = 1; - // A ClientEvent was malformed or referenced an unknown/already-resolved - // id (e.g. a plan_decision or interactive_response naming an item that - // was already resolved by another attached frontend, per frontend.md - // §3.3's first-response-wins arbitration). - FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT = 2; - // A PlacedContent named a Region this frontend cannot honor. - FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED = 3; + // A request was malformed or referenced an unknown/already-resolved + // id (e.g. ResolvePlanDecision naming an item already resolved by + // another attached frontend — first-response-wins arbitration). + FRONTEND_ERROR_CATEGORY_INVALID_REQUEST = 2; + // Field 3 was REGION_UNSUPPORTED. Placement regions were retired; + // reserved so the number is never reused. + reserved 3; + reserved "FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED"; // An error that does not fit any other category. FRONTEND_ERROR_CATEGORY_UNKNOWN = 4; // AttachSession, ResumeSession, DetachSession, or ListSessions' @@ -30,26 +34,24 @@ enum FrontendErrorCategory { // CreateSession failed — an invalid profile, or an unusable // working_directory. FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED = 6; - // A session-mutating control event targeted a session currently + // A session-mutating control targeted a session currently // SESSION_STATUS_RUNNING in a way that conflicts with that (reserved - // for future session-mutating control events; no current variant in - // this protocol revision triggers it, since DetachSession is always - // safe on a running session). + // for future control events; DetachSession is always safe on a running + // session). FRONTEND_ERROR_CATEGORY_SESSION_BUSY = 7; // ResumeSession named a session file with a newer PRAGMA user_version - // than this kernel understands (state-backend.md §"Schema migration"). + // than this kernel understands (state-backend.md schema migration). FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW = 8; - // A user_message (or any other new-turn-inducing event) targeted a - // session attached replay-only per frontend.md §"Resume and re-open - // semantics" — a bound-exhausted (error_max_*) or FAILED session - // resumed via ResumeSession. Distinct from SESSION_BUSY: the session - // isn't running, it's terminal and specifically barred from new turns. + // SubmitInput (or any other new-turn-inducing RPC) targeted a session + // attached replay-only — a bound-exhausted (error_max_*) or FAILED + // session resumed via ResumeSession. Distinct from SESSION_BUSY: the + // session is not running, it is terminal and specifically barred from + // new turns. FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY = 9; } -// FrontendError is the structured error type for this category, per -// frontend.md §7. Carried in ServerEvent.Error and in the structured detail -// of a gRPC status returned from Configure. +// FrontendError is the structured error type for this category. Carried +// in the structured detail of a gRPC status. message FrontendError { // The error's category. FrontendErrorCategory category = 1; diff --git a/api/pluggableharness/frontend/v1/events.proto b/api/pluggableharness/frontend/v1/events.proto deleted file mode 100644 index 9f3bc95..0000000 --- a/api/pluggableharness/frontend/v1/events.proto +++ /dev/null @@ -1,469 +0,0 @@ -syntax = "proto3"; - -package pluggableharness.frontend.v1; - -import "google/protobuf/struct.proto"; -import "pluggableharness/common/v1/types.proto"; -import "pluggableharness/content/v1/types.proto"; -import "pluggableharness/frontend/v1/errors.proto"; -import "pluggableharness/model/v1/types.proto"; -import "pluggableharness/plan/v1/types.proto"; -import "pluggableharness/render/v1/types.proto"; -import "pluggableharness/session/v1/types.proto"; -import "pluggableharness/slashcommand/v1/types.proto"; - -option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; - -// Occurrence-shaped messages flowing over the Attach bidirectional stream. - -// ServerEvent is one message the kernel sends to an attached frontend over -// the single multiplexed Attach stream, described in frontend.md §3.2. -// Exactly one variant is set. -message ServerEvent { - // The session this event is scoped to. Set for every session-scoped - // variant (stream_delta .. session_status_update); empty for the one - // connection-level variant, session_list. - string session_id = 100; - - // Correlates a response to the ClientEvent control message that - // triggered it, echoing that message's own request_id - // (hello/create_session/attach_session/resume_session/detach_session/ - // list_sessions all carry one). Set on session_created, session_attached, - // backfill_complete, session_detached, session_list, and on error when - // it answers one of those control requests. Unset for ordinary live - // session events that were not triggered by a specific client request - // (stream_delta, render, plan_ready, ...). - optional string request_id = 101; - - // Field 13 is deliberately withheld, not merely unused: it would have - // been session_deleted, for a SessionDeleted variant answering a - // DeleteSession control event. Sessions are protected from every - // plugin, frontends included — pruning is a kernel CLI + operator-at- - // keyboard action only (frontend.md §"No session deletion"); no - // frontend-triggered deletion mechanism exists in this protocol. The - // number stays reserved, not reused, so it can never be accidentally - // repurposed for something unrelated if deletion is ever reconsidered. - reserved 13; - reserved "session_deleted"; - - oneof event { - StreamDelta stream_delta = 1; - Render render = 2; - PermissionRequest permission_request = 3; - PlanReady plan_ready = 4; - InteractiveRequest interactive_request = 5; - SessionTreeUpdate session_tree_update = 6; - Error error = 7; - // Acknowledges a ClientEvent.CreateSession, carrying the new - // session's info. The kernel auto-attaches the creating stream to - // this session (frontend.md §"Session lifecycle"). - SessionCreated session_created = 8; - // Acknowledges a ClientEvent.AttachSession or ResumeSession, carrying - // the session's current info. Opens the backfill batch: the - // replayed `render` events that follow, bracketed by the eventual - // backfill_complete (frontend.md §"Backfill"). - SessionAttached session_attached = 9; - // The done-marker closing a backfill batch opened by session_attached - // above. Live events with sequence > last_sequence follow this. - // Unicast to the attaching stream only, never broadcast. - BackfillComplete backfill_complete = 10; - // Acknowledges a ClientEvent.DetachSession. - SessionDetached session_detached = 11; - // Answers a ClientEvent.ListSessions. Connection-scoped — session_id - // above is empty for this variant. - SessionList session_list = 12; - // The profile-scoped aggregate slash-command registry across every - // provider loaded for this session, sent on session_attached and - // again whenever the registry changes. - SlashCommandRegistry slash_command_registry = 14; - // Per-turn token/cost accounting and context-budget pressure. - UsageUpdate usage_update = 15; - // This session's OWN lifecycle status. Deliberately distinct from - // session_tree_update above, which reports a CHILD session's status - // — this variant is the attached session's own transition (e.g. - // RUNNING -> COMPLETED, or a bound-exhausted re-open per - // frontend.md §"Resume and re-open semantics"). - SessionStatusUpdate session_status_update = 16; - } - - // StreamDelta is the fast path for incremental text display, skipping a - // full Render() round trip. frontend.md §3.2. - message StreamDelta { - // Identifies which displayed element this delta appends to (e.g. a - // RenderNode id from a prior Render), for correlating consecutive - // deltas into one growing piece of text. - string target_id = 1; - // The incremental text to append. - string text = 2; - } - - // Render carries one placed RenderTree to Paint. frontend.md §3.2. - message Render { - // The content, its target region, and its replace/append behavior. - pluggableharness.render.v1.PlacedContent content = 1; - } - - // PermissionRequest asks the user to resolve an agent-loop.md §5.2 `ask` - // decision: the kernel blocks this plan item's apply until a - // ClientEvent.plan_decision resolves it. - message PermissionRequest { - // The plan item awaiting a decision. - pluggableharness.plan.v1.PlanItem plan_item = 1; - } - - // PlanReady announces a complete plan for display, e.g. before execution - // begins or after a replan. - message PlanReady { - // The plan to display. - pluggableharness.plan.v1.Plan plan = 1; - } - - // InteractiveRequest corresponds to a tool.md §2.1 - // TOOL_KIND_INTERACTIVE call. A frontend MUST render this in the - // REGION_OVERLAY region (frontend.md §2), the same visual treatment as an - // ordinary `ask` prompt. - message InteractiveRequest { - // Identifies the pending interactive tool call, echoed back in the - // resolving ClientEvent.interactive_response. - string call_id = 1; - // The tool being invoked (tool.md §2.1 ToolSchema.name). - string tool_name = 2; - // The prompt to render. - pluggableharness.render.v1.RenderTree prompt = 3; - } - - // SessionTreeUpdate reports a change in a nested sub-session's lifecycle - // (e.g. a RunSession-spawned child), so a frontend can keep a - // SubSessionNode's displayed status current. Reports a CHILD session's - // status — for the attached session's OWN status, see - // SessionStatusUpdate below, a deliberately distinct variant. - message SessionTreeUpdate { - // The parent session's id. - string parent_session_id = 1; - // The child session's id. - string child_session_id = 2; - // The child session's current status. - pluggableharness.session.v1.SessionStatus status = 3; - } - - // Error carries a structured, non-fatal frontend error for display. - message Error { - // The error's category and message. - FrontendError error = 1; - } - - // SessionCreated acknowledges a successful ClientEvent.CreateSession. - message SessionCreated { - // The newly created session's info. - pluggableharness.session.v1.SessionInfo info = 1; - } - - // SessionAttached acknowledges a successful ClientEvent.AttachSession or - // ResumeSession, and opens that session's backfill batch. - message SessionAttached { - // The attached session's current info. - pluggableharness.session.v1.SessionInfo info = 1; - } - - // BackfillComplete is the done-marker closing a backfill batch, per - // frontend.md §"Backfill". Unicast to the attaching stream only. - message BackfillComplete { - // The persisted sequence number of the last event replayed in this - // batch. Live events with sequence > last_sequence follow. - int64 last_sequence = 1; - } - - // SessionDetached acknowledges a successful ClientEvent.DetachSession. - message SessionDetached {} - - // SessionList answers a ClientEvent.ListSessions. - message SessionList { - // The matching sessions, most-recently-started first. - repeated pluggableharness.session.v1.SessionInfo sessions = 1; - } - - // SlashCommandRegistry is the profile-scoped aggregate of every loaded - // provider's declared slash commands for this session, per - // frontend.md §"Slash commands" and specifications/slashcommand/. Two - // separate lists rather than one, since the two kinds are declared by - // different provider categories and dispatched differently — a - // frontend distinguishes them the same way it renders them (a `/name` - // lookup checks both), but the kernel keeps their namespaces distinct - // per-list while still enforcing one combined collision check across - // both at config-load time. - message SlashCommandRegistry { - // Every registered direct-invoke command, declared by a - // slashcommand.v1 provider's own GetCapabilities response. - // Name-collision-checked (jointly with prompt_expansion_commands - // below) at config-load time (frontend.md §"Slash commands"). - repeated pluggableharness.slashcommand.v1.SlashCommandSpec direct_invoke_commands = 1; - - // Every registered prompt-expansion command, declared by any - // category's own capability response. Name-collision-checked - // (jointly with direct_invoke_commands above) at config-load time. - repeated pluggableharness.common.v1.PromptExpansionSpec prompt_expansion_commands = 2; - } - - // UsageUpdate carries one turn's token/cost accounting and the - // session's running totals, for a context-budget indicator or similar. - message UsageUpdate { - // This turn's token accounting. - pluggableharness.model.v1.Usage turn = 1; - // The session's running total spend in USD, mirroring - // session.v1.SessionInfo.cost_usd. - double cumulative_cost_usd = 2; - // The session's running total token count against `effective_ceiling` - // below — the pair a context-budget indicator divides to get - // pressure (e.g. "51,204 / 200,000"). - int64 used_tokens = 3; - // The usable context budget this session's turns are measured - // against (model.v1.ModelTarget.effective_ceiling). - int64 effective_ceiling = 4; - } - - // SessionStatusUpdate reports the attached session's OWN lifecycle - // status transition. Distinct from SessionTreeUpdate above, which - // reports a CHILD session's status. - message SessionStatusUpdate { - // The session's new status. - pluggableharness.session.v1.SessionStatus status = 1; - } -} - -// ClientDecision is the user's resolution of a pending PermissionRequest, -// per agent-loop.md §5.2's `ask` decision. -enum ClientDecision { - // Zero value. Never valid for a real decision; its presence on the wire - // means a caller forgot to set the field. - CLIENT_DECISION_UNSPECIFIED = 0; - // The user approved the plan item as proposed (or as corrected, see - // ClientEvent.PlanDecision.corrected_input). - CLIENT_DECISION_ALLOW = 1; - // The user rejected the plan item. - CLIENT_DECISION_DENY = 2; -} - -// PlanDecisionScope is how durably a ClientEvent.PlanDecision applies, -// beyond just the one PlanItem it names. Orthogonal to ClientDecision: -// decision says allow/deny, scope says how long that verdict is -// remembered. agent-loop/plan-apply-gate.md's "PlanDecisionScope -// semantics" documents evaluation order and the ALWAYS persistence -// obligation. -enum PlanDecisionScope { - // Zero value. Never valid for a real decision; its presence on the wire - // means a caller forgot to set the field. - PLAN_DECISION_SCOPE_UNSPECIFIED = 0; - // Applies to this PlanItem only. The default a frontend SHOULD send - // when the user hasn't explicitly asked for a broader scope. - PLAN_DECISION_SCOPE_ONCE = 1; - // Applies to the rest of this session, for calls matching the same - // provider/tool_name (and, where policy's match schema supports it, - // narrower criteria) — an in-memory, session-lifetime rule, not - // written to agent.hcl or any persisted policy store. - PLAN_DECISION_SCOPE_SESSION = 2; - // The kernel persists this as policy, applying beyond this session to - // future sessions under the same profile. Requires kernel-side policy - // persistence (agent-loop/plan-apply-gate.md#plandecisionscope-semantics) - // — a frontend MUST NOT assume ALWAYS is honored merely because it was - // sent; a kernel that cannot persist policy MUST reject it as a - // distinct error rather than silently downgrading to SESSION or ONCE. - PLAN_DECISION_SCOPE_ALWAYS = 3; -} - -// ClientEvent is one message a frontend sends to the kernel over the -// single multiplexed Attach stream, described in frontend.md §3.2. -// Exactly one variant is set. -message ClientEvent { - // The session this event is scoped to. REQUIRED for every - // session-scoped variant (user_message, slash_command, plan_decision, - // interactive_response, action_trigger, interrupt) — the kernel MUST - // reject one of these arriving with an empty session_id as - // FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT. Empty for the - // connection-level control variants (hello, create_session, - // attach_session, resume_session, detach_session, list_sessions), which - // either don't yet have a bound session or operate across sessions — - // attach_session/resume_session instead name the target session inside - // their own nested message. - string session_id = 100; - - oneof event { - UserMessage user_message = 1; - SlashCommand slash_command = 2; - PlanDecision plan_decision = 3; - InteractiveResponse interactive_response = 4; - ActionTrigger action_trigger = 5; - Interrupt interrupt = 6; - // MAY be sent as the first message on a newly opened Attach stream; - // asserts the protocol version only. Not required to bind any - // session — that happens via the variants below. - Hello hello = 7; - // Creates a new session and auto-attaches this stream to it. - CreateSession create_session = 8; - // Subscribes an existing (possibly live) session onto this stream, - // triggering a backfill replay (frontend.md §"Backfill"). - AttachSession attach_session = 9; - // Attaches a historical session for continuation or replay, per - // frontend.md §"Resume and re-open semantics" — a COMPLETED or - // CANCELLED session MAY be re-opened to RUNNING for new turns; a - // bound-exhausted (error_max_*) or FAILED session attaches - // replay-only and rejects any subsequent user_message for it. - ResumeSession resume_session = 10; - // Unsubscribes a session from this stream without affecting the - // session itself. - DetachSession detach_session = 11; - // Requests the connection-scoped session summary list. - ListSessions list_sessions = 12; - } - - // UserMessage is ordinary chat input from the user. - message UserMessage { - // Field 1 was a bare `string text`, superseded by `content` below — - // block-structured input (image paste/attachments) had no entry - // point under a plain string. A frontend sending plain typed text - // sends a single TextBlock; `content` MAY carry more (e.g. a - // TextBlock plus an ImageBlock for a pasted screenshot, gated by the - // target model's supports_vision same as any other ImageBlock). - reserved 1; - reserved "text"; - // The message content, in emission order. MUST contain at least one - // block. - repeated pluggableharness.content.v1.ContentBlock content = 2; - } - - // SlashCommand is a dispatched slash command invocation (frontend.md §5). - message SlashCommand { - // The command name, without its leading slash. - string name = 1; - // The raw argument string following the command name. - string args = 2; - } - - // PlanDecision resolves a pending ServerEvent.PermissionRequest. - message PlanDecision { - // The plan item being resolved, matching - // ServerEvent.PermissionRequest.plan_item's id. - string plan_item_id = 1; - // The user's decision. - ClientDecision decision = 2; - // When present, a user-edited replacement for the plan item's tool - // input. The kernel MUST re-validate this against the tool's - // input_schema (tool.md §6); an invalid correction is rejected as a - // distinct error, not silently coerced. - optional google.protobuf.Struct corrected_input = 3; - // How durably this decision applies beyond this one item — see - // PlanDecisionScope. - PlanDecisionScope scope = 4; - } - - // InteractiveResponse resolves a pending ServerEvent.InteractiveRequest. - message InteractiveResponse { - // Correlates to ServerEvent.InteractiveRequest.call_id. - string call_id = 1; - // The user's response, becoming the interactive call's - // ToolResult.payload. - google.protobuf.Struct response = 2; - } - - // ActionTrigger is dispatched when a user activates a RenderNode's - // ActionNode (render/v1/types.proto's ActionNode, frontend.md §5.1). The kernel - // handles this identically to a direct_invoke slash command: the normal - // Invoke/plan-apply pipeline including policy evaluation, with no model - // turn. - message ActionTrigger { - // The originating ActionNode's id (render/v1/types.proto's ActionNode.id). - string node_id = 1; - // The tool operation to invoke (tool.md §2 ToolSchema.name), echoed - // unchanged from the originating ActionNode.tool_name. - string tool_name = 2; - // The arguments to invoke it with, echoed unchanged from the - // originating ActionNode.args. - google.protobuf.Struct args = 3; - // The declared name of the tool provider plugin `tool_name` belongs - // to, echoed unchanged from the originating ActionNode.provider - // (render/v1/types.proto) — tool_name is only unique per provider. - string provider = 4; - } - - // Interrupt carries no fields; it signals that the user wants to - // interrupt the current turn. - message Interrupt {} - - // Hello MAY be sent as the first ClientEvent on a newly opened Attach - // stream, to assert the protocol version. Binding a session happens via - // the session-control variants, not via Hello. - message Hello { - // The protocol version this frontend was built against. - uint32 protocol_version = 1; - } - - // CreateSession creates a new session and auto-attaches this stream to - // it, per frontend.md §"Session lifecycle". Answered by - // ServerEvent.SessionCreated. - message CreateSession { - // Client-generated, echoed back on ServerEvent.request_id. - string request_id = 1; - // The agent.hcl profile to create the session under. Absent means - // the kernel's configured default profile. - optional string profile = 2; - // An initial user message to seed the session with, submitted as the - // first turn once the session is created. Absent creates an empty - // session awaiting the first ordinary user_message. - optional string initial_prompt = 3; - // The session's working directory. Absent means the kernel's own - // working directory at creation time. - optional string working_directory = 4; - } - - // AttachSession subscribes an existing session (live or terminal) onto - // this stream, triggering a backfill replay. Answered by - // ServerEvent.SessionAttached, bracketing a replay batch closed by - // ServerEvent.BackfillComplete. - message AttachSession { - // Client-generated, echoed back on ServerEvent.request_id. - string request_id = 1; - // The session to attach. - string session_id = 2; - } - - // ResumeSession attaches a historical (possibly terminal) session, per - // frontend.md §"Resume and re-open semantics". A COMPLETED or - // CANCELLED session MAY be re-opened to SESSION_STATUS_RUNNING for new - // turns; a bound-exhausted (error_max_*) or FAILED session attaches - // replay-only — the kernel MUST reject a subsequent user_message - // against it with FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY. Answered - // identically to AttachSession: SessionAttached bracketing a backfill - // batch. - message ResumeSession { - // Client-generated, echoed back on ServerEvent.request_id. - string request_id = 1; - // The session to resume. - string session_id = 2; - } - - // DetachSession unsubscribes a session from this stream without - // affecting the session itself — other streams attached to the same - // session, and the session's own execution, are unaffected. Answered - // by ServerEvent.SessionDetached. - message DetachSession { - // Client-generated, echoed back on ServerEvent.request_id. - string request_id = 1; - // The session to detach. - string session_id = 2; - } - - // ListSessions requests the connection-scoped session summary list. - // Answered by ServerEvent.SessionList. - message ListSessions { - // Client-generated, echoed back on ServerEvent.request_id. - string request_id = 1; - // Restricts the result to sessions in this status. Absent means no - // status filter. - optional pluggableharness.session.v1.SessionStatus status = 2; - // Restricts the result to children of this session. Absent means no - // parent filter. - optional string parent_session_id = 3; - // True: only root sessions (no parent_session_id). False: all - // sessions matching the other filters, at any depth. - bool roots_only = 4; - } -} diff --git a/api/pluggableharness/frontend/v1/service.proto b/api/pluggableharness/frontend/v1/service.proto index c1ab011..9176668 100644 --- a/api/pluggableharness/frontend/v1/service.proto +++ b/api/pluggableharness/frontend/v1/service.proto @@ -1,69 +1,36 @@ syntax = "proto3"; -// Package pluggableharness.frontend.v1 defines the frontend provider plugin protocol -// described in specifications/frontend.md §3 (Attach, ServerEvent, -// ClientEvent, ...). +// Package pluggableharness.frontend.v1 defines the frontend provider plugin +// protocol described in specifications/frontend/. A frontend owns how the +// operator sees and types — TUI, web, CLI, voice — but does not own the +// agent loop. Kernel-to-frontend traffic (state, metadata, transcript, +// token deltas) rides the kernel callback channel +// (specifications/kernel-callbacks.md); this service is only the +// standard category triple every plugin exposes. package pluggableharness.frontend.v1; -import "pluggableharness/frontend/v1/events.proto"; import "pluggableharness/frontend/v1/rpc_request.proto"; import "pluggableharness/frontend/v1/rpc_response.proto"; option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; -// FrontendService implements the frontend provider protocol described in -// specifications/frontend.md §3. +// FrontendService implements the frontend provider protocol. There is no +// Attach RPC: under go-plugin the plugin is the gRPC server, so the only +// direction that lets the kernel push streams into a frontend is the +// callback channel where the plugin is the client. Session lifecycle, +// operator input, plan/interactive resolution, metadata, and token +// deltas are all KernelCallbackService RPCs. service FrontendService { - // GetCapabilities returns this frontend's slash commands and config - // schema. Unary. frontend.md §3.1. + // GetCapabilities returns this frontend's slash commands, config + // schema, and supported hook points. Unary. rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - // Configure applies this provider's `agent.hcl` configuration, validated - // against the schema returned by GetCapabilities (configuration.md §4). - // Unary. frontend.md §3.1. + // Configure applies this provider's agent.hcl configuration, validated + // against the schema returned by GetCapabilities. rpc Configure(ConfigureRequest) returns (ConfigureResponse); - // Attach opens ONE multiplexed, connection-scoped bidirectional event - // channel between the kernel and this frontend connection — not a - // per-session stream. A frontend subscribes individual sessions onto - // this one stream via the session-control ClientEvent variants - // (create_session/attach_session/resume_session/detach_session), and - // unsubscribes the same way; connection-level operations - // (list_sessions, the aggregate slash-command registry) have a natural - // home here precisely because the stream isn't tied to one session. - // ServerEvents flow from kernel to frontend, ClientEvents flow from - // frontend to kernel, both directions live for the duration of the - // stream. Bidirectional streaming — frontend.md §"Transport", and - // (along with the kernel callback channel) one of only two genuinely - // bidirectional RPCs in this protocol series (see .claude/rules/grpc.md). - // - // Multiple frontends MAY subscribe to the same session concurrently on - // their own Attach streams (frontend.md §"Session scope"): every - // ServerEvent for a given session broadcasts identically to every - // frontend subscribed to that session, with no partitioning and no - // "primary" frontend. ClientEvents are processed in kernel arrival - // order; for ClientEvent.plan_decision and - // ClientEvent.interactive_response specifically, which name a pending - // item by id within a session, the kernel applies first-response-wins - // arbitration per session and MUST reject any later response for an - // already-resolved item with a distinct error back to its sender. - // - // buf:lint:ignore RPC_REQUEST_STANDARD_NAME - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element types are the bare "ClientEvent"/"ServerEvent" per - // frontend.md §3.2's literal spec — names used throughout this project's - // specs and rules, not just here. Neither is reused by another RPC (no - // uniqueness violation); renaming to Attach*Request/Response would only - // satisfy a style convention while discarding real spec traceability. - rpc Attach(stream ClientEvent) returns (stream ServerEvent); - // Describe reports this plugin build's own identity — {name, version, // source, category, protocol_version} — directly from the running // process, rather than the kernel inferring it from a lock-file row. - // Every one of the seven category protocols gains this identical RPC in - // this protocol revision; it exists specifically for a - // `dev_overrides`-resolved binary (configuration/lock-file.md's - // "dev_overrides and identity without a lock entry"), which has no - // provider {} lock-file entry to read identity from at all. rpc Describe(DescribeRequest) returns (DescribeResponse); } diff --git a/api/pluggableharness/frontend/v1/types.proto b/api/pluggableharness/frontend/v1/types.proto index c67aa00..e3b7eec 100644 --- a/api/pluggableharness/frontend/v1/types.proto +++ b/api/pluggableharness/frontend/v1/types.proto @@ -4,30 +4,29 @@ package pluggableharness.frontend.v1; import "pluggableharness/common/v1/types.proto"; import "pluggableharness/config/v1/types.proto"; -import "pluggableharness/render/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1"; // Domain messages for the frontend provider protocol. // FrontendCapabilities is this frontend's static self-description, returned -// by GetCapabilities (frontend.md §3.1). +// by GetCapabilities. message FrontendCapabilities { // Prompt-expansion slash commands this frontend contributes. MAY be // empty. A direct-invoke command is declared by a slashcommand.v1 // provider instead (specifications/slashcommand/), never here. repeated pluggableharness.common.v1.PromptExpansionSpec slash_commands = 1; - // This provider's `agent.hcl` configuration schema (configuration.md §4). + // This provider's agent.hcl configuration schema + // (configuration/blocks-reference.md). pluggableharness.config.v1.ConfigSchema config_schema = 2; - // Regions this frontend proactively declares it can render into. A - // complement to, not a replacement for, the reactive - // FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED error a placement the - // frontend can't honor still produces — this lets a producer route - // content preferentially without waiting to find out the hard way. - repeated pluggableharness.render.v1.Region supported_regions = 3; - // Hook points this frontend can subscribe to (agent-loop/hook-dispatch.md), - // so a mis-declared agent.hcl hook{} block naming an unsupported point - // can be rejected at config-load time rather than failing at first - // dispatch. + // Field 3 was supported_regions (Region enum). Placement regions were + // retired with the four-surface revision; reserved so the number is + // never reused. + reserved 3; + reserved "supported_regions"; + // Hook points this frontend can subscribe to + // (agent-loop/hook-dispatch.md), so a mis-declared agent.hcl hook{} + // block naming an unsupported point can be rejected at config-load + // time rather than failing at first dispatch. repeated pluggableharness.common.v1.HookPoint supported_hook_points = 4; } diff --git a/api/pluggableharness/kernel/v1/events.proto b/api/pluggableharness/kernel/v1/events.proto index 3aa8a62..3dd1d89 100644 --- a/api/pluggableharness/kernel/v1/events.proto +++ b/api/pluggableharness/kernel/v1/events.proto @@ -8,9 +8,9 @@ import "pluggableharness/kernel/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; -// Occurrence-shaped messages streamed by the two server-streaming -// KernelCallbackService RPCs: BusEvent (Subscribe) and StoredEvent -// (ReadEvents). +// Occurrence-shaped messages streamed by KernelCallbackService's +// server-streaming RPCs: BusEvent (Subscribe), StoredEvent (ReadEvents), +// and TokenDelta (StreamDeltas). // BusEvent is one event delivered to a Subscribe stream. See // kernel-callbacks.md's Subscribe and event-bus.md. @@ -70,3 +70,19 @@ message StoredEvent { // Opaque to the kernel — see EmitRequest.payload. bytes payload = 7; } + +// TokenDelta is one live incremental text fragment for the fast path, +// out-of-band with respect to the event bus (no topic matching, no +// filter evaluation, no shared subscriber queue). Per-stream FIFO only; +// not durable and never replayed — finished text arrives as RenderTrees +// via ReadEvents. See kernel-callbacks.md's StreamDeltas. +message TokenDelta { + // The session this delta belongs to. MUST be set. + string session_id = 1; + // Identifies which displayed element this delta appends to (e.g. a + // RenderNode id from a prior render), for correlating consecutive + // deltas into one growing piece of text. + string target_id = 2; + // The incremental text to append. + string text = 3; +} diff --git a/api/pluggableharness/kernel/v1/rpc_request.proto b/api/pluggableharness/kernel/v1/rpc_request.proto index ab0aabf..3fe6432 100644 --- a/api/pluggableharness/kernel/v1/rpc_request.proto +++ b/api/pluggableharness/kernel/v1/rpc_request.proto @@ -2,12 +2,16 @@ syntax = "proto3"; package pluggableharness.kernel.v1; +import "google/protobuf/struct.proto"; import "pluggableharness/common/v1/types.proto"; import "pluggableharness/content/v1/types.proto"; import "pluggableharness/kernel/v1/types.proto"; import "pluggableharness/log/v1/types.proto"; +import "pluggableharness/metadata/v1/types.proto"; import "pluggableharness/metric/v1/types.proto"; import "pluggableharness/model/v1/types.proto"; +import "pluggableharness/plan/v1/types.proto"; +import "pluggableharness/session/v1/types.proto"; import "pluggableharness/trace/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; @@ -232,3 +236,170 @@ message GetSessionRequest { // EmitRequest.session_id. string session_id = 1; } + +// GetSessionStateRequest asks for the fixed-schema "where am I" snapshot +// a frontend renders. See kernel-callbacks.md's GetSessionState. +message GetSessionStateRequest { + // The session to snapshot. MUST be set. Same one-session-only rule as + // EmitRequest.session_id for non-frontend callers; a frontend that has + // attached the session holds a grant for it. + string session_id = 1; +} + +// SubmitInputRequest submits operator input as the next turn for a +// session. See kernel-callbacks.md's SubmitInput. +message SubmitInputRequest { + // The session to submit into. MUST be set. + string session_id = 1; + // The message content, in emission order. MUST contain at least one + // block. Plain typed text is a single TextBlock; image paste adds an + // ImageBlock gated by the target model's supports_vision. + repeated pluggableharness.content.v1.ContentBlock content = 2; +} + +// ResolvePlanDecisionRequest answers a pending plan item that policy +// evaluated as ASK. See kernel-callbacks.md's ResolvePlanDecision and +// agent-loop/plan-apply-gate.md. +message ResolvePlanDecisionRequest { + // The session owning the pending plan item. MUST be set. + string session_id = 1; + // The plan item being resolved. MUST be set. + string plan_item_id = 2; + // The operator's allow/deny decision. MUST be set. + pluggableharness.plan.v1.ClientDecision decision = 3; + // When present, a operator-edited replacement for the plan item's + // tool input. The kernel MUST re-validate this against the tool's + // input_schema; an invalid correction is rejected, not silently coerced. + optional google.protobuf.Struct corrected_input = 4; + // How durably this decision applies beyond this one item. + pluggableharness.plan.v1.PlanDecisionScope scope = 5; +} + +// ResolveInteractiveRequest answers a pending interactive-kind tool call. +// See kernel-callbacks.md's ResolveInteractive. +message ResolveInteractiveRequest { + // The session owning the pending call. MUST be set. + string session_id = 1; + // Correlates to the interactive tool call's id. MUST be set. + string call_id = 2; + // The operator's response, becoming the interactive call's + // ToolResult.payload. MUST be set. + google.protobuf.Struct response = 3; +} + +// InterruptRequest cancels the running turn for a session. +message InterruptRequest { + // The session whose turn to cancel. MUST be set. + string session_id = 1; +} + +// CreateSessionRequest creates a new session and grants the calling +// frontend a subscription to it. +message CreateSessionRequest { + // The agent.hcl profile to create the session under. Absent means the + // kernel's configured default profile. + optional string profile = 1; + // An initial user message to seed the session with, submitted as the + // first turn once the session is created. Absent creates an empty + // session awaiting the first SubmitInput. + optional string initial_prompt = 2; + // The session's working directory. Absent means the kernel's own + // working directory at creation time. + optional string working_directory = 3; +} + +// AttachSessionRequest subscribes the calling frontend to an existing +// session (live or terminal) without re-opening a terminal session. +message AttachSessionRequest { + // The session to attach. MUST be set. + string session_id = 1; +} + +// ResumeSessionRequest attaches a historical session for continuation or +// replay. A COMPLETED or CANCELLED session MAY be re-opened to RUNNING +// for new turns; a bound-exhausted or FAILED session attaches +// replay-only. +message ResumeSessionRequest { + // The session to resume. MUST be set. + string session_id = 1; +} + +// DetachSessionRequest unsubscribes the calling frontend from a session +// without affecting the session itself or other attached frontends. +message DetachSessionRequest { + // The session to detach. MUST be set. + string session_id = 1; +} + +// ListSessionsRequest returns a filtered session summary list. +message ListSessionsRequest { + // Restricts the result to sessions in this status. Absent means no + // status filter. + optional pluggableharness.session.v1.SessionStatus status = 1; + // Restricts the result to children of this session. Absent means no + // parent filter. + optional string parent_session_id = 2; + // True: only root sessions (no parent_session_id). False: all sessions + // matching the other filters, at any depth. + bool roots_only = 3; +} + +// PublishMetadataRequest upserts one MetadataBlock into a session's +// metadata surface. The kernel stamps producer and liveness=LIVE. +message PublishMetadataRequest { + // The session this block belongs to. MUST be set. + string session_id = 1; + // The block to publish. id and body MUST be set; producer and liveness + // are server-derived and any client-set values are overwritten. + pluggableharness.metadata.v1.MetadataBlock block = 2; +} + +// RetractMetadataRequest marks a block DISCONNECTED and republishes it. +// The kernel never deletes the block. +message RetractMetadataRequest { + // The session owning the block. MUST be set. + string session_id = 1; + // The block id to retract. MUST be set. + string block_id = 2; +} + +// ListMetadataRequest returns every MetadataBlock currently known for a +// session — the snapshot half of snapshot-then-subscribe for the +// metadata surface. +message ListMetadataRequest { + // The session whose blocks to list. MUST be set. + string session_id = 1; +} + +// StreamDeltasRequest opens a live-only token-delta stream for one +// session. Replayed text arrives as finished RenderTrees via ReadEvents, +// never as deltas. +message StreamDeltasRequest { + // The session to stream deltas for. MUST be set. + string session_id = 1; +} + +// InvokeSlashCommandRequest dispatches a slash command against a session. +message InvokeSlashCommandRequest { + // The session to invoke against. MUST be set. + string session_id = 1; + // The command name, without its leading slash. MUST be set. + string name = 2; + // The raw argument string following the command name. + string args = 3; +} + +// TriggerActionRequest dispatches an ActionNode activation — the same +// no-model-turn Invoke/plan-apply path as a direct-invoke slash command. +message TriggerActionRequest { + // The session to invoke against. MUST be set. + string session_id = 1; + // The originating ActionNode's id. + string node_id = 2; + // The tool operation to invoke (ToolSchema.name). + string tool_name = 3; + // The arguments to invoke it with. + google.protobuf.Struct args = 4; + // The declared name of the tool provider plugin tool_name belongs to. + string provider = 5; +} diff --git a/api/pluggableharness/kernel/v1/rpc_response.proto b/api/pluggableharness/kernel/v1/rpc_response.proto index c7e7fcc..09ff0d4 100644 --- a/api/pluggableharness/kernel/v1/rpc_response.proto +++ b/api/pluggableharness/kernel/v1/rpc_response.proto @@ -5,6 +5,7 @@ package pluggableharness.kernel.v1; import "google/protobuf/struct.proto"; import "pluggableharness/content/v1/types.proto"; import "pluggableharness/log/v1/types.proto"; +import "pluggableharness/metadata/v1/types.proto"; import "pluggableharness/session/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; @@ -147,3 +148,84 @@ message GetSessionResult { // live, in-memory rationale as remaining_depth. double remaining_cost_budget_usd = 3; } + +// GetSessionStateResult carries the fixed-schema SessionState snapshot. +message GetSessionStateResult { + // The session's "where am I" state. MUST be set. + pluggableharness.session.v1.SessionState state = 1; +} + +// SubmitInputResult acknowledges a submitted turn. +message SubmitInputResult { + // The turn id assigned to this submission, for correlating subsequent + // events and deltas without relying solely on stream order. MUST be set. + string turn_id = 1; +} + +// ResolvePlanDecisionResult is empty on success. Errors surface as a +// gRPC status (e.g. already resolved, unknown plan_item_id). +message ResolvePlanDecisionResult {} + +// ResolveInteractiveResult is empty on success. +message ResolveInteractiveResult {} + +// InterruptResult is empty on success. +message InterruptResult {} + +// CreateSessionResult carries the newly created session's info. The +// calling frontend is auto-attached. +message CreateSessionResult { + // The newly created session's info. MUST be set. + pluggableharness.session.v1.SessionInfo info = 1; +} + +// AttachSessionResult carries the attached session's current info. +// History backfill is via ReadEvents, not inlined here. +message AttachSessionResult { + // The attached session's current info. MUST be set. + pluggableharness.session.v1.SessionInfo info = 1; +} + +// ResumeSessionResult carries the resumed session's current info. +message ResumeSessionResult { + // The resumed session's current info. MUST be set. + pluggableharness.session.v1.SessionInfo info = 1; +} + +// DetachSessionResult is empty on success. +message DetachSessionResult {} + +// ListSessionsResult carries the matching session summaries. +message ListSessionsResult { + // The matching sessions, most-recently-started first. + repeated pluggableharness.session.v1.SessionInfo sessions = 1; +} + +// PublishMetadataResult carries the block as stored after the kernel +// stamped producer and liveness. +message PublishMetadataResult { + // The stored block. MUST be set. + pluggableharness.metadata.v1.MetadataBlock block = 1; +} + +// RetractMetadataResult carries the block after liveness was flipped to +// DISCONNECTED. +message RetractMetadataResult { + // The retracted block. MUST be set. + pluggableharness.metadata.v1.MetadataBlock block = 1; +} + +// ListMetadataResult carries every known block for the session. +message ListMetadataResult { + // All blocks currently in the session's metadata store, in stable + // id order. MAY be empty. + repeated pluggableharness.metadata.v1.MetadataBlock blocks = 1; +} + +// InvokeSlashCommandResult is empty on success; command output flows +// through the transcript / metadata surfaces. +message InvokeSlashCommandResult {} + +// TriggerActionResult is empty on success; action output flows through +// the transcript / plan-apply path. +message TriggerActionResult {} diff --git a/api/pluggableharness/kernel/v1/service.proto b/api/pluggableharness/kernel/v1/service.proto index e53a07d..7a70278 100644 --- a/api/pluggableharness/kernel/v1/service.proto +++ b/api/pluggableharness/kernel/v1/service.proto @@ -1,14 +1,18 @@ syntax = "proto3"; -// Package pluggableharness.kernel.v1 defines the kernel-callback service described -// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, -// Log, ExportSpans, RecordMetrics, GetTelemetryConfig, GetConfig, Publish, -// Subscribe, ReadEvents, GetSession) — the plugin-to-kernel calling -// direction every plugin category gets at handshake, the reverse of every -// other category's protocol in this series. Unlike a category plugin -// protocol, this service carries no GetCapabilities/Configure RPCs: it -// isn't something the kernel dials into a plugin, it's the connection -// every plugin subprocess is handed back to call into the kernel. +// Package pluggableharness.kernel.v1 defines the kernel-callback service +// described in specifications/kernel-callbacks.md — the plugin-to-kernel +// calling direction every plugin category gets at handshake, the reverse +// of every other category's protocol in this series. Unlike a category +// plugin protocol, this service carries no GetCapabilities/Configure +// RPCs: it is not something the kernel dials into a plugin, it is the +// connection every plugin subprocess is handed back to call into the +// kernel. +// +// Frontend state surfaces (input, state, metadata, transcript backfill, +// token deltas) also live here: under go-plugin the plugin is the gRPC +// server, so kernel-push streams and frontend-originated control both +// use this channel rather than a category Attach RPC. package pluggableharness.kernel.v1; import "pluggableharness/kernel/v1/events.proto"; @@ -18,135 +22,170 @@ import "pluggableharness/kernel/v1/rpc_response.proto"; option go_package = "github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1"; // KernelCallbackService is the plugin-to-kernel callback channel described -// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively +// in specifications/kernel-callbacks.md. hashicorp/go-plugin natively // supports bidirectional plugins, and this is that mechanism: every plugin -// subprocess, for every category defined across this series, MUST be given -// a client connection to this service at handshake time, unconditionally. -// A plugin that never calls back simply never uses it, but the channel's -// presence is not gated on category — a context provider needing -// CountTokens is just as valid a caller as a tool provider needing -// RunSession. +// subprocess, for every category, MUST be given a client connection to +// this service at handshake time, unconditionally. +// +// Application RPCs are unary or server-streaming. The connection itself is +// bidirectional at the transport layer; that is the only genuinely +// bidirectional surface in the protocol series (there is no category +// Attach stream). service KernelCallbackService { // RunSession dispatches a nested sub-agent session under a named - // agent.hcl profile. Full semantics — profile resolution, budget - // inheritance, visibility of intermediate turns — are defined in - // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 - // gives this RPC's calling contract. + // agent.hcl profile. Full semantics live in agent-loop/subagents.md. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 - // uses in its own data-type definition. Not a uniqueness violation: - // RunSessionResult is used by exactly this one RPC. rpc RunSession(RunSessionRequest) returns (RunSessionResult); - // CountTokens resolves the token-counting gap independently flagged by - // context.md §12, configuration.md §12, memory.md §13, and frontend.md - // §10: exactly one kernel-owned implementation, so that `tokens` figures - // produced by different providers stay mutually comparable and additive - // for configuration.md §6's budget-sum arithmetic. See - // kernel-callbacks.md §2 for the resolution algorithm and §3 for the - // single documented fallback formula. + // CountTokens resolves the token-counting gap: exactly one kernel-owned + // implementation so tokens figures stay mutually comparable. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "CountTokensResult", the exact name kernel-callbacks.md - // §2 uses. Not a uniqueness violation: used by exactly this one RPC. rpc CountTokens(CountTokensRequest) returns (CountTokensResult); // Emit is how a plugin persists anything into the session's state - // backend. The kernel is the state backend's sole writer - // (state-backend.md §3) — a plugin never opens or writes the sqlite file - // directly; it calls Emit and the kernel performs the actual write, - // assigning the ordering-authoritative sequence number and the stable - // event id itself. See kernel-callbacks.md §4. + // backend. The kernel is the sole writer. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "EmitResult", the exact name kernel-callbacks.md §4 - // uses. Not a uniqueness violation: used by exactly this one RPC. rpc Emit(EmitRequest) returns (EmitResult); // Log carries a plugin's own log output into the kernel's centralized - // logging, so it doesn't vanish into an unread subprocess stderr. - // Unlike Emit, a Log call is not tied to an active session — a plugin - // MAY call Log before any session exists (process startup, or from - // within Configure) or after one has ended (during shutdown). See - // kernel-callbacks.md §5. + // logging. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "LogResult", the exact name kernel-callbacks.md §5 - // uses. Not a uniqueness violation: used by exactly this one RPC. rpc Log(LogRequest) returns (LogResult); - // ExportSpans relays a batch of a plugin's own completed trace spans to - // the kernel, which forwards them to the operator's configured - // collector essentially unchanged. This reverses an earlier - // direct-per-process-OTLP-export design — see - // specifications/observability.md#the-relay-model for why. + // ExportSpans relays a batch of a plugin's own completed trace spans. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "ExportSpansResult", used by exactly this one RPC. rpc ExportSpans(ExportSpansRequest) returns (ExportSpansResult); - // RecordMetrics relays a batch of metric observations. Unlike - // ExportSpans, this is not a transparent relay: the kernel records each - // observation against its own instrument and bounds the attribute key - // set before it reaches any exporter. See - // specifications/observability.md#the-tracing-metrics-asymmetry. + // RecordMetrics relays a batch of metric observations (bounded + // attributes; not a transparent relay). // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RecordMetricsResult", used by exactly this one RPC. rpc RecordMetrics(RecordMetricsRequest) returns (RecordMetricsResult); - // GetTelemetryConfig answers whether tracing/metrics/logs are enabled - // and at what level/ratio, so a plugin doesn't have to guess from its - // own environment. A plugin SHOULD call this once at startup and cache - // the result — see specifications/observability.md#gettelemetryconfig-caching. + // GetTelemetryConfig answers whether tracing/metrics/logs are enabled. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetTelemetryConfigResult", used by exactly this one - // RPC. rpc GetTelemetryConfig(GetTelemetryConfigRequest) returns (GetTelemetryConfigResult); // GetConfig returns the calling plugin's own already-decoded agent.hcl - // configuration — the same shape Configure received. See - // kernel-callbacks.md's GetConfig for the secret-echo MUST NOT rule this - // implies. + // configuration. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetConfigResult", used by exactly this one RPC. rpc GetConfig(GetConfigRequest) returns (GetConfigResult); - // Publish emits one event onto the ephemeral, best-effort, cross-plugin - // event bus, distinct from Emit's durable per-session log and from - // hook dispatch's synchronous, agent.hcl-declared subscriber chain. See - // specifications/event-bus.md. + // Publish emits one event onto the ephemeral event bus. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "PublishResult", used by exactly this one RPC. rpc Publish(PublishRequest) returns (PublishResult); - // Subscribe opens a server-streaming subscription to the event bus, - // filtered by topic. See specifications/event-bus.md#filter-grammar and - // #backpressure for why the kernel may unilaterally close this stream. + // Subscribe opens a server-streaming subscription to the event bus. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is "BusEvent", naming the streamed domain concept - // rather than the RPC, the same convention model.md §4's StreamEvent - // and widget.md's WidgetUpdate already use. rpc Subscribe(SubscribeRequest) returns (stream BusEvent); // ReadEvents reads back the calling plugin's own session's persisted - // event log, ordered by sequence — never by wall-clock time - // (.claude/rules/determinism.md). + // event log, ordered by sequence. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is "StoredEvent", the same "name the domain - // concept" convention as Subscribe's BusEvent above. rpc ReadEvents(ReadEventsRequest) returns (stream StoredEvent); // GetSession returns the calling plugin's own session's metadata plus - // its live, in-memory budget rollups. + // its live budget rollups. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetSessionResult", used by exactly this one RPC. rpc GetSession(GetSessionRequest) returns (GetSessionResult); + + // GetSessionState returns the fixed-schema "where am I" snapshot for + // one session. Pair with Subscribe on topic kernel.state for deltas. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc GetSessionState(GetSessionStateRequest) returns (GetSessionStateResult); + + // SubmitInput submits operator input as the next turn. Returns the + // assigned turn_id for correlation. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc SubmitInput(SubmitInputRequest) returns (SubmitInputResult); + + // ResolvePlanDecision answers a pending plan item (policy ASK). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc ResolvePlanDecision(ResolvePlanDecisionRequest) returns (ResolvePlanDecisionResult); + + // ResolveInteractive answers a pending interactive-kind tool call. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc ResolveInteractive(ResolveInteractiveRequest) returns (ResolveInteractiveResult); + + // Interrupt cancels the running turn for a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc Interrupt(InterruptRequest) returns (InterruptResult); + + // CreateSession creates a new session and auto-attaches the caller. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc CreateSession(CreateSessionRequest) returns (CreateSessionResult); + + // AttachSession subscribes the caller to an existing session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc AttachSession(AttachSessionRequest) returns (AttachSessionResult); + + // ResumeSession attaches a historical session for continuation or + // replay-only. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc ResumeSession(ResumeSessionRequest) returns (ResumeSessionResult); + + // DetachSession unsubscribes the caller from a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc DetachSession(DetachSessionRequest) returns (DetachSessionResult); + + // ListSessions returns a filtered session summary list. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResult); + + // PublishMetadata upserts a MetadataBlock (producer and liveness + // server-stamped). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc PublishMetadata(PublishMetadataRequest) returns (PublishMetadataResult); + + // RetractMetadata flips a block to DISCONNECTED and republishes; never + // deletes. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc RetractMetadata(RetractMetadataRequest) returns (RetractMetadataResult); + + // ListMetadata returns every known MetadataBlock for a session + // (snapshot half of snapshot-then-subscribe). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc ListMetadata(ListMetadataRequest) returns (ListMetadataResult); + + // StreamDeltas is the live-only token fast path: server-streaming on + // this channel, out-of-band with respect to the event bus. The kernel + // does not batch; frontends coalesce to their own refresh. + // + // buf:lint:ignore RPC_REQUEST_STANDARD_NAME + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc StreamDeltas(StreamDeltasRequest) returns (stream TokenDelta); + + // InvokeSlashCommand dispatches a slash command against a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc InvokeSlashCommand(InvokeSlashCommandRequest) returns (InvokeSlashCommandResult); + + // TriggerAction dispatches an ActionNode activation (no model turn). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + rpc TriggerAction(TriggerActionRequest) returns (TriggerActionResult); } diff --git a/api/pluggableharness/metadata/v1/types.proto b/api/pluggableharness/metadata/v1/types.proto new file mode 100644 index 0000000..ac0a3b1 --- /dev/null +++ b/api/pluggableharness/metadata/v1/types.proto @@ -0,0 +1,137 @@ +syntax = "proto3"; + +// Package pluggableharness.metadata.v1 defines the typed metadata-block +// vocabulary plugins publish and frontends render — the "Metadata" surface +// of the four frontend state surfaces (input, state, metadata, transcript) +// described in specifications/frontend/. A block never carries a color, a +// width, or a position: Tone is a closed token scale the frontend maps to +// whatever it has (including a screen reader saying "warning"), and the +// closed oneof body is the only set of kinds every conforming frontend is +// required to render. +package pluggableharness.metadata.v1; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "pluggableharness/common/v1/types.proto"; + +option go_package = "github.com/pluggableharness/agent/pkg/metadata/proto/v1;metadatav1"; + +// Tone is a closed token scale for a MetadataBlock's presentation intent. +// Frontends map each token to their own vocabulary (ANSI color, CSS class, +// spoken label); a block never names a color value. +enum Tone { + // Zero value. Never valid for a real block; its presence means a caller + // forgot to set the field. + TONE_UNSPECIFIED = 0; + // Ordinary, unemphasized content. + TONE_NEUTRAL = 1; + // Informational emphasis without implying success or failure. + TONE_INFO = 2; + // A successful or healthy condition. + TONE_SUCCESS = 3; + // A cautionary condition that is not yet a failure. + TONE_WARNING = 4; + // A failure or high-severity condition. + TONE_DANGER = 5; +} + +// Liveness reports whether the producing plugin is still connected. +// The kernel never deletes a block: on publisher exit or RetractMetadata +// it flips liveness to DISCONNECTED and republishes; the frontend decides +// whether that means gray, drop, or "plugin gone." +enum Liveness { + // Zero value. Never valid for a real block. + LIVENESS_UNSPECIFIED = 0; + // The producing plugin is still live and owns this block. + LIVENESS_LIVE = 1; + // The producing plugin has exited or explicitly retracted the block. + LIVENESS_DISCONNECTED = 2; +} + +// MetadataBlock is one keyed, typed contribution to a session's metadata +// surface. Identified by id within a session; upserted by PublishMetadata +// and never deleted by the kernel. +message MetadataBlock { + // Stable id within the session. The publishing plugin chooses it; a + // second PublishMetadata with the same id upserts. + string id = 1; + + // The plugin that currently owns this block. Server-derived from the + // authenticated callback connection on PublishMetadata — a client MUST + // NOT supply this; the kernel overwrites any client-set value. + pluggableharness.common.v1.ProducerRef producer = 2; + + // Ordering/eviction hint for space-constrained frontends. Higher wins. + // Unset (zero) means declaration order among equal-priority peers. + int32 priority = 3; + + // Presentation intent token. MUST be set to a non-UNSPECIFIED value. + Tone tone = 4; + + // Whether the producer is still connected. MUST be set. + Liveness liveness = 5; + + // The session this block belongs to. MUST be set. Carried on the block + // (rather than only on the publish request) so a bus subscriber can + // filter without a second lookup. + string session_id = 6; + + // Exactly one body variant is set. The set is closed on purpose: five + // kinds surface missing shapes faster than an open type would, and a + // closed set is what guarantees every frontend can render every block. + oneof body { + KeyValue key_value = 10; + Progress progress = 11; + Status status = 12; + ItemList item_list = 13; + Timer timer = 14; + } +} + +// KeyValue is a short labeled value, e.g. "branch: main". +message KeyValue { + // The field label shown to the operator. + string key = 1; + // The field value shown to the operator. + string value = 2; +} + +// Progress is a determinate or indeterminate progress indicator. +message Progress { + // Short label describing what is in progress. + string label = 1; + // Completed units. Meaningful when total is set and > 0. + int64 completed = 2; + // Total units. Absent or zero means indeterminate progress. + optional int64 total = 3; +} + +// Status is a single status line with an optional detail. +message Status { + // The primary status text. + string text = 1; + // Optional secondary detail. + optional string detail = 2; +} + +// ItemList is an ordered list of short text items. +message ItemList { + // Optional list title. + optional string title = 1; + // The items, in display order. + repeated string items = 2; +} + +// Timer is a wall-clock duration or deadline the frontend can render as +// a ticking clock or countdown. +message Timer { + // Optional label for what the timer measures. + optional string label = 1; + // When the timed interval started. MUST be set for elapsed display. + google.protobuf.Timestamp started_at = 2; + // Optional absolute deadline; when set, frontends MAY render a + // countdown instead of (or in addition to) elapsed time. + optional google.protobuf.Timestamp deadline = 3; + // Optional fixed duration the timer represents when no deadline is set. + optional google.protobuf.Duration duration = 4; +} diff --git a/api/pluggableharness/model/v1/events.proto b/api/pluggableharness/model/v1/events.proto index c4ec378..c1a07c6 100644 --- a/api/pluggableharness/model/v1/events.proto +++ b/api/pluggableharness/model/v1/events.proto @@ -36,6 +36,10 @@ message StreamEvent { RedactedThinking redacted_thinking = 10; // The vendor has accepted the request and named it. StreamStart stream_start = 11; + // Non-content facts about how the vendor is serving this request. + StreamMetadata metadata = 12; + // The vendor is interposing on this request. + SafetyNotice safety_notice = 13; } // StreamStart carries the vendor's own identifier for this request, as @@ -52,6 +56,93 @@ message StreamEvent { // `request-id` header, an OpenAI `x-request-id`). Opaque to the // kernel: logged and surfaced, never parsed. string provider_request_id = 1; + + // Every other handle this request is known by, keyed by the + // vendor's own name for it ("x-request-id", "response_id", + // "cf-ray"). + // + // One id is not enough in practice: vendors log the same request + // under several, and a support conversation asks for whichever one + // that vendor's own tooling indexes. provider_request_id stays the + // single canonical handle; this carries the rest rather than forcing + // an adapter to choose which to discard. + // + // The kernel MUST serialize this with sorted keys wherever it + // reaches a persisted payload (.claude/rules/determinism.md). + map correlation_ids = 2; + } + + // StreamMetadata carries non-content facts about how the vendor is + // serving this request: which model actually answered, which build, + // which tier, and whatever budget state the response headers exposed. + // + // Separate from StreamStart because these arrive on a different + // schedule. StreamStart is emitted once when the vendor accepts the + // request; metadata may not be knowable until headers land, may + // change mid-stream, and MAY be emitted more than once — a later event + // supersedes an earlier one field by field, and an absent field means + // "no new information", never "cleared". + // + // This is not a block boundary. It carries no content, so a kernel + // accumulating a message MUST NOT close an open text or thinking block + // on receiving one; doing so would split a run of deltas that a vendor + // happened to interrupt with a late header. + message StreamMetadata { + // The model that actually served this completion, when it differs + // from the requested StreamCompletionRequest.model_id — vendors + // remap for safety routing, capacity, and deprecation + // (`grok-4` resolving to `grok-4.3`). + // + // Load-bearing for trust, not just for ops. A vendor silently + // serving a different model is how an operator experiences "it got + // worse today" with nothing to point at; dropping the fact makes + // the regression unattributable and, worse, makes the kernel's own + // cost computation cite pricing for a model that never ran. + optional string actual_model = 1; + + // The vendor's opaque identifier for the backend build serving this + // request (OpenAI's `system_fingerprint`). Never parsed. + optional string system_fingerprint = 2; + + // The service or speed tier this request was served at, where the + // vendor exposes tiers that differ in latency or price. + optional string service_tier = 3; + + // Budget state as of this point in the stream, when the vendor + // publishes it in response headers rather than in the terminal usage + // payload. + // + // The same RateLimitSnapshot shape Usage carries. Reported here it + // is visible while a long completion is still running, which is the + // whole point: a limit an operator learns about only after the turn + // that exhausted it has already stranded them. + repeated RateLimitSnapshot rate_limits = 4; + + // The context window the vendor says applies to this request, when + // it reports one that supersedes the roster's static figure + // (xAI's `x-grok-context-window`). + optional int64 live_context_window = 5; + + // The maximum output token count the vendor says applies to this + // request, on the same terms as live_context_window. + optional int64 live_max_output_tokens = 6; + + // The vendor's current model-catalog version, when a response + // advertises one (xAI's `x-models-etag`). A value differing from the + // one the loaded roster was built from means the catalog moved. + optional string catalog_etag = 7; + + // Vendor-defined metadata with no typed field above. Opaque to the + // kernel, which stores and surfaces it without interpretation. + // + // The kernel MUST serialize this with sorted keys wherever it + // reaches a persisted payload (.claude/rules/determinism.md). + map attrs = 8; + + // A handle to this turn's vendor-side state, for vendors that accept + // an incremental continuation on the next request. The kernel passes + // it back as StreamCompletionRequest.sticky_turn_token. + optional string sticky_turn_token = 9; } // TextDelta carries one incremental fragment of assistant text output. @@ -67,6 +158,72 @@ message StreamEvent { message ThinkingDelta { // The reasoning-text fragment. string text = 1; + + // Which reasoning stream this fragment belongs to, for vendors that + // emit a readable summary alongside (or instead of) raw reasoning. + // + // UNSPECIFIED means the vendor draws no distinction, which is the + // correct reading for every provider written before this field and + // the reason it is not a required field. + ThinkingChannel channel = 2; + + // Which reasoning part this fragment belongs to, where a vendor + // emits several in parallel. Fragments sharing a part_index within a + // channel are one block; absent means a single part. + // + // Unlike tool calls, which correlate by an explicit id, reasoning + // fragments have historically been correlated by adjacency alone — + // this makes a vendor's own part structure representable without + // changing that default. + optional int32 part_index = 3; + } + + // ThinkingChannel distinguishes a vendor's reasoning streams. + enum ThinkingChannel { + // The vendor draws no distinction between reasoning streams. + THINKING_CHANNEL_UNSPECIFIED = 0; + // The model's raw reasoning output. + THINKING_CHANNEL_CONTENT = 1; + // A vendor-generated readable summary of the reasoning, which is + // typically what a frontend should show when both are present. + THINKING_CHANNEL_SUMMARY = 2; + } + + // SafetyNotice reports that the vendor is interposing on this request: + // holding output for review, applying a moderation decision, or + // requiring an account challenge before continuing. + // + // It carries no content and is not a block boundary. A kernel that does + // not understand a given kind MUST ignore the event rather than failing + // the turn — this exists so a frontend can explain a stall, and an + // unexplained stall is strictly worse than an unrecognized notice. + message SafetyNotice { + // What the vendor is doing. + SafetyKind kind = 1; + + // A human-readable explanation, where the vendor supplies one worth + // showing. Never synthesized. + optional string message = 2; + + // Vendor-defined detail with no typed field. The kernel MUST + // serialize this with sorted keys wherever it reaches a persisted + // payload (.claude/rules/determinism.md). + map attrs = 3; + } + + // SafetyKind names the vendor interventions a SafetyNotice reports. + enum SafetyKind { + // Zero value. Never valid on a real notice. + SAFETY_KIND_UNSPECIFIED = 0; + // Output is being buffered for review before release, so a stall is + // expected and is not a hang. + SAFETY_KIND_BUFFERING = 1; + // A moderation decision was applied to this request. + SAFETY_KIND_MODERATION = 2; + // The account must complete a challenge before the request can + // proceed. The kernel cannot satisfy this itself; surfacing it is + // what lets an operator go and do so. + SAFETY_KIND_VERIFICATION_REQUIRED = 3; } // ThinkingSignature carries the vendor's opaque integrity token for the @@ -115,6 +272,16 @@ message StreamEvent { // reason == STOP_REASON_STOP_SEQUENCE; MUST be omitted for every // other reason. optional string matched_stop_sequence = 2; + + // Whether the vendor reported that the model itself declared the turn + // finished, as opposed to the stream merely ending. + // + // STOP_REASON_END_TURN already covers the ordinary case; this + // separates "the model said it was done" from "nothing further + // arrived", which vendors exposing an explicit end-of-turn signal can + // distinguish and the kernel otherwise cannot. Absent means the + // vendor said nothing, not that the model failed to affirm. + optional bool model_affirmed = 3; } // Error signals the completion failed. diff --git a/api/pluggableharness/model/v1/rpc_request.proto b/api/pluggableharness/model/v1/rpc_request.proto index 19b15f8..362bc97 100644 --- a/api/pluggableharness/model/v1/rpc_request.proto +++ b/api/pluggableharness/model/v1/rpc_request.proto @@ -114,6 +114,22 @@ message StreamCompletionRequest { // revision is the fix; teaching the kernel to read this field is not. // See model/data-types.md#provider_options. optional google.protobuf.Struct provider_options = 8; + + // An opaque handle to the vendor-side state of a prior turn, for + // vendors that keep conversation state server-side and accept an + // incremental continuation instead of a full history resend (an + // OpenAI `previous_response_id`). + // + // Typed rather than left to provider_options because using it changes + // what the kernel must send: a continuation carries only the new + // messages, so `messages` above and this field are not independent. + // The kernel therefore has to know whether it is in use, which is + // exactly the "a field the kernel reads" test data-types.md applies to + // provider_options. + // + // A provider that publishes such a handle returns it on StreamMetadata; + // absent here means send the full history as normal. + optional string sticky_turn_token = 9; } // CountTokensRequest is CountTokens' request: the request whose input @@ -171,3 +187,7 @@ message RenderRequest { // schema-drift-sensitive persisted data. string schema_version = 2; } + +// GetAccountRequest is empty: a plugin serves exactly one credential, +// fixed at Configure, so there is nothing for the kernel to select. +message GetAccountRequest {} diff --git a/api/pluggableharness/model/v1/rpc_response.proto b/api/pluggableharness/model/v1/rpc_response.proto index 4b24212..931dc47 100644 --- a/api/pluggableharness/model/v1/rpc_response.proto +++ b/api/pluggableharness/model/v1/rpc_response.proto @@ -45,3 +45,13 @@ message RenderResponse { // Emit->Render->Paint pipeline, not a per-category variant. pluggableharness.render.v1.RenderTree tree = 1; } + +// GetAccountResponse carries the live account snapshot, per model.md's +// GetAccount RPC. +message GetAccountResponse { + // The account state. MUST be set on a successful response — a provider + // with nothing to report returns codes.Unimplemented rather than an + // empty snapshot, so "does not participate" stays distinguishable from + // "participates and currently knows nothing". + AccountSnapshot account = 1; +} diff --git a/api/pluggableharness/model/v1/service.proto b/api/pluggableharness/model/v1/service.proto index 6d6a341..698fb7b 100644 --- a/api/pluggableharness/model/v1/service.proto +++ b/api/pluggableharness/model/v1/service.proto @@ -84,4 +84,29 @@ service ModelService { // explanation, shared verbatim across all seven category protocols that // gain this RPC in this same protocol revision. rpc Describe(DescribeRequest) returns (DescribeResponse); + + // GetAccount reports the live account and entitlement state behind this + // plugin's credential: which pool completions are charged against, what + // plan is in force, and whatever quota the vendor publishes outside a + // completion. + // + // MAY be implemented. A provider that has no account concept — a bare + // API key against a metered endpoint, a local model — returns + // codes.Unimplemented, and the kernel MUST tolerate that exactly as it + // tolerates an absent Render (tool/protocol.md's Preview rule is the + // precedent). Absence means "no account state to report", never an + // error. + // + // Separate from GetCapabilities because the two have different + // lifetimes. Capabilities are the static roster fixed at Configure; + // account state is live, changes as quota burns down, and is the only + // way an operator learns a subscription pool is nearly empty *before* + // the turn that strands them. It is not part of the capability + // advertisement and MUST NOT be cached as if it were. + // + // Deliberately not persisted into the session event log: it is a live + // reading of external state, so recording it would put a value into + // the replay path that no replay can reproduce + // (.claude/rules/determinism.md). + rpc GetAccount(GetAccountRequest) returns (GetAccountResponse); } diff --git a/api/pluggableharness/model/v1/types.proto b/api/pluggableharness/model/v1/types.proto index 3d1e000..2de23e8 100644 --- a/api/pluggableharness/model/v1/types.proto +++ b/api/pluggableharness/model/v1/types.proto @@ -44,6 +44,130 @@ message Capabilities { // HookPoint itself lives in common.v1 for exactly this reason (see // common/v1/types.proto), already imported here for CallContext/Describe. repeated pluggableharness.common.v1.HookPoint supported_hook_points = 4; + + // How this plugin authenticated and which pool it meters against, when + // it can say. MAY be absent — a provider with one credential shape has + // nothing to disambiguate. + optional AuthDescriptor auth = 5; + + // The vendor's version identifier for the model catalog this roster + // was built from (an `x-models-etag`), when the provider fetched one. + // + // Reported so a mismatch against a StreamMetadata.catalog_etag on a + // later completion is *detectable*. Acting on it requires the kernel + // to be able to re-fetch capabilities, which this protocol revision + // does not add — the roster is still resolved once at Configure. Ship + // this now so a provider need not re-advertise when refresh lands. + optional string catalog_etag = 6; + + // When this roster was fetched from the vendor. Absent for a + // hand-written roster compiled into the plugin, which is exactly the + // distinction it exists to make: a static roster is never stale, while + // a fetched one has an age. + optional google.protobuf.Timestamp catalog_fetched_at = 7; +} + +// AuthMethod names the credential shape a model plugin is running under. +// +// It matters beyond bookkeeping: which models a vendor exposes, which +// endpoint answers, and which terms of service apply all differ by +// credential. A frontend that cannot tell a subscription session from a +// console key cannot warn an operator which one they are spending. +enum AuthMethod { + // Zero value: the provider did not say. Not an error — declaring this + // is optional. + AUTH_METHOD_UNSPECIFIED = 0; + // A console/platform API key billed per token. + AUTH_METHOD_API_KEY = 1; + // An interactive product session (a ChatGPT or Grok login), metered + // against that product's subscription pool. + AUTH_METHOD_PRODUCT_SESSION = 2; + // A cloud deployment credential (Azure, Bedrock, Vertex) where billing + // belongs to the hosting account rather than the model vendor. + AUTH_METHOD_DEPLOYMENT_KEY = 3; +} + +// MeteringDomain names what a completion is actually charged against. +// +// Separate from AuthMethod because the two are not one-to-one: a product +// session can be billed against credits once a pool is exhausted. +enum MeteringDomain { + // Zero value: the provider did not say. + METERING_DOMAIN_UNSPECIFIED = 0; + // A subscription pool, where the scarce resource is quota rather than + // currency and computed cost_usd is not what the operator is spending. + METERING_DOMAIN_SUBSCRIPTION_POOL = 1; + // Per-token billing against an invoice, where computed cost_usd is a + // real prediction of a real charge. + METERING_DOMAIN_METERED_API = 2; +} + +// AuthDescriptor is the non-secret description of how a model plugin is +// authenticated. +// +// Nothing here is a credential, and nothing here may be derived from one +// in a way that leaks it: no key material, no token, no refresh token, +// no full account identifier. .claude/rules/logging-telemetry.md's +// no-secrets rule applies to every field with no exception, including +// the labels map. +message AuthDescriptor { + // The credential shape in use. + AuthMethod method = 1; + + // What completions are charged against. + MeteringDomain metering = 2; + + // The vendor's plan name, where a subscription names one ("plus", + // "pro", "SuperGrok"). Display only; the kernel never routes on it. + optional string plan = 3; + + // Additional non-secret, vendor-defined labels — a redacted account + // handle, a region, an organization display name. + // + // The kernel MUST serialize this with sorted keys wherever it reaches + // a persisted payload; Go map iteration order is randomized and would + // otherwise make the same state serialize differently across runs + // (.claude/rules/determinism.md). + map labels = 4; +} + +// AccountSnapshot is the live account and entitlement state behind a +// model plugin's credential, as returned by GetAccount. +// +// It answers the question a subscription operator actually has — "how +// much of my pool is left, and on which plan" — which no per-completion +// message can answer before the first completion runs. The quota list +// reuses RateLimitSnapshot rather than introducing a parallel shape: +// pool headroom and a rate-limit budget are the same concept read at +// different times, and two types for it would guarantee two frontend +// renderers that disagree. +message AccountSnapshot { + // The credential shape in use. + AuthMethod method = 1; + + // What completions are charged against. + MeteringDomain metering = 2; + + // The vendor's plan name, where a subscription names one. Display + // only; the kernel never routes on it. + optional string plan = 3; + + // Non-secret, vendor-defined labels — a redacted account handle, a + // region, an organization display name. The no-secrets rule on + // AuthDescriptor.labels applies here identically. + map labels = 4; + + // Every budget the vendor publishes for this account outside a + // completion: pool percentages, credit balances, request ceilings. + // MAY be empty — a vendor that publishes budgets only in completion + // response headers reports them on Usage instead. + repeated RateLimitSnapshot quotas = 5; + + // When this snapshot was read from the vendor. Lets a frontend say how + // stale the figure is rather than presenting a cached reading as live, + // which is the specific failure mode that makes an operator distrust a + // usage meter. + optional google.protobuf.Timestamp fetched_at = 6; } // ModelSpec describes one model this provider can serve, per @@ -108,6 +232,123 @@ message ModelSpec { // the kernel MUST reject a DocumentBlock sent to a model where this is // false, with invalid_request, rather than silently dropping it. bool supports_documents = 12; + + // Human-facing catalog metadata for a model picker. Absent for a + // provider with a hand-written roster and nothing to say beyond the + // id. + optional CatalogMetadata catalog = 13; + + // The largest context window this model can be configured with, where + // the vendor exposes a ceiling above the default context_window (a + // long-context variant billed at a different tier). + // + // context_window remains the figure the kernel budgets against; + // this is the headroom a picker can offer, not a silent upgrade. + optional int64 max_context_window = 14; + + // The fraction of context_window, 0-100, that is actually usable for + // conversation after the vendor's own fixed overhead. + // + // Vendors publish a round context_window and then reserve part of it, + // which is why a session can hit a limit well below the advertised + // number. Absent means the whole window is usable. + optional double effective_context_window_percent = 15; + + // The assembled-token count at which a harness should compact this + // model's history, where the vendor recommends one. Advisory: the + // kernel's own compaction policy decides, and MUST NOT treat this as a + // hard bound. + optional int64 auto_compact_token_limit = 16; + + // The model's output-verbosity control, where it exposes one. + optional VerbositySpec verbosity = 17; + + // The service or speed tiers this model can be served at, in the + // vendor's own naming. Empty means the vendor exposes no tier choice. + repeated string service_tiers = 18; + + // Which vendor API surface serves this model ("chat_completions", + // "responses", "messages"), for vendors exposing several with + // different capabilities. + // + // Opaque to the kernel — it never routes on this. It exists so a + // provider serving one roster across two backends can record which is + // which instead of splitting into two plugins. + optional string api_backend = 19; + + // The vendor's policy name for truncating oversized tool output, where + // it defines one. Opaque to the kernel. + optional string truncation_policy = 20; + + // The vendor's compaction-compatibility identifier, where it publishes + // one. Two models sharing a value can consume each other's compacted + // history; differing values mean a compaction cannot be carried + // across. Opaque to the kernel. + optional string comp_hash = 21; +} + +// CatalogMetadata is the human-facing description of a model — what a +// picker shows, not what the kernel routes on. +// +// Grouped into its own message rather than flattened onto ModelSpec +// because none of it is behavioral: a kernel that ignored this message +// entirely would route, budget, and bill identically. Keeping the +// separation makes that obvious at a glance instead of leaving a reader +// to work out which of twenty ModelSpec fields change behavior. +message CatalogMetadata { + // The model's display name ("Grok 4.5", "GPT-5 Codex"), for a picker. + // Absent means a frontend falls back to ModelSpec.id. + optional string display_name = 1; + + // A one-line description of what the model is for. + optional string description = 2; + + // Whether this model should be offered in a picker by default. + // Vendors publish models that exist but are deprecated, internal, or + // gated; absent means visible. + optional bool visible = 3; + + // A sort weight for a picker, higher first. Absent means unranked, and + // a frontend orders by whatever it likes. + optional int32 priority = 4; + + // Whether this model is reachable with an API key, as opposed to only + // through a product session. + // + // Paired with AuthDescriptor.method this is what lets a frontend hide + // models the current credential cannot actually reach, instead of + // offering one that fails at first use. + optional bool supported_in_api = 5; + + // Other ids that resolve to this same model. A vendor publishing + // `grok-4` as an alias of `grok-4.3` lists it here. + // + // Aliases are NOT separate ModelSpec entries: expanding them into one + // spec each is what makes a catalog appear to hold several distinct + // models that are one model, and makes a picker offer the same thing + // three times. + repeated string aliases = 6; + + // The model family this belongs to, for grouping variants that differ + // only by size or revision. Opaque to the kernel. + optional string family = 7; +} + +// VerbositySpec declares a model's output-verbosity control, where the +// vendor exposes one — a knob distinct from thinking effort, which +// governs reasoning depth rather than answer length. +message VerbositySpec { + // Whether this model accepts a verbosity setting at all. When false, + // levels MUST be empty and default MUST be absent. + bool supported = 1; + + // The accepted level names, in the vendor's own vocabulary ("low", + // "medium", "high"), ordered least to most verbose. + repeated string levels = 2; + + // The level applied when a request names none. MUST be one of levels + // when set. + optional string default = 3; } // ThinkingDisableSupport describes whether a model's reasoning can be @@ -219,6 +460,16 @@ message ThinkingSpec { // Whether, and when, reasoning can be turned off. MUST be set when // supported is true. ThinkingDisableSupport disable = 10; + + // Whether this model can emit a reasoning *summary* distinct from its + // raw reasoning stream. When true, a StreamEvent's thinking deltas may + // carry a channel distinguishing the two. + optional bool supports_reasoning_summary = 11; + + // The summary mode applied when a request names none, in the vendor's + // own vocabulary ("auto", "concise", "detailed"). Meaningless unless + // supports_reasoning_summary is true. + optional string default_reasoning_summary = 12; } // CachingSpec describes one model's prompt-caching capability, per @@ -325,6 +576,18 @@ message PricingTier { // Omitted means unbounded above. Half-open with input_tokens_from, // matching effective_from/effective_until's half-open convention. optional int64 input_tokens_until = 10; + + // Price per million image input tokens, where the vendor rates image + // input separately from text. + // + // Absent means image input bills at input_per_mtok — the correct + // reading for every vendor that does not price it separately, and the + // behavior before this field existed. + optional double image_input_per_mtok = 11; + + // Price per million audio input tokens, on the same terms as + // image_input_per_mtok. + optional double audio_input_per_mtok = 12; } // Pricing describes one model's cost structure, per model.md §2. MUST @@ -344,6 +607,17 @@ message Pricing { // given timestamp; the kernel MUST reject overlapping or gapped tiers // at capability-load time. repeated PricingTier tiers = 3; + + // The vendor's own pricing unit these rates were converted from, when + // the adapter had to convert — an integer per-token price, a tick + // scale, a per-thousand rate. + // + // Recorded for audit, never used in computation: the kernel bills from + // the per-MTok rates above regardless. It exists because the + // conversion is otherwise adapter-private, which makes a ledger figure + // that disagrees with a vendor invoice impossible to trace back to + // whether the rate or the arithmetic was wrong. + optional string source_unit = 4; } // CacheBreakpoint marks one position in a StreamCompletionRequest where @@ -429,6 +703,71 @@ message GenerationParams { // above — an unsupported mode is a kernel-level reject-or-fallback, not // something forwarded to the vendor. optional ToolChoice tool_choice = 6; + + // The service or speed tier to serve this request at. MUST be one of + // the target ModelSpec.service_tiers; the kernel rejects a tier the + // model does not advertise rather than forwarding it, matching + // tool_choice's own validate-then-send rule above. + optional string service_tier = 7; + + // The output-verbosity level — answer length, distinct from + // thinking_effort's reasoning depth. MUST be one of the target + // model's VerbositySpec.levels. + optional string verbosity = 8; + + // Constrains the response to a structured format. + optional ResponseFormat response_format = 9; + + // An opaque key grouping requests the vendor should cache together. + // + // Typed rather than left to provider_options because the kernel does + // act on caching — cache_read_tokens and cache_write_tokens feed + // cost_usd, and data-types.md's provider_options rule is explicit that + // a field affecting cost computation cannot live there. + optional string prompt_cache_key = 10; + + // Whether the vendor should retain this request server-side, for + // vendors that offer it. Absent leaves the vendor's own default. + optional bool store = 11; + + // Overrides ModelSpec.supports_parallel_tool_calls for this one + // request. MUST NOT be set true for a model that does not support it. + optional bool parallel_tool_calls = 12; + + // The reasoning-summary mode for this request, where the model + // advertises ThinkingSpec.supports_reasoning_summary. + optional string reasoning_summary = 13; +} + +// ResponseFormat constrains a completion's shape, for vendors offering +// structured output. +message ResponseFormat { + // The format kind. + ResponseFormatKind kind = 1; + + // The JSON Schema the response MUST conform to. Required when kind is + // RESPONSE_FORMAT_KIND_JSON_SCHEMA, meaningless otherwise. + // + // Uses the same restricted JSON-Schema subset as tool parameters + // (schema.v1), deliberately: a vendor accepting one and not the other + // is an adapter concern, and two schema dialects in one protocol would + // be two things for a plugin author to learn. + optional pluggableharness.schema.v1.Schema json_schema = 2; + + // A name for the schema, where the vendor requires one. + optional string schema_name = 3; +} + +// ResponseFormatKind names the structured-output modes. +enum ResponseFormatKind { + // Zero value. Never valid on a set ResponseFormat. + RESPONSE_FORMAT_KIND_UNSPECIFIED = 0; + // Ordinary free-form text. + RESPONSE_FORMAT_KIND_TEXT = 1; + // Any syntactically valid JSON, with no schema constraint. + RESPONSE_FORMAT_KIND_JSON_OBJECT = 2; + // JSON conforming to json_schema. + RESPONSE_FORMAT_KIND_JSON_SCHEMA = 3; } // ToolChoiceMode enumerates the tool-invocation constraint shapes found @@ -501,9 +840,90 @@ message Usage { // left" is unactionable without saying 2% of what. repeated RateLimitSnapshot rate_limits = 6; - // Deliberately no cost field: the kernel computes and persists - // cost_usd from these token counts plus the matching PricingTier, per - // model.md §4.1 — the provider never computes cost itself. + // What the vendor says this completion cost, in the vendor's own + // denomination, when it reports a figure at all (xAI returns + // `cost_in_usd_ticks`). + // + // Reported, never authoritative. The kernel still computes and + // persists cost_usd from the token counts above plus the matching + // PricingTier, and every rollup, budget, and replay reads that + // computed figure — see model.md §4.1. This field is persisted + // alongside it for display and reconciliation, so an operator can see + // that list price and actual bill disagree instead of having to guess. + // Making it authoritative would put two costs in the ledger with no + // deterministic rule for which one a replay reproduces + // (.claude/rules/determinism.md). + optional VendorCost vendor_cost = 7; + + // The vendor's own total-token figure, when it publishes one that is + // not simply the sum of the parts above. Recorded rather than + // recomputed precisely so a disagreement stays visible; the kernel + // never derives this and never bills from it. + optional int64 vendor_total_tokens = 8; + + // Vendor-defined counters with no first-class field: per-modality + // input tokens (text/image/audio), accepted/rejected prediction + // tokens, hosted-tool source counts. Opaque to the kernel, which + // stores and surfaces them without interpretation. + // + // The kernel MUST sort these by name before persisting: a repeated + // field reaching a persisted payload in adapter-emission order would + // make the event log depend on map iteration inside the adapter + // (.claude/rules/determinism.md). + repeated UsageComponent components = 9; + + // Whether reasoning_tokens is already included in output_tokens + // because the vendor said so out of band (OpenAI's + // `X-Reasoning-Included`). Absent means "not stated", which the kernel + // treats as the documented default for reasoning_tokens above: a + // distinct count, not folded in. Set true only on a vendor's explicit + // signal — it exists to stop the kernel double-counting reasoning in + // its own estimates, so guessing defeats the purpose. + optional bool reasoning_already_counted = 10; +} + +// VendorCost is a vendor's own price for one completion, in whatever +// unit that vendor bills in. +// +// The amount is a decimal string rather than a double because these are +// exact monetary quantities and binary floating point cannot represent +// them exactly — a ledger that must reconcile against an invoice cannot +// afford the rounding. +message VendorCost { + // The amount, as an exact decimal string ("0.00241", "24100000"). + // MUST parse as a decimal number; MUST NOT carry a currency symbol, + // thousands separators, or exponent notation. + string amount = 1; + + // The unit `amount` is denominated in, naming the vendor's own scale + // where it is not plain currency: "usd", "xai_ticks_1e10". Opaque to + // the kernel, which never converts between units — a conversion the + // kernel invented would be one more unaudited number in the ledger. + string unit = 2; + + // The ISO 4217 currency, when `unit` is a currency-denominated one and + // the vendor bills in something other than USD. + optional string currency = 3; +} + +// UsageComponent is one vendor-defined counter the protocol has no +// typed field for. +// +// A repeated name/value pair rather than a growing list of optional +// int64s because the set is vendor-specific and open-ended: every vendor +// meters a slightly different decomposition, and promoting each one to a +// field would churn this message on every provider added. +message UsageComponent { + // The counter's vendor-facing name, verbatim + // ("input_image_tokens", "num_sources_used", + // "accepted_prediction_tokens"). MUST be set and MUST be unique within + // one Usage. + string name = 1; + + // The counter's value. Named `value` rather than `tokens` because not + // every vendor counter is a token count — `num_sources_used` counts + // documents. + int64 value = 2; } // RateLimitKind names which vendor budget a RateLimitSnapshot describes. @@ -519,6 +939,28 @@ enum RateLimitKind { RATE_LIMIT_KIND_INPUT_TOKENS = 3; // Output tokens per window, where the vendor meters them separately. RATE_LIMIT_KIND_OUTPUT_TOKENS = 4; + // A credit or currency balance metered independently of requests and + // tokens, as subscription products bill overage against. + RATE_LIMIT_KIND_CREDITS = 5; +} + +// WindowRole distinguishes the several budgets a subscription product +// meters at once, where one is the headline limit and the others +// constrain bursts within it. +// +// Without it an adapter facing a product that publishes a primary and a +// secondary window has to pick one RateLimitKind for each and hope the +// frontend guesses right — the mapping 27-missing-openai-protocol.md +// calls a "semantic lie". +enum WindowRole { + // Zero value: the vendor publishes one undifferentiated budget, or + // says nothing about role. Not an error. + WINDOW_ROLE_UNSPECIFIED = 0; + // The headline budget an operator thinks of as "my limit". + WINDOW_ROLE_PRIMARY = 1; + // A shorter or narrower budget that constrains bursts inside the + // primary one. + WINDOW_ROLE_SECONDARY = 2; } // RateLimitSnapshot is one of the vendor's rate-limit budgets as of one @@ -540,6 +982,36 @@ message RateLimitSnapshot { // When this budget next resets. optional google.protobuf.Timestamp reset_at = 4; + + // The vendor's stable identifier for this budget, where it names one + // ("codex", "codex_other"). Opaque to the kernel; it exists so two + // snapshots of the same budget can be correlated across completions + // even when kind and window_role are identical. + optional string limit_id = 5; + + // A human-facing label for this budget, when the vendor supplies one + // worth showing. Never synthesized from limit_id — a frontend can fall + // back to kind and window_role perfectly well, and an invented label + // reads as authoritative. + optional string limit_name = 6; + + // Which of the vendor's several budgets this is. + WindowRole window_role = 7; + + // How much of this budget is spent, 0-100, for the products that + // publish only a percentage and never absolute counts. + // + // This exists so those adapters stop faking `limit = 100` and + // `remaining = 100 - percent` to fit the absolute fields. A vendor + // publishing real counts sets remaining/limit and leaves this unset; a + // vendor publishing only a percentage sets this and leaves those + // unset. An adapter MUST NOT derive one form from the other. + optional double used_percent = 8; + + // The budget window's length. Paired with reset_at it lets a frontend + // say "5 hours" rather than only "resets at 14:00", which is what + // makes a limit predictable instead of a surprise. + optional int64 window_seconds = 9; } // ModelTarget describes the model a context or memory contribution is diff --git a/api/pluggableharness/plan/v1/types.proto b/api/pluggableharness/plan/v1/types.proto index 997d039..7645e13 100644 --- a/api/pluggableharness/plan/v1/types.proto +++ b/api/pluggableharness/plan/v1/types.proto @@ -18,6 +18,37 @@ import "pluggableharness/tool/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/plan/proto/v1;planv1"; +// ClientDecision is the operator's resolution of a pending plan item that +// policy evaluated as ASK — the allow/deny half of ResolvePlanDecision, +// orthogonal to PlanDecisionScope (how durably the verdict is remembered). +enum ClientDecision { + // Zero value. Never valid for a real decision. + CLIENT_DECISION_UNSPECIFIED = 0; + // The operator approved the plan item as proposed (or as corrected). + CLIENT_DECISION_ALLOW = 1; + // The operator rejected the plan item. + CLIENT_DECISION_DENY = 2; +} + +// PlanDecisionScope is how durably a ResolvePlanDecision applies beyond +// the one PlanItem it names. Orthogonal to ClientDecision: decision says +// allow/deny, scope says how long that verdict is remembered. See +// agent-loop/plan-apply-gate.md's PlanDecisionScope semantics. +enum PlanDecisionScope { + // Zero value. Never valid for a real decision. + PLAN_DECISION_SCOPE_UNSPECIFIED = 0; + // Applies to this PlanItem only. The default a frontend SHOULD send + // when the operator has not explicitly asked for a broader scope. + PLAN_DECISION_SCOPE_ONCE = 1; + // Applies to the rest of this session for matching provider/operation + // calls — an in-memory, session-lifetime rule, not written to agent.hcl. + PLAN_DECISION_SCOPE_SESSION = 2; + // The kernel persists this as policy beyond this session. A kernel that + // cannot persist policy MUST reject ALWAYS rather than silently + // downgrading to SESSION or ONCE. + PLAN_DECISION_SCOPE_ALWAYS = 3; +} + // PlanDecision is the outcome of evaluating one PlanItem against policy. enum PlanDecision { // Zero value. Never valid as a deliberately-chosen decision; its diff --git a/api/pluggableharness/render/v1/types.proto b/api/pluggableharness/render/v1/types.proto index 3abd676..5d7fce1 100644 --- a/api/pluggableharness/render/v1/types.proto +++ b/api/pluggableharness/render/v1/types.proto @@ -1,13 +1,17 @@ syntax = "proto3"; -// Package pluggableharness.render.v1 defines the Emit->Render->Paint intermediate -// representation described in specifications/frontend.md §1. Every plugin -// category's optional Render() RPC (model.md §7, tool.md §7, -// context.md §9, memory.md §10) returns this; every frontend/widget Paints -// it. frontend.md §1 MUST: a frontend must render every RenderNode variant -// gracefully with a generic fallback (e.g. an unrecognized-in-practice -// future variant, or `diff` on a frontend with no diff view -> plain -// before/after text) — never error, never silently drop a node. +// Package pluggableharness.render.v1 defines the Emit->Render->Paint +// intermediate representation used by the transcript surface +// (specifications/frontend/). Every plugin category's optional Render() +// RPC (model, tool, context, memory) returns a RenderTree; frontends +// paint transcript content from it. A frontend MUST render every +// RenderNode variant gracefully with a generic fallback (e.g. an +// unrecognized future variant, or `diff` on a frontend with no diff view +// -> plain before/after text) — never error, never silently drop a node. +// +// Placement is not this package's job: Region and PlacedContent were +// retired. State, metadata, and input are typed kernel surfaces; only +// the conversation transcript still travels as RenderTree. package pluggableharness.render.v1; import "google/protobuf/struct.proto"; @@ -15,62 +19,16 @@ import "google/protobuf/struct.proto"; option go_package = "github.com/pluggableharness/agent/pkg/render/proto/v1;renderv1"; // RenderTree is the return type of every category's Render() RPC. -// frontend.md §1 treats "RenderTree" and "RenderNode" as equivalent (the -// tree root is just a node) — this wraps a single root RenderNode so every -// Render() RPC across every category shares one stable, named response -// type, with room to grow (e.g. a schema_version) without changing -// RenderNode itself. +// specifications/frontend/render-tree.md treats "RenderTree" and +// "RenderNode" as equivalent (the tree root is just a node) — this wraps +// a single root RenderNode so every Render() RPC across every category +// shares one stable, named response type, with room to grow (e.g. a +// schema_version) without changing RenderNode itself. message RenderTree { // The tree's root node. RenderNode root = 1; } -// Region is the placement vocabulary for where a RenderTree gets shown, -// described in specifications/frontend.md §2. Every region is -// plugin-contributable; a placement is a hint the frontend MAY drop or -// re-fold under space constraints. Lives here (not in frontend/v1) because -// both the frontend provider protocol AND the widget provider protocol -// place content into these same regions (frontend.md §4.1's WidgetUpdate), -// and neither category's package should depend on the other's. -enum Region { - // Zero value. Never valid for a real placement; its presence on the wire - // means a caller forgot to set the field. - REGION_UNSPECIFIED = 0; - // The main conversation transcript. The default region when a producer - // doesn't specify one. - REGION_MAIN_CHAT = 1; - // A persistent side panel. - REGION_SIDEBAR = 2; - // A persistent header bar. - REGION_TOP_BAR = 3; - // The area around the user's input box. - REGION_INPUT_BAR = 4; - // Contextual hotkey/command hints. - REGION_HOTKEY_HINTS = 5; - // A modal or floating layer. MUST be visually distinct from the rest of - // the interface (frontend.md §2). - REGION_OVERLAY = 6; -} - -// PlacedContent pairs a RenderTree with where it should be shown and how -// it interacts with that region's prior content from the same producer. -// Used by frontend.md §3.2's ServerEvent.render variant. -message PlacedContent { - // Where this content should be placed. - Region region = 1; - - // The content to place. - RenderTree content = 2; - - // True: replace this producer's prior content in `region`. False: - // append (the default behavior for REGION_MAIN_CHAT). - bool replace = 3; - - // Ordering/eviction hint for space-constrained regions. Unset means - // "declaration order". - optional int32 priority = 4; -} - // TextStyle is a hint for how a TextNode's content should be presented. // Distinct from "unset": TEXT_STYLE_NORMAL is a producer explicitly // requesting plain styling (overriding any inherited formatting context), @@ -232,15 +190,16 @@ message SubSessionNode { string summary = 2; } -// ActionNode is interactive/clickable content — the widget action channel -// (frontend.md §5.1). A frontend MUST make this node interactive and, on -// activation, dispatch a ClientEvent.action_trigger carrying `tool_name` -// and `args` unchanged. The kernel handles the resulting action_trigger -// identically to a direct_invoke slash command: the normal Invoke/ -// plan-apply pipeline including policy evaluation, with no model turn. +// ActionNode is interactive/clickable content in a transcript RenderTree. +// A frontend MUST make this node interactive and, on activation, call +// KernelCallbackService.TriggerAction carrying tool_name, args, and +// provider unchanged. The kernel handles it identically to a +// direct-invoke slash command: the normal Invoke/plan-apply pipeline +// including policy evaluation, with no model turn. message ActionNode { - // Identifies this action node within its RenderTree, echoed back in the - // resulting ClientEvent so the frontend (and kernel) can correlate. + // Identifies this action node within its RenderTree, echoed back in + // TriggerActionRequest.node_id so the frontend (and kernel) can + // correlate. string id = 1; // The clickable label shown to the user. string label = 2; @@ -254,7 +213,7 @@ message ActionNode { // The declared name of the tool provider plugin `tool_name` belongs to. // tool_name is only unique per provider (matching plan.v1.PlanItem's own // provider/tool_name pairing), so this disambiguates which provider's - // operation to invoke on activation. Echoed unchanged onto the resulting - // ClientEvent.ActionTrigger.provider (frontend.md §"Client events"). + // operation to invoke on activation. Echoed unchanged onto + // TriggerActionRequest.provider. string provider = 5; } diff --git a/api/pluggableharness/session/v1/types.proto b/api/pluggableharness/session/v1/types.proto index 2092667..a81f2ac 100644 --- a/api/pluggableharness/session/v1/types.proto +++ b/api/pluggableharness/session/v1/types.proto @@ -1,12 +1,15 @@ syntax = "proto3"; -// Package pluggableharness.session.v1 defines the session lifecycle status enum -// shared by kernel-callbacks.md §1's RunSessionResult, state-backend.md -// §4.2's session_meta.status column, and frontend.md §3.2's -// session_tree_update ServerEvent. +// Package pluggableharness.session.v1 defines the session lifecycle status +// enum shared by kernel-callbacks.md's RunSessionResult and +// state-backend.md's session_meta.status column, plus SessionInfo and the +// frontend-facing SessionState snapshot ("where am I") assembled by +// KernelCallbackService.GetSessionState. package pluggableharness.session.v1; +import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; +import "pluggableharness/model/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/session/proto/v1;sessionv1"; @@ -40,9 +43,9 @@ enum SessionStatus { // SessionInfo is a session's shareable summary, mirroring // state-backend.md's session_meta row plus a cheap cost_ledger SUM. Used -// by frontend.md's SessionCreated/SessionAttached/SessionList ServerEvent -// variants — the frontend protocol's read-only view of session lifecycle -// state, never mutated by a frontend directly. +// by session lifecycle RPCs (CreateSession/AttachSession/ListSessions) +// and embedded inside SessionState — the frontend protocol's read-only +// view of session lifecycle state, never mutated by a frontend directly. message SessionInfo { // The session's id. ULID, matches the session's sqlite filename stem // (state-backend.md §"File layout"). @@ -75,3 +78,112 @@ message SessionInfo { // cost has been incurred yet, rather than a meaningless zero. optional double cost_usd = 8; } + +// VcsState is the session's version-control summary for status rendering. +// Absent fields mean "unknown / not a VCS working tree," not empty. +message VcsState { + // The remote tracking URL or name, when known. + optional string remote = 1; + // The current branch or detached-HEAD ref name, when known. + optional string branch = 2; + // True when the working tree has uncommitted changes. Absent when VCS + // state could not be determined. + optional bool dirty = 3; +} + +// ModelState names the model currently driving the session. +message ModelState { + // The model's id within its provider (ModelSpec.id). + string id = 1; + // The declared name of the model provider plugin. + string provider = 2; +} + +// ContextState is the session's context-window pressure for status bars. +message ContextState { + // Tokens currently used against the window. + int64 used_tokens = 1; + // The usable context window size in tokens (effective ceiling). + int64 window_tokens = 2; +} + +// SessionState is the fixed-schema "where am I" snapshot a frontend +// renders into a status bar, HTTP header, stdout line, or spoken sentence. +// Assembled by KernelCallbackService.GetSessionState and republished on +// the event bus topic kernel.state whenever a watched field changes. +// Per-session: every snapshot names exactly one session. No extension +// point — a closed schema is what makes every frontend able to render it. +message SessionState { + // The session's shareable lifecycle summary. MUST be set. + SessionInfo info = 1; + + // Absolute working directory for this session. MUST be set once the + // session has one; empty only before CreateSession finishes binding it. + string working_directory = 2; + + // Version-control summary for working_directory. Absent when not a + // VCS tree or not yet probed. + optional VcsState vcs = 3; + + // The model currently selected for this session's turns. Absent only + // before the first model resolution. + optional ModelState model = 4; + + // The active thinking/effort level name for the current model, when + // the model exposes an effort ladder. Absent when thinking is off or + // the model has no effort control. + optional string thinking_effort = 5; + + // Context-window pressure. Absent only before the first turn's usage + // is known. + optional ContextState context = 6; + + // Number of completed turns in this session. + int32 turn_count = 7; + + // Wall-clock time since info.started_at. MUST be set for a live + // session; for a terminal session equals ended_at - started_at. + google.protobuf.Duration elapsed = 8; + + // Session-lifetime total tokens (input + output), summed from the cost + // ledger / usage rollups. Zero when no model call has completed yet. + int64 total_tokens = 9; + + // The vendor budgets reported by the most recent completion, from that + // completion's Usage or StreamMetadata. + // + // Distinct from account.quotas below: these are per-completion + // readings taken from response headers as turns run, while + // account.quotas is the account-level snapshot GetAccount returns + // independently of any completion. A subscription product typically + // publishes both, and they refresh on different schedules. + // + // MAY be empty — a vendor that publishes no budgets has nothing here, + // and the kernel MUST NOT synthesize an entry from its own token + // counting. + repeated pluggableharness.model.v1.RateLimitSnapshot quotas = 10; + + // The account and entitlement state behind the session's model + // provider, from GetAccount. Absent when the provider does not + // implement that RPC, which is the common case for a bare API key. + optional pluggableharness.model.v1.AccountSnapshot account = 11; + + // What the vendor said the most recent completion cost, in its own + // denomination. + // + // Reported beside info.cost_usd, never instead of it: cost_usd remains + // the kernel's computed figure and the one every rollup and budget + // reads. This is here so a frontend can show that list price and + // actual bill disagree — and so a subscription session, where computed + // cost is structurally 0.00, has something truthful to display. + optional pluggableharness.model.v1.VendorCost vendor_cost = 12; + + // The model that actually served the most recent completion, when the + // vendor remapped it away from the requested id. + // + // Surfaced at session level because silent model substitution is + // otherwise invisible: an operator sees only that answers got worse, + // with nothing in the UI to attribute it to. Absent means the vendor + // served what was asked for, or said nothing. + optional string actual_model = 13; +} diff --git a/api/pluggableharness/widget/v1/errors.proto b/api/pluggableharness/widget/v1/errors.proto index c5f2e5e..52550d1 100644 --- a/api/pluggableharness/widget/v1/errors.proto +++ b/api/pluggableharness/widget/v1/errors.proto @@ -6,29 +6,24 @@ option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widge // The widget provider protocol's error taxonomy. -// WidgetErrorCategory classifies a WidgetError, mirroring -// FrontendErrorCategory's shape (frontend/v1/errors.proto) for the widget category — -// resolves frontend/conformance.md's prior open question of whether -// widgets need a structured error type of their own. +// WidgetErrorCategory classifies a WidgetError. enum WidgetErrorCategory { // Zero value. Never valid for a real error; its presence on the wire // means a caller forgot to set the field. WIDGET_ERROR_CATEGORY_UNSPECIFIED = 0; - // A RenderTree or WidgetUpdate could not be displayed. + // A render or metadata contribution could not be produced. WIDGET_ERROR_CATEGORY_RENDER_FAILED = 1; - // A WidgetUpdate named a Region this widget's frontend cannot honor. - WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED = 2; + // Field 2 was REGION_UNSUPPORTED. Placement regions were retired; + // reserved so the number is never reused. + reserved 2; + reserved "WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED"; // An error that does not fit any other category. WIDGET_ERROR_CATEGORY_UNKNOWN = 3; } -// WidgetError is the structured error type for the widget category, -// mirroring FrontendError (frontend/v1/errors.proto). Unlike the frontend category -// (whose Attach errors surface in-band via ServerEvent.Error), widget -// Attach has no return channel other than the stream itself — WidgetError -// is carried in the structured detail of a gRPC status on Configure or -// Attach, per .claude/rules/grpc.md's error-taxonomy discipline, not as an -// in-band stream message. +// WidgetError is the structured error type for the widget category. +// Carried in the structured detail of a gRPC status on Configure (or +// any future unary), per .claude/rules/grpc.md. message WidgetError { // The error's category. WidgetErrorCategory category = 1; diff --git a/api/pluggableharness/widget/v1/events.proto b/api/pluggableharness/widget/v1/events.proto deleted file mode 100644 index 95f6e62..0000000 --- a/api/pluggableharness/widget/v1/events.proto +++ /dev/null @@ -1,22 +0,0 @@ -syntax = "proto3"; - -package pluggableharness.widget.v1; - -import "pluggableharness/render/v1/types.proto"; - -option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; - -// Occurrence-shaped messages streamed by Attach. - -// WidgetUpdate is one pushed update to this widget's rendered content, per -// frontend.md §4.1. -message WidgetUpdate { - // Which region this update places content into. - pluggableharness.render.v1.Region region = 1; - - // The content to place. - pluggableharness.render.v1.RenderTree content = 2; - - // True: replace this widget's prior content in `region`. False: append. - bool replace = 3; -} diff --git a/api/pluggableharness/widget/v1/rpc_request.proto b/api/pluggableharness/widget/v1/rpc_request.proto index a67be9d..d23fd81 100644 --- a/api/pluggableharness/widget/v1/rpc_request.proto +++ b/api/pluggableharness/widget/v1/rpc_request.proto @@ -17,12 +17,6 @@ message GetCapabilitiesRequest {} // ConfigureRequest carries this provider's already-decoded agent.hcl block. message ConfigureRequest { - // The provider's already-decoded config, per frontend.md §4.1. + // The provider's already-decoded config. google.protobuf.Struct config = 1; } - -// AttachRequest identifies which session's widget instance to attach to. -message AttachRequest { - // The session this widget instance is attaching to. - string session_id = 1; -} diff --git a/api/pluggableharness/widget/v1/service.proto b/api/pluggableharness/widget/v1/service.proto index c6a4460..6a02d51 100644 --- a/api/pluggableharness/widget/v1/service.proto +++ b/api/pluggableharness/widget/v1/service.proto @@ -1,60 +1,32 @@ syntax = "proto3"; -// Package pluggableharness.widget.v1 defines the widget provider plugin protocol -// described in specifications/frontend.md §4 (Attach, action dispatch, ...). -// Messages and RPCs are added incrementally as the protocol is finalized; -// this file currently scaffolds the buf toolchain wiring — see -// .claude/rules/proto.md. +// Package pluggableharness.widget.v1 defines the widget provider plugin +// protocol described in specifications/frontend/widget-protocol.md. A +// widget contributes typed metadata (or other plugin-side work) without +// owning the frontend. There is no Attach stream: a widget that wants +// screen presence calls KernelCallbackService.PublishMetadata on the +// callback channel, the same path a tool provider uses for a status +// block. package pluggableharness.widget.v1; -import "pluggableharness/widget/v1/events.proto"; import "pluggableharness/widget/v1/rpc_request.proto"; import "pluggableharness/widget/v1/rpc_response.proto"; option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; -// WidgetService is the widget provider plugin protocol described in -// specifications/frontend.md §4.1. A widget provider plugin exposes -// GetCapabilities, Configure, and Attach. Unlike the frontend provider's -// bidirectional Attach (frontend.md §3), this Attach is server-streaming -// only — widgets are passive/display-only in v1; a widget wanting to -// trigger an action does so by also being a tool provider with a slash -// command (frontend.md §5), not through this channel. +// WidgetService is the widget provider plugin protocol. Same three RPCs +// every category exposes; no Attach. service WidgetService { - // GetCapabilities returns this widget's regions and config schema, per - // frontend.md §4.1. MUST be cheaply re-queryable and MUST NOT require a + // GetCapabilities returns this widget's config schema and supported + // hook points. MUST be cheaply re-queryable and MUST NOT require a // network call. rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - // Configure decodes this provider's agent.hcl block, per frontend.md - // §4.1. The request is already-decoded JSON (the schema-to-cty bridge is - // kernel-internal and never crosses the wire). Errors surface as a gRPC - // status per grpc.md — not an in-band field on ConfigureResponse. + // Configure decodes this provider's agent.hcl block. Errors surface as + // a gRPC status per grpc.md — not an in-band field on ConfigureResponse. rpc Configure(ConfigureRequest) returns (ConfigureResponse); - // Attach opens a server-streaming feed of this widget's rendered updates - // for one session, per frontend.md §4.1 — confirmed NOT bidi; widgets are - // passive/display-only in v1. A widget derives its displayed state via - // observe-mode hook subscription (agent-loop.md §4), not a separate - // session-state feed (frontend.md §4.2); this stream is purely how it - // pushes the resulting rendered updates out, it never receives anything - // back on this channel. Cancellation is the kernel closing the gRPC - // stream; the plugin MUST treat this as normal control flow, never as an - // error. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is the bare "WidgetUpdate" per frontend.md §4.1's - // literal spec, naming the domain concept rather than the RPC. Not a - // uniqueness violation: WidgetUpdate is used by exactly this one RPC. - rpc Attach(AttachRequest) returns (stream WidgetUpdate); - - // Describe reports this plugin build's own identity — {name, version, - // source, category, protocol_version} — directly from the running - // process, rather than the kernel inferring it from a lock-file row. - // Every one of the seven category protocols gains this identical RPC in - // this protocol revision; it exists specifically for a - // `dev_overrides`-resolved binary (configuration/lock-file.md's - // "dev_overrides and identity without a lock entry"), which has no - // provider {} lock-file entry to read identity from at all. + // Describe reports this plugin build's own identity from the running + // process rather than a lock-file row. rpc Describe(DescribeRequest) returns (DescribeResponse); } diff --git a/api/pluggableharness/widget/v1/types.proto b/api/pluggableharness/widget/v1/types.proto index 46f10f2..8e7bbbd 100644 --- a/api/pluggableharness/widget/v1/types.proto +++ b/api/pluggableharness/widget/v1/types.proto @@ -4,20 +4,22 @@ package pluggableharness.widget.v1; import "pluggableharness/common/v1/types.proto"; import "pluggableharness/config/v1/types.proto"; -import "pluggableharness/render/v1/types.proto"; option go_package = "github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1"; // Domain types for the widget provider protocol. // WidgetCapabilities is this widget provider's complete capability -// advertisement, per frontend.md §4.1. +// advertisement. message WidgetCapabilities { - // MUST — the regions this widget intends to contribute to. - repeated pluggableharness.render.v1.Region regions = 1; + // Field 1 was regions (Region enum). Placement regions were retired; + // widgets publish metadata blocks instead. Reserved so the number is + // never reused. + reserved 1; + reserved "regions"; - // This provider's agent.hcl config schema, per configuration.md §4 — - // what fields Configure's request may be decoded from. + // This provider's agent.hcl config schema — what fields Configure's + // request may be decoded from. pluggableharness.config.v1.ConfigSchema config_schema = 2; // Hook points this widget can subscribe to in observe mode diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 8f008d0..7ebde9f 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -1,10 +1,19 @@ -// Command agent is the PluggableHarness kernel binary. +// Command agent is the PluggableHarness kernel binary. It has two modes, +// selected by whether -prompt is given. // -// This build runs exactly one non-interactive session: it loads agent.hcl, -// launches every resolved provider plugin, runs -prompt to completion, -// prints the session's final message to stdout, and exits. The interactive -// command docs/specifications/architecture.md#cli-shape describes arrives -// with the frontend plugin category; there is no REPL here yet. +// With -prompt it runs exactly one non-interactive session: load agent.hcl, +// launch every resolved provider plugin, run the prompt to completion, +// print the session's final message to stdout, exit. +// +// Without -prompt it hosts a frontend. The kernel brings the same providers +// up and then waits while a frontend plugin creates and drives sessions +// over the kernel callback channel, exiting when the operator closes it. +// There is still no REPL in this binary and there is not meant to be one: +// the interactive surface is a plugin, so the terminal UI is a separate +// process the kernel launches rather than code that lives here. +// +// Hosting a frontend means another process owns the terminal. Send this +// one's logs somewhere else (2>agent.log) or they will paint over it. // // Everything below is wiring, per .claude/rules/go-layout.md: flags in, // one internal/kernel.Run call, an exit code out. @@ -50,7 +59,7 @@ func run() int { var ( configPath = fs.String("config", kernel.DefaultConfigFile, "path to the agent.hcl config file") profile = fs.String("profile", "", `agent_profile block to run under (default "default")`) - prompt = fs.String("prompt", "", "the prompt to run (required: this build has no interactive mode)") + prompt = fs.String("prompt", "", "run this prompt to completion and exit; omit to host a frontend plugin instead") logLevel = fs.String("log-level", "", "override settings.log_level (trace|debug|info|warn|error)") showVersion = fs.Bool("version", false, "print the version and exit") ) @@ -66,12 +75,6 @@ func run() int { _, _ = fmt.Fprintln(os.Stdout, buildVersion()) return exitOK } - if *prompt == "" { - _, _ = fmt.Fprintln(os.Stderr, "agent: -prompt is required") - fs.Usage() - return exitUsage - } - // The one cancellation root: everything below derives from it, so a // signal reaches the model stream, the tool calls, and the plugin // subprocesses through the same context internal/kernel already diff --git a/cmd/anthropic/main.go b/cmd/anthropic/main.go deleted file mode 100644 index 0c1124e..0000000 --- a/cmd/anthropic/main.go +++ /dev/null @@ -1,55 +0,0 @@ -// Command anthropic is the Anthropic model-provider plugin. -// -// It is a hashicorp/go-plugin subprocess: the kernel launches it, speaks -// pluggableharness.model.v1.ModelService to it over gRPC, and kills it at -// session end. It is never run directly by a human — started from a -// shell it simply prints go-plugin's handshake line and waits. -// -// Everything here is wiring, per .claude/rules/go-layout.md: build the -// provider, hand it to pkg/model's service adapter, serve. All real logic -// lives in internal/anthropic. -package main - -import ( - "github.com/pluggableharness/agent/internal/anthropic" - commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - "github.com/pluggableharness/agent/pkg/model" - "github.com/pluggableharness/agent/pkg/plugin" -) - -// Identity this build reports through the Describe RPC. -// -// These are variables rather than constants so a release build can stamp -// them with -ldflags (see .goreleaser.yaml), matching how -// internal/pluginhost's integration fixture is built. Describe has to -// answer from the running process because a dev_overrides binary has no -// agent.lock.hcl entry for the kernel to read identity from -// (configuration/lock-file.md#dev_overrides-and-identity-without-a-lock-entry). -var ( - pluginName = "anthropic" - pluginVersion = "0.0.0" - pluginSource = "github.com/pluggableharness/agent-provider-anthropic" -) - -func main() { - identity := plugin.Identity{ - Name: pluginName, - Version: pluginVersion, - Source: pluginSource, - } - - // The callback handle is constructed here and handed to both - // plugin.Serve and the model service, but is deliberately never - // dialed from main: pkg/plugin's "callback-timing trap" means - // Callback.Client may only be called from inside an RPC handler, - // after go-plugin has begun serving the broker. - callback := plugin.NewCallback() - provider := anthropic.New() - - plugin.Serve(plugin.Config{ - Identity: identity, - Category: commonv1.Category_CATEGORY_MODEL, - Callback: callback, - Services: []plugin.Service{model.NewService(provider, identity, callback)}, - }) -} diff --git a/cmd/tui/main.go b/cmd/tui/main.go deleted file mode 100644 index 03d75b1..0000000 --- a/cmd/tui/main.go +++ /dev/null @@ -1,131 +0,0 @@ -// Command tui runs the reference terminal shell for PluggableHarness Agent. -// -// The shell is a frontend provider: in its finished form the kernel launches it -// as a hashicorp/go-plugin subprocess and drives it over a bidirectional Attach -// stream. That kernel-side attach path does not exist yet, so this binary -// currently runs the shell against a scripted demo source, which is what makes -// the layout, focus model, and keymap reviewable ahead of the wiring. -// -// The terminal is opened directly rather than using stdin/stdout, because under -// go-plugin those streams belong to the handshake and the host's logger. That -// is the real code path, exercised here so it does not need revisiting when the -// bridge lands. -package main - -import ( - "context" - "flag" - "fmt" - "log/slog" - "os" - "os/signal" - "syscall" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/pluggableharness/agent/internal/tui/shell" - "github.com/pluggableharness/agent/internal/tui/theme" -) - -func main() { - themeName := flag.String("theme", "dark", "color theme: dark or light") - step := flag.Duration("step", 120*time.Millisecond, "delay between scripted demo events") - logLevel := flag.String("log-level", "warn", "log level: debug, info, warn, error") - flag.Parse() - - if err := run(*themeName, *step, *logLevel); err != nil { - // Diagnostics go to stderr, never to the painted surface. Under - // go-plugin the host collects this as structured plugin output. - fmt.Fprintf(os.Stderr, "tui: %v\n", err) - os.Exit(1) - } -} - -func run(themeName string, step time.Duration, logLevel string) error { - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: parseLevel(logLevel), - }))) - - th, ok := theme.ByName(themeName) - if !ok { - slog.Warn("unknown theme, falling back", "requested", themeName, "using", th.Name) - } - - tty, err := openTTY() - if err != nil { - // No controlling terminal: the shell degrades to not attaching rather - // than taking down whatever launched it. - return fmt.Errorf("tui: open terminal: %w", err) - } - defer func() { - if cerr := tty.Close(); cerr != nil { - slog.Warn("closing terminal", "error", cerr) - } - }() - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - outbox := make(chan shell.Action, 64) - model := shell.New( - shell.WithTheme(th), - shell.WithEmitter(func(a shell.Action) { - select { - case outbox <- a: - default: - slog.Warn("outbox full, dropping action") - } - }), - ) - - // Alt-screen is declared by the model's View in Bubble Tea v2, not as a - // program option. - prog := tea.NewProgram(model, - tea.WithContext(ctx), - tea.WithInput(tty), - tea.WithOutput(tty), - ) - - go drainOutbox(ctx, outbox) - go func() { - src := shell.DemoSource{Step: step} - if rerr := src.Run(ctx, prog.Send); rerr != nil { - slog.Error("event source stopped", "error", rerr) - } - }() - - if _, err := prog.Run(); err != nil { - return fmt.Errorf("tui: run: %w", err) - } - - return nil -} - -// drainOutbox stands in for the Attach stream's writer goroutine. The real -// bridge translates each Action into a ClientEvent and writes it to the stream -// in arrival order, which matters because the kernel processes client events in -// arrival order per session. -func drainOutbox(ctx context.Context, outbox <-chan shell.Action) { - for { - select { - case <-ctx.Done(): - return - case a := <-outbox: - slog.Debug("client action", "action", fmt.Sprintf("%T", a)) - } - } -} - -func parseLevel(s string) slog.Level { - switch s { - case "debug": - return slog.LevelDebug - case "info": - return slog.LevelInfo - case "error": - return slog.LevelError - default: - return slog.LevelWarn - } -} diff --git a/cmd/tui/tty_unix.go b/cmd/tui/tty_unix.go deleted file mode 100644 index 50656fb..0000000 --- a/cmd/tui/tty_unix.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !windows - -package main - -import "os" - -// openTTY opens the controlling terminal for direct read/write. -// -// The shell must never render to stdout or read stdin: when it runs as a -// go-plugin subprocess the handshake line is written to stdout and the host -// pipes stdout and stderr into its own logger, so painting there would corrupt -// the handshake and reading there would compete with the plugin transport. -// Opening the controlling terminal sidesteps both. -func openTTY() (*os.File, error) { - return os.OpenFile("/dev/tty", os.O_RDWR, 0) -} diff --git a/cmd/tui/tty_windows.go b/cmd/tui/tty_windows.go deleted file mode 100644 index 6418ea7..0000000 --- a/cmd/tui/tty_windows.go +++ /dev/null @@ -1,17 +0,0 @@ -//go:build windows - -package main - -import "os" - -// openTTY opens the Windows console device for direct read/write. -// -// This is the Windows half of the same constraint the unix build documents: -// stdout carries the go-plugin handshake and is piped into the host's logger, -// so the shell paints to the console device instead. CONIN$/CONOUT$ are the -// console equivalents of /dev/tty, but they are two separate handles rather -// than one bidirectional file, so the caller receives the output handle and -// Bubble Tea opens console input itself. -func openTTY() (*os.File, error) { - return os.OpenFile("CONOUT$", os.O_RDWR, 0) -} diff --git a/docs/first-party/frontends/README.md b/docs/first-party/frontends/README.md index 1cecd43..2903bf9 100644 --- a/docs/first-party/frontends/README.md +++ b/docs/first-party/frontends/README.md @@ -1,16 +1,18 @@ # Frontend implementations — index -This directory contains first-party reference documentation for frontend providers — the plugin category that owns the display surface, paints [`RenderTree`](../../specifications/frontend/render-tree.md) content into the region vocabulary, and turns operator input into `ClientEvent`s. +This directory contains first-party reference documentation for frontend providers — the plugin category that owns the display surface, paints [`RenderTree`](../../specifications/frontend/render-tree.md) transcript content, and turns operator input into kernel-callback calls. > [!IMPORTANT] -> These are descriptive design documents, not the protocol spec itself: the frontend and widget provider protocols, the `RenderTree` intermediate representation, and the region/placement model live in [`docs/specifications/frontend/`](../../specifications/frontend/README.md), which remains the source of truth. A frontend's layout, focus model, and keymap are explicitly *not* protocol — the spec's own reference-TUI table is labelled non-normative — so everything documented here is one conforming implementation's choices, not a constraint on any other frontend. +> These are descriptive design documents, not the protocol spec itself: the frontend and widget provider protocols and the `RenderTree` intermediate representation live in [`docs/specifications/frontend/`](../../specifications/frontend/README.md), which remains the source of truth. A frontend's layout, focus model, and keymap are explicitly *not* protocol — the protocol carries no placement vocabulary at all — so everything documented here is one conforming implementation's choices, not a constraint on any other frontend. ## Implementations -| Frontend | Document | Surface | +| Frontend | Where it lives | Surface | |---|---|---| -| Reference TUI shell | [tui.md](tui.md) | Full-screen terminal, Bubble Tea + Lip Gloss | +| Reference TUI shell | [pluggableharness/plugin-frontend-tui](https://github.com/pluggableharness/plugin-frontend-tui) | Full-screen terminal, Bubble Tea + Lip Gloss | + +A frontend implementation is a plugin like any other, so its design documentation ships with it rather than here. This directory holds what is true across frontends; the per-implementation choices live in each implementation's own repository. ## Why these documents exist -The protocol deliberately defines *what* content arrives and *where* it is placed, and stops there — it specifies no focus model, no keybinding schema, no resize semantics, and no scrollback behavior. Those are display concerns that only a concrete surface can answer, and they must be answered before widget plugins are written: a widget contributing to `sidebar` needs to know whether that region can hold focus and how its `ActionNode`s become reachable from the keyboard. Documenting each frontend's resolution of those gaps is what gives integrations a stable target to attach to. +The protocol deliberately defines *what* content arrives and stops there — it specifies no placement, no focus model, no keybinding schema, no resize semantics, and no scrollback behavior. Those are display concerns only a concrete surface can answer, and they have to be answered before anything that contributes content can be written: a plugin publishing a metadata block needs to know whether the frontend surfaces it at all, whether it can hold focus, and how its `ActionNode`s become reachable. Documenting each frontend's resolution of those gaps is what gives integrations a stable target to attach to. diff --git a/docs/first-party/frontends/tui.md b/docs/first-party/frontends/tui.md deleted file mode 100644 index 86b425f..0000000 --- a/docs/first-party/frontends/tui.md +++ /dev/null @@ -1,399 +0,0 @@ -# The reference TUI shell - -The first-party frontend provider: a full-screen terminal shell that paints [`RenderTree`](../../specifications/frontend/render-tree.md) content into the six-region vocabulary and turns operator input into [`ClientEvent`](../../specifications/frontend/frontend-protocol.md#client-events)s. - -> [!IMPORTANT] -> This document is descriptive, not normative. The protocol contract — what any frontend MUST implement — is [`frontend-protocol.md`](../../specifications/frontend/frontend-protocol.md) and [`render-tree.md`](../../specifications/frontend/render-tree.md). The layout, focus model, and keymap described here are *this* shell's choices, one conforming instantiation of the abstract region vocabulary, exactly as [`examples.md#the-reference-tui`](../../specifications/frontend/examples.md#the-reference-tui) frames it. A second frontend is free to resolve every one of them differently. - -## Why a shell framework exists at all - -The protocol defines *what* content arrives and *where* it is placed, and deliberately stops there. It defines no focus model, no keybinding schema, no terminal-resize semantics, and no scrollback behavior — [`conformance.md`](../../specifications/frontend/conformance.md) is explicit that the region vocabulary was designed against one reference implementation plus a thought experiment. Those four gaps are not oversights to be pushed back into the protocol; they are display concerns that only a concrete surface can answer. This document answers them for the terminal, so that widget authors and future integrations have stable, named places to attach to. - -The ordering matters: the shell's regions, focus ring, and keymap layers must exist before widget plugins are written, because a widget contributing to `sidebar` needs to know whether `sidebar` can hold focus, whether its `ActionNode`s are reachable from the keyboard, and what happens to it in a narrow terminal. - -## Process shape — who owns the terminal - -A frontend is a `hashicorp/go-plugin` subprocess, per [`plugin-runtime`](../../specifications/frontend/README.md#transport--lifecycle): the kernel is the gRPC *client*, the shell is the *server*, and the kernel calls `Attach` on it. That inverts the usual intuition — the process painting the screen is the child, not the parent — and it creates the single most load-bearing constraint in this design. - -**go-plugin owns the subprocess's standard streams.** The handshake line is written to the plugin's `stdout`, and after handshake the host pipes the plugin's `stdout`/`stderr` into its own logger. A TUI that renders to `stdout` therefore corrupts the handshake, and one that reads `stdin` competes with the plugin transport. - -The shell resolves this by never touching the standard streams for display: it opens the controlling terminal directly and hands that file to Bubble Tea as both input and output. - -```go -tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) -// ... -prog := tea.NewProgram(model, - tea.WithInput(tty), - tea.WithOutput(tty), - tea.WithContext(ctx), -) -``` - -Note there is no `tea.WithAltScreen()`: in Bubble Tea v2 the alt screen is a property of the `View` the model returns, not a program option. - -Consequences that follow from this and are not negotiable: - -- `stdout` and `stderr` remain go-plugin's. Nothing in the shell may `fmt.Println`. Diagnostics go through `slog` (which the host collects from `stderr` as structured plugin logs) or the kernel's `Log` callback — never to the painted surface. -- The shell requires a controlling terminal. Launched without one (CI, a daemonized kernel, a piped session), opening `/dev/tty` fails and the shell MUST degrade to a non-painting mode rather than crash the kernel that spawned it — it reports `FRONTEND_ERROR_CATEGORY_UNKNOWN` at `Configure` time and declines to attach. -- Windows uses `CONIN$`/`CONOUT$` in place of `/dev/tty`; this is the one genuinely platform-forked file in the tree, isolated behind a build-tagged `openTTY()` so nothing else needs to care. - -### Owning the whole screen - -Painting to the right file is only half of taking over a terminal. The shell also claims the surface itself, through properties of the `View` it returns each frame: - -| Property | Effect | -|---|---| -| `AltScreen` | Full-screen takeover, leaving the user's scrollback untouched on exit | -| `BackgroundColor` / `ForegroundColor` | Sets the terminal's own default colors to the theme's, so nothing shows through as "not part of the app" | -| `WindowTitle` | Names the session in the terminal's title bar | -| `Cursor` | Places the **real** terminal cursor in the composer, colored and blinking, and hides it whenever the composer does not own the keyboard | -| `MouseMode` | Claims the mouse, so the wheel scrolls the transcript instead of the terminal's own scrollback behind the alt screen | - -Claiming the mouse is what closes the last gap in the takeover. Without it the wheel still appears to work — but it is scrolling the terminal's buffer *behind* the alt screen, not the conversation. The cost is that drag-selection now needs the terminal's own override, which is shift+drag in most of them. - -The cursor point is worth stating plainly: the composer draws no caret glyph of its own. It reports its buffer position, the shell maps that onto absolute screen coordinates, and Bubble Tea places the actual cursor there — so it behaves exactly like the cursor in every other terminal application rather than being a drawn approximation of one. - -The last piece is the invariant that ties the frame together: **every frame is exactly `Height` rows of exactly `Width` cells**. An uncovered cell shows the terminal's own background and breaks the illusion; a row wider than the terminal wraps and shifts everything beneath it. This is asserted by test across a range of sizes down to 20×6, because it is otherwise only visible by eye. - -## The design system - -Before the layout, the vocabulary it is built from. Without a constrained token set, every pane picks its own padding, its own border color, and its own idea of what "muted" means, and the result is a collection of individually reasonable choices that does not look like one system. The shell therefore borrows the structure of a utility-first CSS framework, in two layers. - -**Layer one: raw palette.** `theme.Palette` is the literal color values, named for what they are — a ten-step neutral ramp from app background to strongest text, plus six intent hues. Nothing outside the theme package references it. - -**Layer two: semantic tokens.** `theme.Tokens` names every color by its *role*, and it is the only color vocabulary the rest of the shell sees: - -| Group | Tokens | -|---|---| -| Surfaces | `Background` (the application surface), `BackgroundPanel`, `BackgroundElement` | -| Text | `Text`, `TextMuted`, `TextSubtle`, `OnAccent` | -| Borders | `BorderSubtle`, `Border`, `BorderActive` | -| Intents | `Primary`, `Accent`, `Success`, `Warning`, `Danger`, `Info` | -| Diff | `DiffAdded`, `DiffRemoved`, `DiffContext`, `DiffHunkHeader` | - -The non-color half matters just as much: spacing is a scale (`Space0`..`Space4`, plus `Gutter`), and borders are a preset set. A pane that wants more padding picks the next step; it does not invent `3`. - -### One surface, and why - -**What makes the UI read as paneled is borders, titles, and spacing — not competing background colors.** The shell paints a single application surface, set once via the Bubble Tea `View`, and content styles carry a foreground and nothing else. - -This is a correctness rule before it is a taste one. Lip Gloss terminates every styled run with a full SGR reset, and a reset inside a container clears the background that container set. A text style carrying its own background therefore paints a band that stops exactly where the text stops, dropping the rest of the row back to the terminal's background: - -``` -ESC[48;2;21;25;34m <- container opens its background - ESC[38;2;158;206;106;…m styled ESC[m <- inner run ends with a FULL reset - " PADDING" <- so this lands on the terminal background -ESC[m -``` - -Filling broad regions is therefore unreliable in a way no amount of care at the call site fixes, and the visible result is a patchwork of bands at differing widths across every pane. `BackgroundPanel` and `BackgroundElement` remain in the token set, but they are for genuinely filled, self-contained controls — a status badge, a focused button — where the run opens and closes its own fill and cannot bleed. A test asserts that no content style sets a background. - -**Utilities and components.** `internal/tui/ui` is the terminal analogue of utility classes: `Style` is a chainable builder where each method sets exactly one property, so a pane's appearance reads as a sentence where it is used — `ui.New().Bg(t.C.BackgroundPanel).Fg(t.C.TextMuted).Px(theme.Space1)`. `Panel`, `StatusLine`, and `Badge` are compositions of those utilities, not escapes from them. - -Two cell-accuracy rules fall out of this and are load-bearing rather than cosmetic. Every component covers **every cell it claims**, because an uncovered cell shows the terminal's own background and breaks the illusion of a full-screen application. And **tabs are expanded to spaces on the way in**: a tab measures as zero cells but a terminal advances to the next tab stop when it draws one, so an unexpanded tab in producer content paints wider than it measures, overflows its pane, and corrupts every row to its right. - -## Screen layout - -Six abstract regions map onto terminal geometry as follows. This is the wide layout, at or above 100 columns: - -``` - pluggableharness session-01ABC claude-opus-5 ready <- top_bar - ╭─ conversation ────────────────────────────────╮ ╭─ git ──────────────╮ - │ Reference TUI shell │ │ branch: main │ - │ ▾ read_file(internal/tui/shell/model.go) │ │ 3 modified │ <- sidebar: - │ func (m *Model) View() tea.View { │ │ [ Review diff ] │ one panel - │ @@ -12,3 +12,4 @@ │ ╰────────────────────╯ per widget - │ - return Layout{} │ ╭─ context ──────────╮ - │ + l := Layout{Width: width} │ │ 42% of budget │ - │ [ Compact context ] │ ╰────────────────────╯ - ╰───────────────────────────────────────────────╯ - ╭─ message ───────────────────────────────────────────────────────────╮ <- input_bar - │ › ask anything, or / for commands │ - ╰─────────────────────────────────────────────────────────────────────╯ - enter send · alt+enter newline · tab focus · ctrl+c interrupt input <- hotkey_hints - -overlay: a centered pane composited over the frame, on the element surface - with an active border — never inline, and never blanking what is - behind it. See "Overlay is modal" below. -``` - -Every region is a panel or a bar, and each carries a title in its top border rather than on a row of its own — that buys back a line of content per pane, which is what makes a stack of small side panels affordable. - -A panel may also carry a **caption** in its bottom border, against the right corner. It is for the second thing a panel often has to say about itself: not what it is, but how it is currently configured. Splitting the two across the diagonal gives each a corner to own, so neither has to be found inside a long run of text. The caption is never clipped — an abbreviated model name names no model — so it renders whole or drops out entirely on a narrow terminal. - -**The sidebar is one panel per contributing producer, not one column of concatenated text.** This is the affordance widget authors design against: a titled panel makes it obvious which plugin contributed what, and lets a single widget be focused and act on without its neighbors coming along. - -Layout is solved top-down in a fixed order so it stays deterministic: `top_bar` takes 1 row, the composer takes its measured content height (clamped to 6) plus its border, `hotkey_hints` takes 1 row, and the body receives every row left over. Horizontally, a one-cell gutter runs down each screen edge, `sidebar` is measured next (clamped to `[26, 38]` columns and never more than 40% of the terminal), and the main panel takes the remainder. `Layout` exposes only *outer* boxes; interior sizes come from its `Inner` helpers so no caller open-codes the border-and-padding arithmetic. - -### Responsive degradation - -The protocol's graceful-fallback rule ([`render-tree.md:136`](../../specifications/frontend/render-tree.md)) says placement is a hint the frontend may reinterpret, never a mandate. This shell reinterprets in a fixed, documented order as space runs out, so behavior is predictable rather than emergent: - -| Constraint | Response | -|---|---| -| width `< 100` | `sidebar` leaves the layout and becomes a toggleable pane (`ctrl+b`). Its content is not dropped — it is reachable on demand. | -| width `< 64` | `sidebar` is unavailable entirely; its content folds into `main_chat`, per the spec's "fold it into another region" allowance. | -| height `< 12` | `hotkey_hints` is dropped first (it is a reminder, not content). | -| height `< 10` | `top_bar` is dropped; `main_chat` and `input_bar` are the last two regions standing. | -| any | `input_bar` and `main_chat` are never dropped. A shell that cannot show input or output is not a shell. | - -Dropping a region is logged once per transition at `debug`, never per frame, and never surfaces as `region_unsupported` to the kernel — the region is supported, the terminal is merely small, and the protocol's own open question notes those two conditions currently share one error category. The shell therefore reports neither and just adapts. - -## The content store - -Regions do not hold a tree; they hold an ordered set of *placements*, because multiple producers may target one region and coexistence — not exclusivity — is the documented default. - -```go -type producerKey struct{ category, name string } // server-derived identity -type placement struct { - producer producerKey - priority int32 - ranked bool // false == "unset", which sorts after every ranked entry - seq uint64 // kernel sequence; the sole tiebreak - tree *renderv1.RenderTree -} -``` - -Two placement behaviors, selected by `PlacedContent.replace`: - -- **Append** (`main_chat`'s default): the placement is added to the region's transcript and never rewrites prior entries. This is the conversation flow. -- **Replace**: the placement supersedes *that producer's* prior entry in that region, leaving other producers' entries untouched. This is how a status widget updates without evicting its neighbors. - -Ordering is `(ranked, priority, seq)` ascending — unset priority sorts last, and `seq` is the only tiebreak. **Wall-clock time is never an input to ordering, and the store is never iterated as a map**, both of which the repository's determinism rules require. The practical effect is that two shells replaying the same session paint identical frames. - -Rendered output is derived state: it is recomputed from the store, never persisted, never cached to disk. - -### Streaming text - -`stream_delta` is the fast path and never round-trips through `Render`. The store keeps a live buffer keyed by `target_id`; consecutive deltas append to it and it paints as ordinary `main_chat` content. When the corresponding finished `render` arrives, it *replaces* the buffer rather than appending beside it — otherwise every streamed message would appear twice. Backfill never contains deltas, so the replay path exercises none of this. - -## Focus — the first gap the protocol leaves open - -Focus is a shell concept; the protocol has no notion of it. The model here is deliberately small. - -**Focus targets are regions, not nodes.** The ring is `input_bar → main_chat → sidebar → input_bar`, cycled with `tab` / `shift+tab`. `input_bar` holds focus at startup, because typing is the overwhelmingly common intent and a shell that requires a keystroke before it accepts text is hostile. - -**Within a focused region, an action cursor selects among that region's `ActionNode`s.** The protocol requires every `ActionNode` be interactive and that activation dispatch `action_trigger` with `tool_name`/`args`/`provider` unchanged. This shell satisfies that by assigning each actionable node a stable index in paint order; `↑`/`↓` move the cursor within the focused region and `enter` activates. Regions with no actionable nodes are skipped by the focus ring entirely, so `tab` never lands somewhere inert. - -**Overlay is modal and exclusive.** When overlay content exists, it takes focus unconditionally, the focus ring is suspended, and the previously focused region is restored when the overlay clears. This is what makes a plan-approval prompt un-missable, and it is the shell's answer to the protocol's requirement that overlay content be *visually distinct* from ambient content — here it is also behaviorally distinct. - -## Agents - -The shell carries an active **agent** — the profile a turn will run under — and `shift+tab` cycles it, which is the convention operators arrive with from other harnesses. The demo roster is three entries: - -| Agent | Tone | Meaning | -|---|---|---| -| `Code` | `primary` | Build and edit — the ordinary mode | -| `Plan` | `warning` | Read-only; nothing gets applied | -| `Chat` | `info` | Conversation only | - -**An agent is data, not code.** `Agent` is a name plus a *tone*, and the roster is expected to come from configuration — an `agent_profile` block naming a color — so nothing in the shell may assume the three built-ins exist. `WithAgents` replaces the roster wholesale; an empty one keeps the defaults, because the shell always needs something to display as active. - -**The color is a role, not a value.** Config says `color = "warning"`, `theme.ToneByName` resolves it, and the active theme decides what amber actually is. That is what keeps a custom theme able to recolor agents along with everything else, and it is why agents never name a hex value. - -The active agent is visible in three places, so the current mode is never a guess: a filled badge in the top bar, the composer's title, and the composer's border and prompt caret, both drawn in the agent's tone. The agent's tone stops at the title — the model caption in the opposite corner stays neutral, because the model is not what the agent switch recolors. Color alone is never the only signal — the name appears twice in text. - -> [!NOTE] -> **This selection is local state, and that is a protocol gap rather than a design choice.** The frontend protocol's `ClientEvent` set has no agent-profile variant, and a session's profile is fixed when the session is created — so there is currently no way to tell the kernel that the operator switched. The shell emits an `AgentSelected` action for the bridge to interpret, most plausibly as the profile for the *next* session or as a direct-invoke slash command. Resolving it properly means either a new `ClientEvent` variant or an explicit statement that agent switching applies at session-creation time only. That belongs in [`frontend-protocol.md`](../../specifications/frontend/frontend-protocol.md), not here. - -Cycling is a global binding, so it works from any focused region; it does not disturb focus, and `shift+tab` no longer moves focus backward. The focus ring is at most three entries, so cycling forward with `tab` reaches everything and a backward binding was not worth the key. `KeyMap.PrevFocus` keeps its field, unbound, for a future configuration. - -## Where session data lives - -Session data is placed by **how fast it changes**, not by what kind of thing it is. That single rule decides the whole layout: - -| Volatility | Data | Home | -|---|---|---| -| Where the work is | directory, repository | Header box | -| Session identity | session, run state | Header box, right | -| Who you are talking to | agent | Composer title | -| What is behind it | model, thinking, effort | Composer caption, bottom right | -| Detail, consulted occasionally | token split, lines read and changed | Sidebar — `usage` panel | -| **Volatile, changes every turn** | context, cache rate, cost, elapsed | One line under the composer | - -The reasoning is about attention rather than tidiness. Space beside the input box is the most-looked-at real estate on the screen, so it goes to the things that actually move. A field that is identical on turn one and turn fifty has not earned a place there — it belongs in the periphery, which is exactly where the empty space was. - -Two placements deserve their reasons stated. **The agent and the model both live on the composer, but in opposite corners.** Both belong beside the input — switching agent is what changes the model behind it, so separating them across the screen would make one change appear in two distant places. But they answer different questions and move at different rates: the agent is who you are talking to and changes on a keystroke, while the model is what is behind it and changes rarely. Run together in one title they became a four-part string in which neither was findable, and the agent's color bled onto settings it does not own. The agent takes the title, where the eye already goes for a panel's name; the model takes the caption, quiet until looked for. **Version-control detail is absent from shell chrome entirely:** a git widget already contributes branch and PR as ordinary sidebar content, and carrying them in the status bar too would give the operator two sources for one truth. Only the directory and repository — which the shell is told at startup and which never change — sit in the top bar. - -``` - ╭─ pluggableharness ───────────────────────────────────────────────────────────────────╮ - │ ~/code/aiagent │ pluggableharness/agent session-01DEMO [ready] │ - ╰──────────────────────────────────────────────────────────────────────────────────────╯ - ╭─ conversation ──────────────────────────────────╮ ╭─ usage ───────────────────────────╮ - │ │ │ out 9.1k │ - │ (empty space lives up here) │ │ in 165.2k │ - │ │ │ lines 4.8k read +612 -148 │ - │ Reference TUI shell │ ╰───────────────────────────────────╯ - │ ▾ read_file(internal/tui/shell/model.go) │ ╭─ git ─────────────────────────────╮ - │ @@ -12,3 +12,4 @@ │ │ feat/tui-shell │ - │ - return Layout{} │ │ 3 modified │ - │ + l := Layout{Width: width} │ │ pr #11 │ - │ [ Compact context ] │ │ [ Review diff ] │ - │ Streaming text arrives token by token. │ ╰───────────────────────────────────╯ - ╰─────────────────────────────────────────────────╯ - ╭─ Code · claude-opus-5 · extended · high ───────────────────────────────────────╮ - │ › ask anything, or / for commands │ - ╰──────────────────────────────────────────────────────────────────────────────────────╯ - context ━━━━━━━━──────────── 51.2k / 200k 26% │ cache 89% │ cost $0.42 │ elapsed 22m00s - ╭─ keys ───────────────────────────────────────────────────────────────────────────────╮ - │ enter send · shift+enter newline · shift+tab agent · tab focus input │ - ╰──────────────────────────────────────────────────────────────────────────────────────╯ -``` - -### Header and footer are boxes - -They are bordered panels, in the same visual language as everything between them, each carrying a title — the product name above, `keys` below. - -A background tint was tried first and does not work. At the contrast levels a dark theme lives at, a tinted row reads as a slightly-off *content* row rather than as a frame; the eye needs an edge, not a shade. A box gives it one for the cost of two rows. Those rows are real, so the header and footer are dropped outright on a short terminal rather than degrading to unboxed lines — one visual language is worth more than one extra row of transcript. - -The status line deliberately stays **unboxed** between the composer and the footer. It is what the two boxes are separating; boxing it too would leave three stacked frames and nothing to separate. - -### The transcript grows upward - -Content shorter than the viewport is pushed to the **bottom** of its panel, not left at the top. Every chat interface works this way, and the reason is the same here: the newest message belongs next to where the operator is typing. Top-anchoring instead strands the last message a screen away from the input and puts the empty space *between* the two things you are looking at — which is the worst possible place for it. Anchored to the bottom, the void sits above the conversation where it costs nothing. - -### The sidebar is the session dashboard - -Panels stack from the top: the shell's own `workspace` and `usage` first, then whatever widgets have contributed. They are rendered identically on purpose — a widget author looking at the sidebar should see one visual language, not shell chrome sitting apart from plugin content. - -A panel with no data is not rendered at all. An empty titled box is worse than no box. - -### The status line - -One row, and only what changes during a turn. **The context meter is the whole left side and grows into whatever the right group leaves** — cache, cost, and elapsed are pinned right, and the meter absorbs everything between. Context is alone on the left for a reason: a line drops left segments right-to-left, so anything beside it would survive at its expense, and context is the field worth keeping longest. - -The absolute figures ride immediately after the bar (`51.2k / 200k 26%`) rather than at the far edge. That placement is what lets the bar grow without the earlier failure mode, where a long meter ended in a percentage marooned halfway across the screen. - -**The bar is a gradient, not a single color.** It runs green through amber to red along its length, with the consumed run in full color and the remainder in a muted blend of the same gradient. A uniformly-amber bar tells you the current state; a gradient shows you the whole scale and where on it you sit. It remains a heavy stroke against a light one, so the measurement still reads on a monochrome terminal and never depends on telling green from red. - -The blend happens in **Oklab**, not in sRGB. Interpolating sRGB channels is the obvious implementation and it looks wrong: green to red passes through a muddy olive, the midpoint is darker than either end, and the result bands visibly because equal numeric steps are not equal perceptual steps. - -**The meter never blinks out while a terminal is resized.** It used to: the right-hand group was all-or-nothing, so a single column of width could make a field affordable and take the meter from drawable to below-its-minimum in one step. The right group now sheds one field at a time, and the segment reserves room for a usable bar rather than only for its text. - -Each line has a left and a right group so it spans its width. The right group sheds fields from its own right end until the left fits beside it whole — reserving its width first would let a secondary field evict a primary one, inverting the ranking that segment order expresses. A line with an empty left group still renders its right: the two are independent. - -When a filling segment has taken every spare cell, the space between the two groups is exactly one separator wide, because that is what was reserved for it — so the separator is drawn there. Leaving it blank produced a conspicuous hole with no divider to explain it. Without a filling segment the gap is genuine slack pushing the right group to the edge, and a divider stranded in the middle of it would only look lost. - -Key hints drop **whole bindings** from the end rather than being cut to width: a hint truncated mid-word reads as a rendering fault and tells the operator nothing. - -### Paths clip from the left - -The directory in the top bar keeps its tail. Given too little room, `…/aiagent/internal/tui` tells you where you are and `/home/steven/code/…` does not — so `ui.ClipLeft` cuts from whichever end preserves the part that identifies the thing. - -### Measuring context against the right number - -The denominator is `UsageUpdate.effective_ceiling`, not the model's raw `context_window`. The ceiling is what remains after the kernel reserves room for expected output and tool schemas, and the protocol names that pair as the one "a context-budget indicator divides to get pressure". Dividing by the raw window would understate pressure — you would read 70% while the next turn was already at risk of not fitting. - -**Unknown is not zero.** Before the first `UsageUpdate` the context segment is absent, and the same holds for a ceiling the kernel has not resolved. A meter confidently reading 0% before any turn has run is a lie that looks like a fact. - -Past 85% the hint line replaces the focus label with a plain warning. It names no command: compaction here is automatic — a context provider declaring `compactor: true` receives the conversation history and returns a rewritten one on its own initiative ([`context/protocol.md#session-wide-conversation-compaction`](../../specifications/context/protocol.md#session-wide-conversation-compaction)) — so there is no operator-invoked `/compact` to point at. - -### What the protocol actually supplies - -Only some of this has a wire source. The rest arrives as shell messages, which is deliberate: the shell performs no I/O, so anything it cannot be *told* it cannot know. - -| Field | Source | -|---|---| -| model, session, status | `ServerEvent` session lifecycle | -| context, cost, token split, cache rate | `UsageUpdate`, including the per-turn `model.v1.Usage` | -| effort, session length | `ModelSpec.thinking` and `SessionInfo.started_at` exist, but no frontend event carries them; the bridge resolves and passes them through | -| directory, repository | **No protocol source, by design.** There is no workspace concept in the wire contracts and none should be invented for a status bar; `cmd/tui` supplies these | -| branch, subtree, PR | Not rendered by the shell at all — a git widget contributes them as ordinary sidebar content, so there is one source for one truth | -| lines read and changed | **No protocol source.** No event aggregates per-tool line counts; a tool provider knows them, so a widget or a kernel-side rollup is the path | - -Session length arrives pre-computed rather than as a start time, because the model never reads the clock — whatever drives the shell decides how often it ticks. - -## Keymap layers — the second gap## Keymap layers — the second gap## Keymap layers — the second gap - -Bindings resolve in three layers, highest first: **overlay → focused region → global**. A layer that handles a key stops propagation. `hotkey_hints` renders the currently active layer's bindings, which is what makes the region meaningful rather than a static legend. - -| Layer | Binding | Action | -|---|---|---| -| global | `ctrl+c` | First press sends `interrupt`; second within 2s quits. Interrupting cascades to the whole sub-agent tree. | -| global | `ctrl+d` | Quit (only on an empty `input_bar`). | -| global | `tab` | Cycle focus. | -| global | `shift+tab` | Cycle the active agent. | -| global | `ctrl+b` | Toggle `sidebar` when narrow. | -| overlay | `y` / `n` | Allow / deny the focused plan item. | -| overlay | `e` | Edit arguments — opens the `corrected_input` editor. | -| overlay | `a` | Allow with `SESSION` scope. | -| overlay | `esc` | Dismiss where dismissal is meaningful; never silently resolves a pending decision. | -| main_chat | `↑`/`↓`, `pgup`/`pgdn`, `home`/`end` | Scroll; `end` re-pins to the live tail. | -| main_chat | `enter` | Activate the action under the cursor. | -| input_bar | `enter` | Submit as `user_message`. | -| input_bar | `shift+enter` | Insert a newline instead of submitting; the composer grows with it, up to six rows. | -| input_bar | `alt+enter`, `ctrl+j` | The same, for terminals that cannot disambiguate shift+enter. | -| input_bar | `↑`/`↓` | Prompt history, when the cursor is on the first/last line. | - -Plan decisions default to `PLAN_DECISION_SCOPE_ONCE`, which the protocol names as the scope a frontend SHOULD send absent explicit operator intent. `SESSION` and `ALWAYS` require the distinct keystrokes above — they are never inferred. - -There is no protocol-level keybinding registration, so a widget cannot claim a key. Widgets expose affordances as `ActionNode`s and reach the keyboard through the action cursor. This is a deliberate limitation: it keeps the keymap total and conflict-free, at the cost of widgets not being able to bind accelerators. - -## Painting a RenderTree - -The painter is a pure function from `*renderv1.RenderNode` plus a width to a styled string. It holds no terminal state, which is what lets the whole node vocabulary be tested headlessly on every CI platform including Windows. - -| Node | Treatment | -|---|---| -| `TextNode` | Styled per `TextStyle`; unset means the theme's default, distinct from an explicit `normal`. | -| `CodeBlockNode` | Bordered block, language label when set. No syntax highlighting in the skeleton. | -| `DiffNode` | Hunk headers dim, `+` green, `-` red, context plain. | -| `TableNode` | Column-aligned; flat string cells only, as the protocol defines it. | -| `LinkNode` | Label plus a dimmed URL, so the target stays visible in any terminal. | -| `ListNode` | Bulleted or numbered; recurses. | -| `GroupNode` | Transparent — no border, no indent, no label. Adding chrome here would violate the node's stated meaning. | -| `CollapsibleNode` | Summary line with a disclosure marker, honoring `collapsed_by_default`; expandable via the action cursor. | -| `SubSessionNode` | A one-line pointer to the child session with its summary — never inlined. | -| `ActionNode` | A button-styled affordance, highlighted when it is under the cursor. | - -**Unknown node types are the interesting case.** The protocol requires a frontend to render gracefully any variant added after it shipped, and `pkg/frontend`'s existing `FallbackText` already implements exactly that traversal. The painter delegates to it rather than reimplementing the fallback, and a `render_failed` on one node degrades that subtree to fallback text — it never crashes the process, which the error taxonomy states as a MUST. - -## Theme - -A small token set, not a general theming engine: one `Theme` struct mapping each `TextStyle` plus the shell's own chrome roles (border, focused border, cursor, backdrop, region title) to a `lipgloss.Style`. Two built-ins, dark and light, selected from the terminal's detected background with an explicit config override. Lip Gloss v2 degrades color automatically down to 16-color and monochrome terminals, so the tokens are authored once in truecolor. - -Keeping this a token table rather than per-call styling is what allows a later config-driven theme without touching the painter. - -## Wiring the kernel stream to Bubble Tea - -Two event loops meet here, and the bridge between them is the whole integration: - -- **Inbound**: the `Attach` stream's `ServerEvent`s are read on their own goroutine, translated one-to-one into `tea.Msg` values, and delivered with `Program.Send`. No kernel type reaches the Bubble Tea model unconverted. -- **Outbound**: operator actions become `tea.Cmd`s that write `ClientEvent`s to a buffered channel; a single writer goroutine drains it into the stream, preserving arrival order — which matters because the kernel processes `ClientEvent`s in arrival order per session and resolves decisions first-response-wins. - -The shell must handle a decision it did not win: a second response to an already-resolved item is rejected with an `invalid_client_event`-category error specifically so the UI can show "already decided elsewhere" instead of appearing to hang. The overlay renders that outcome rather than swallowing it. - -Because `Attach` is connection-scoped and multiplexes sessions by `session_id`, the shell keeps one store per attached session and paints the focused one, with `session_tree_update` driving a sub-session indicator in `top_bar`. - -## Package layout - -``` -cmd/tui/ thin entrypoint: flag parsing, TTY open, program wiring -internal/tui/theme/ the design tokens: palette -> semantic Tokens, the - spacing scale, and the border presets -internal/tui/ui/ the utility layer: the chainable Style builder plus - Panel, StatusLine, and Badge built from it -internal/tui/paint/ RenderTree -> styled string (pure, headless-testable) -internal/tui/region/ the placement store, ordering, streaming buffers -internal/tui/shell/ the Bubble Tea model: layout, focus, keymap, compose, - the EventSource seam, and a scripted demo source -``` - -The dependency direction is one-way and worth keeping that way: `theme` knows nothing, `ui` consumes `theme`, `paint` consumes both, and `shell` composes all of them. A color or a spacing value introduced at a call site in `shell` — rather than added to the scale in `theme` — is the failure mode this layering exists to prevent. - -`EventSource` is declared in `shell` rather than in a package of its own, because that is where it is consumed and the shell needs exactly one method of it — the house rule is to define an interface as narrowly as its consumer needs, at the consumer. When the real gRPC bridge arrives it becomes a second implementation beside the demo one; if a third ever appears, that is the point to promote the seam to the interface/driver layout, not before. - -Each package carries the `README.md` + `CLAUDE.md` pair the layout rules require. All four are pure — no I/O, no terminal, no logging — which puts them under the pure-domain exemption for instrumentation and is what lets the whole shell be tested by calling `Update` directly, with no TTY and no kernel. Logging lives in `cmd/tui`, which is where the process boundary actually is. - -### A note on shift+enter - -A bare terminal cannot distinguish `shift+enter` from `enter`: both are carriage return. Bubble Tea negotiates key disambiguation at startup (the Kitty keyboard protocol, plus `modifyOtherKeys` level 2), which makes the distinction available on terminals that support it — and most modern ones do. - -Because that negotiation can fail, `alt+enter` and `ctrl+j` are bound to the same action. `ctrl+j` is literally line feed and works everywhere, so there is always a way to insert a newline no matter what the terminal supports. - -## Deliberately deferred - -- **Syntax highlighting** in `CodeBlockNode` — a real dependency decision, not skeleton work. -- **Telling the kernel which agent is active.** See the note under "Agents": the protocol has no client event for it. The shell tracks and displays the selection; conveying it needs a protocol answer first. -- **The kernel-side attach path.** No `internal/` code launches a frontend plugin and drives its `Attach` stream yet; `cmd/agent` is non-interactive and `internal/interactive/drivers` still flags its frontend-backed driver as pending. Until that lands, `drivers/fake` is what makes the shell runnable, and it is a test fixture rather than a shipping path. -- **Widget hosting.** The shell renders widget-contributed `RenderTree`s like any other producer's, so nothing widget-specific is needed here — but no widget plugin exists to contribute yet. diff --git a/docs/specifications/agent-loop/plan-apply-gate.md b/docs/specifications/agent-loop/plan-apply-gate.md index bde98da..2c2c3a7 100644 --- a/docs/specifications/agent-loop/plan-apply-gate.md +++ b/docs/specifications/agent-loop/plan-apply-gate.md @@ -74,7 +74,7 @@ The `interactive`-kind precheck reuses the `data_source` precheck's defaulting a [`frontend/frontend-protocol.md`](../frontend/frontend-protocol.md)'s `ClientEvent.PlanDecision` carries a `scope` field (`PlanDecisionScope`: `ONCE`/`SESSION`/`ALWAYS`) alongside `decision` and `corrected_input`. `scope` governs how durably the resolved decision applies beyond the one `PlanItem` it names — it is evaluated by the plan/apply gate at the same point `decision` and `corrected_input` are, immediately on receipt of a `plan_decision` client event, not deferred to any later stage: - **`ONCE`** (the default a frontend SHOULD send absent explicit operator intent): applies to the named `PlanItem` only. No durable record beyond the ordinary `plan_items` audit row this decision produces regardless of scope. -- **`SESSION`**: the kernel MUST remember this verdict for the rest of the current session, in memory — not written to `agent.hcl` or any persisted policy store — and apply it automatically to any future plan item in the same session matching the same `(provider, operation_name)` pair, without re-emitting a `permission_request`/blocking on a fresh `plan_decision`. A `SESSION`-scoped `deny` suppresses future `ask`/`allow` items the same way; a `SESSION`-scoped `allow` (with or without `corrected_input`) auto-applies the decision (re-validating `corrected_input` against the current call's own arguments each time, per [`frontend/frontend-protocol.md#plan_decisioncorrected_input`](../frontend/frontend-protocol.md#plan_decisioncorrected_input) — a `SESSION` scope remembers the *verdict*, not a frozen copy of the corrected arguments). This rule lapses at session end; it does not survive a `ResumeSession` re-open ([`frontend/frontend-protocol.md#resume-and-re-open-semantics`](../frontend/frontend-protocol.md#resume-and-re-open-semantics)) into a fresh session of rules. +- **`SESSION`**: the kernel MUST remember this verdict for the rest of the current session, in memory — not written to `agent.hcl` or any persisted policy store — and apply it automatically to any future plan item in the same session matching the same `(provider, operation_name)` pair, without re-emitting a `permission_request`/blocking on a fresh `plan_decision`. A `SESSION`-scoped `deny` suppresses future `ask`/`allow` items the same way; a `SESSION`-scoped `allow` (with or without `corrected_input`) auto-applies the decision (re-validating `corrected_input` against the current call's own arguments each time, per [`frontend/frontend-protocol.md#plan-and-interactive-resolution`](../frontend/frontend-protocol.md#plan-and-interactive-resolution) — a `SESSION` scope remembers the *verdict*, not a frozen copy of the corrected arguments). This rule lapses at session end; it does not survive a `ResumeSession` re-open ([`frontend/frontend-protocol.md#session-lifecycle`](../frontend/frontend-protocol.md#session-lifecycle)) into a fresh session of rules. - **`ALWAYS`**: the kernel MUST persist this verdict as policy — surviving beyond the current session, applying to future sessions under the same profile — via the same policy-rule mechanism [`configuration/policy-dsl.md`](../configuration/policy-dsl.md) already governs for operator-authored rules. This requires kernel-side policy persistence: a kernel build that cannot durably write a new policy rule (e.g. no writable policy store configured) MUST reject an `ALWAYS`-scoped `plan_decision` with a distinct error rather than silently downgrading it to `SESSION` or `ONCE` — a frontend and its operator need to know an "always allow this" request didn't actually stick, not discover it the next time the same prompt reappears. An `ALWAYS`-scoped decision, once persisted, takes effect starting with the *next* plan-ready evaluation it would match — it does not retroactively alter the plan item that triggered it, which has already been decided via the ordinary `decision`/`corrected_input` fields. `SESSION` and `ALWAYS` are both strictly broader than what the underlying per-item decision unit ([Plan construction and policy evaluation](#plan-construction-and-policy-evaluation) above) requires — they are a frontend/operator convenience layered on top of, not a replacement for, per-item evaluation: policy still runs and still produces an independent decision for every item in every future plan, it's simply that a `SESSION`/`ALWAYS` rule can now be one of the things that decision is based on. diff --git a/docs/specifications/context/protocol.md b/docs/specifications/context/protocol.md index ee8c4f6..a1a375c 100644 --- a/docs/specifications/context/protocol.md +++ b/docs/specifications/context/protocol.md @@ -51,7 +51,7 @@ A non-compactor provider whose `Contribute` response mutates a section it doesn' Context providers MAY implement `Render` per the general Emit→Render→Paint pipeline ([`architecture.md`](../architecture.md#emit--render--paint-pipeline)), returning the `RenderTree` formally defined in [`frontend/render-tree.md`](../frontend/render-tree.md) — e.g. to render an injected CLAUDE.md section collapsed by default in a transcript view, distinct from the live conversation. If not implemented, the kernel falls back to its generic default rendering. -`RenderRequest` carries `schema_version` alongside the opaque `payload` — see [`frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning) for what the value means and how a `Render` implementation is expected to use it. +`RenderRequest` carries `schema_version` alongside the opaque `payload` — see [`frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads`](../frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads) for what the value means and how a `Render` implementation is expected to use it. ## `Describe` diff --git a/docs/specifications/event-bus.md b/docs/specifications/event-bus.md index 88c99f0..d91135e 100644 --- a/docs/specifications/event-bus.md +++ b/docs/specifications/event-bus.md @@ -59,6 +59,13 @@ The per-stream bound is an `event_bus{}` config value (`configuration/blocks-ref - **`kernel.event.{kind}`** — republished by `Emit` immediately after a successful persisted write, where `{kind}` is the lowercase text form of the persisted `EventKind` (`state-backend.md#the-kind-enum`'s own vocabulary — e.g. `kernel.event.tool_call`, `kernel.event.message`). This lets a plugin observe the durable event stream live without polling `ReadEvents`, while the durability guarantee is untouched: the sqlite row is committed before the republish happens, so a subscriber that never connects, or that disconnects mid-stream, loses nothing durable — it can always fall back to `ReadEvents` for anything it missed. +Additional kernel-originated topics in this revision: + +- **`kernel.state`** — republished when a session's `SessionState` snapshot changes (working directory, model, context pressure, turn count, …). Payload carries `session_id` (not in the topic name — keeps topic cardinality bounded). Frontends pair `GetSessionState` with a subscription here. +- **`kernel.metadata`** — republished on every `PublishMetadata` / `RetractMetadata` / producer-disconnect liveness flip. Payload is a `metadata.v1.MetadataBlock` (includes `session_id`). Frontends pair `ListMetadata` with a subscription here. + +**Token deltas do not use the bus.** `StreamDeltas` is a separate server-streaming RPC on the callback channel (no topic matching, no shared subscriber queue). See `kernel-callbacks.md` and `frontend/frontend-protocol.md#token-fast-path`. + A future kernel-originated topic (a turn-lifecycle notification, a plan-ready signal mirrored onto the bus for observability) has a home in this namespace without a naming collision with any plugin's own topics, since no plugin can ever construct a `kernel.*` topic. ## Open questions diff --git a/docs/specifications/frontend/README.md b/docs/specifications/frontend/README.md index eb3a77a..0fe5f8d 100644 --- a/docs/specifications/frontend/README.md +++ b/docs/specifications/frontend/README.md @@ -1,37 +1,45 @@ # Frontend & widget provider protocols -Covers **two** plugin categories in one directory, both concerned with what the operator sees and does, neither owning the agent loop itself: +Covers **two** plugin categories concerned with what the operator sees and does, neither owning the agent loop itself: -- **Frontend provider** ([`frontend-protocol.md`](frontend-protocol.md)) — owns the terminal (or window, or voice channel): the process the kernel's state-event stream attaches to, responsible for actually painting pixels/text and turning operator input into `ClientEvent`s. -- **Widget provider** ([`widget-protocol.md`](widget-protocol.md)) — contributes content *into* whichever frontend is active, without owning it. This is a genuine sixth plugin category, not merely an extension of the other six (see [`architecture.md`](../architecture.md#the-seven-provider-categories)) — a git-status panel or a context-budget indicator isn't naturally "a tool" or "a context provider," it just wants to put something on screen. +- **Frontend provider** ([`frontend-protocol.md`](frontend-protocol.md)) — owns how the operator sees and types (terminal, window, voice channel, HTTP surface). The process that paints state and turns operator input into kernel-callback RPCs. +- **Widget provider** ([`widget-protocol.md`](widget-protocol.md)) — contributes typed metadata (or other plugin-side work) without owning the frontend. A genuine category, not merely an extension of the other six (see [`architecture.md`](../architecture.md#the-seven-provider-categories)). -Both categories share one vocabulary, formalized once and reused verbatim: the [`RenderTree`](render-tree.md#rendertree) intermediate representation (text runs, code blocks, diffs, tables, links, a sub-session node, an interactive `action` node) and the region/placement model ([`render-tree.md`](render-tree.md#placement--regions)). Every other plugin category's optional `Render` RPC — [`model/protocol.md#render`](../model/protocol.md#render), [`tool/protocol.md#render`](../tool/protocol.md#render), and the equivalent sections in `context/` and `memory/` — returns exactly this type; this directory is where the type itself is formally, canonically defined. +## Four surfaces -The wire protocol for both categories, plus the shared `RenderTree` IR, is defined as gRPC services with protobuf messages — see [`examples.md`](examples.md) for the schema. `RenderTree` is deliberately factored into its own shared vocabulary rather than nested inside the frontend or widget definitions: both the frontend and widget protocols place content into the same `Region` vocabulary. Slash commands are split the same way content is, but along a different line: a **direct-invoke** command is owned exclusively by the `slashcommand` plugin category — its `SlashCommandSpec` is defined once, canonically, in [`../slashcommand/README.md`](../slashcommand/README.md), not here — while a **prompt-expansion** command is genuinely shared vocabulary, declarable directly in any provider category's own capability response (not just frontend/widget) as a `pluggableharness.common.v1.PromptExpansionSpec` — see [`frontend-protocol.md#slash-commands`](frontend-protocol.md#slash-commands). +The operator-facing model is four **kernel-held surfaces**, not a set of plugin-writable screen regions: -## Transport & lifecycle +| Surface | What it is | Mechanism | +|---|---|---| +| **Input** | A capability, not a place — TUI prompt, web form, CLI, voice | Unary RPCs on `KernelCallbackService` (frontend → kernel) | +| **State** | "Where am I" — fixed schema, kernel-owned | `GetSessionState` snapshot + `kernel.state` bus deltas | +| **Metadata** | Keyed collection of typed blocks, plugin-owned | `PublishMetadata` / `RetractMetadata` / `ListMetadata` + `kernel.metadata` | +| **Transcript** | The conversation stream | `ReadEvents` backfill + `kernel.event.*` live; **the one place `RenderTree` remains** | -Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). Standard handshake (magic cookie, protocol version negotiation) applies uniformly across all seven provider categories and isn't repeated per category. +`RenderTree` survives only in the transcript because a tool result's presentation really is producer-specific (a diff, a scrollback pane, a collapsible sub-agent node — [`tool/protocol.md#render`](../tool/protocol.md#render)). The other three carry typed data and let the frontend decide presentation entirely. -The two categories' primary RPC has **different streaming shapes**, and this is the one thing about this directory most worth getting right, since it's easy to conflate the two: +## Transport -- A **frontend** provider plugin exposes `GetCapabilities`, `Configure`, `Attach`, `Describe`. `Attach` is **genuinely bidirectional and connection-scoped** — one stream per connection, not one per session — the frontend sends `ClientEvent`s (operator input, session lifecycle control) and receives `ServerEvent`s (kernel state, session lifecycle acknowledgment) on the same live stream, because the operator can type a message while prior content is still rendering, and because full session lifecycle needs connection-level operations that have no natural per-session home. This is one of only two truly bidirectional RPCs anywhere in this protocol series, the other being the kernel-callback channel; every other category's primary RPC (`StreamCompletion`, `Invoke`) is server-streaming-plus-cancellation. See [`frontend-protocol.md#transport`](frontend-protocol.md#transport). -- A **widget** provider plugin exposes `GetCapabilities`, `Configure`, `Attach`, `Describe` too — same RPC names, **different `Attach` shape**: widget `Attach` is **server-streaming only**, and remains session-scoped (one call per session, via `AttachRequest.session_id`) rather than connection-multiplexed. A widget is passive/display-only in v1; it never sends anything back over this channel. A widget wanting to trigger an action does so by also being a tool provider with a slash command, not through `Attach`. See [`widget-protocol.md#transport`](widget-protocol.md#transport). +There is **no** frontend or widget `Attach` stream. Under `hashicorp/go-plugin` the plugin is the gRPC server, so the only direction that lets the kernel push streams into a plugin is the **callback channel**, where the plugin is the client ([`kernel-callbacks.md`](../kernel-callbacks.md)). That channel is given to every category unconditionally. -Both plugins MAY additionally implement `Render`, per the general Emit→Render→Paint pipeline ([`architecture.md`](../architecture.md#emit--render--paint-pipeline)) — though in practice a frontend/widget is far more often a `Render` *consumer* (painting other categories' trees) than a producer of its own. +- **Kernel → frontend:** `Subscribe` (topic-filtered bus), `StreamDeltas` (token fast path, out-of-band re: bus), `ReadEvents` (durable backfill). +- **Frontend → kernel:** unary RPCs on the same channel (`SubmitInput`, session lifecycle, plan/interactive resolution, metadata publish/list, …). +- **`FrontendService` / `WidgetService`:** only `GetCapabilities`, `Configure`, `Describe` — the same triple every other category has. + +The callback channel is the **only** genuinely bidirectional transport surface in the protocol series (application RPCs on it are unary or server-streaming). See the repository's gRPC rule, `.claude/rules/grpc.md`. ## Session scope — multi-attach -**Multiple frontends MAY subscribe to the same session concurrently** — a TUI and a web tail both watching one live session, for example — each on its own connection-scoped `Attach` stream (`frontend-protocol.md#session-lifecycle`). This follows naturally from how widgets already work: any number of panels can subscribe to the same hook stream, so frontends support the same multiplicity rather than being constrained to a single attachment. +**Multiple frontends MAY subscribe to the same session concurrently** — a TUI and a web tail both watching one live session — each via their own callback connection and their own `Subscribe` / `StreamDeltas` / `ReadEvents` calls. -- **`ServerEvent`s for a given session broadcast identically to every frontend subscribed to that session.** No partitioning, no "primary" frontend — every subscribed frontend sees that session's live stream, in the same order. A frontend's own `Attach` stream may carry several sessions at once, each broadcasting independently to whichever frontends are subscribed to it. -- **`ClientEvent`s are processed in kernel arrival order, per session**, with one specific arbitration rule for decisions that can only be honored once. See [`frontend-protocol.md#session-scope`](frontend-protocol.md#session-scope) for the full rule (first-response-wins on `plan_decision`/ `interactive_response`, with a distinct rejection error for a late-arriving second response). -- **Attaching a session that's already in progress backfills its history first** — a bracketed replay batch (`session_attached` → replayed `render`s → `backfill_complete`) unicast to the newly attaching stream only, never re-broadcast to frontends already subscribed. See [`frontend-protocol.md#backfill--the-replay-path-not-a-new-subsystem`](frontend-protocol.md#backfill--the-replay-path-not-a-new-subsystem). +- State, metadata, and transcript events for a session broadcast identically to every frontend that has attached that session. +- Resolving decisions (`ResolvePlanDecision`, `ResolveInteractive`) is first-response-wins per pending item; a late second response is rejected with a distinct error. +- Attaching a session already in progress backfills history via `ReadEvents` (and metadata via `ListMetadata`); anything missed after snapshot-then-subscribe is recoverable because `Emit` commits to sqlite before republishing onto `kernel.event.{kind}` ([`event-bus.md`](../event-bus.md)). ## Category structure -- [`render-tree.md`](render-tree.md) — the `RenderTree` IR itself: every node type, the placement/region vocabulary, schema versioning for opaque `Render` payloads, and why both categories share one definition. The canonical reference every other category's `Render` points back to. -- [`frontend-protocol.md`](frontend-protocol.md) — the frontend provider protocol: transport, the connection-scoped multiplexed `Attach` stream, fast-path text deltas vs. full `Render`, full session lifecycle (create/attach/resume/detach/list, backfill, no deletion), session scope/multi-attach, slash commands (registry aggregation across the direct-invoke and prompt-expansion lists, `PromptExpansionSpec` defined once, canonically, here), the `plan_decision.corrected_input`/`scope` redirect, and the error taxonomy for this category. -- [`widget-protocol.md`](widget-protocol.md) — the widget provider protocol: transport (server-streaming, not bidi), deriving display state from `observe`-mode hooks with no new data feed, interactive widgets via the `action` `RenderNode`, and the `WidgetError` taxonomy. -- [`examples.md`](examples.md) — wire-protocol excerpts for all three schemas, a worked frontend `Attach` sequence (attach → backfill → plan-ready → render → approve/reject/edit), and a worked widget example (a status-bar widget deriving state from the same event stream a frontend sees). -- [`conformance.md`](conformance.md) — the error taxonomy for both categories, the MUST/SHOULD/MAY summary matrix, and any genuinely open questions. +- [`render-tree.md`](render-tree.md) — the `RenderTree` IR (transcript only). No placement regions. +- [`frontend-protocol.md`](frontend-protocol.md) — frontend provider protocol and how a frontend consumes the four surfaces. +- [`widget-protocol.md`](widget-protocol.md) — widget provider protocol; screen presence is `PublishMetadata`. +- [`examples.md`](examples.md) — worked sequences. +- [`conformance.md`](conformance.md) — MUST/SHOULD/MAY matrix and the acceptance criterion (a second frontend). diff --git a/docs/specifications/frontend/conformance.md b/docs/specifications/frontend/conformance.md index 2ec2510..848ed1c 100644 --- a/docs/specifications/frontend/conformance.md +++ b/docs/specifications/frontend/conformance.md @@ -1,60 +1,26 @@ -# Frontend & widget — conformance +# Frontend & widget conformance -## Error taxonomy - -The frontend provider category has a structured error type, `FrontendError`: +## Summary matrix -| Category | Meaning | Requirement | +| Requirement | Level | Reference | |---|---|---| -| `render_failed` | A specific `RenderTree` node couldn't be painted (e.g. a malformed `diff`). | MUST fall back to a generic text rendering of whatever content is recoverable; MUST NOT crash the frontend process over one bad node. | -| `invalid_client_event` | Malformed input on the operator-facing side, including a `plan_decision`/`interactive_response` naming an already-resolved item. | MUST be surfaced distinctly, not collapsed into `unknown` — rare in the ordinary case since the frontend itself constructs `ClientEvent`s. | -| `region_unsupported` | A producer targeted a `Region` this frontend has no fallback behavior for at all. | SHOULD be logged; MUST NOT be treated as fatal. | -| `unknown` | Anything else. | MUST include enough detail for debugging; treated as non-retryable by default. | - -A `Configure`-time `FrontendError` surfaces as a gRPC status carrying the error in its structured detail, mapped to `codes.InvalidArgument` for a malformed config value and `codes.Internal` for anything unmapped — the same canonical "specific code, never a bare `codes.Unknown`" discipline every category in this protocol series follows. An error encountered mid-`Attach` (a bad render, an invalid client event) surfaces in-band as `ServerEvent.error` instead, since `Attach` is a long-lived stream where tearing down the whole connection over one recoverable error would be far too disruptive — only a genuinely fatal condition (the plugin process itself failing) closes the stream with a gRPC status. +| FrontendService is GetCapabilities / Configure / Describe only | MUST | [frontend-protocol.md](frontend-protocol.md#transport) | +| No Attach stream on frontend or widget | MUST | [README.md](README.md#transport) | +| Four surfaces: input, state, metadata, transcript | MUST | [README.md](README.md#four-surfaces) | +| SessionState fixed schema, per-session | MUST | [frontend-protocol.md](frontend-protocol.md#state) | +| SubmitInput returns turn_id | MUST | [frontend-protocol.md](frontend-protocol.md#input) | +| MetadataBlock closed body oneof; Tone token scale; never delete | MUST | [frontend-protocol.md](frontend-protocol.md#metadata) | +| StreamDeltas out-of-band re: bus; kernel does not batch | MUST | [frontend-protocol.md](frontend-protocol.md#token-fast-path) | +| RenderTree transcript only; no Region/PlacedContent | MUST | [render-tree.md](render-tree.md) | +| Every RenderNode variant graceful fallback | MUST | [render-tree.md](render-tree.md) | +| Multi-attach; first-response-wins on plan/interactive | MUST | [frontend-protocol.md](frontend-protocol.md#multi-attach-arbitration) | +| Widget screen presence via PublishMetadata | MUST | [widget-protocol.md](widget-protocol.md) | +| Structured FrontendError / WidgetError taxonomies | MUST | this file | -**The widget provider category has its own structured error type, `WidgetError`** ([`widget-protocol.md#error-taxonomy`](widget-protocol.md#error-taxonomy)), mirroring `FrontendError`'s category/message shape. Unlike the frontend category, widget `Attach` has no in-band return channel — it's server-streaming only — so `WidgetError` is always carried in the structured detail of a gRPC status on `Configure` or `Attach`, mapped per the same canonical table (`codes.InvalidArgument`/`codes.Internal`/`codes.Canceled`), never as an in-band stream message. This resolves what was previously an open question in this document about whether widgets needed a categorized error channel at all — they do, for the same reason a partial-failure condition (e.g. "this update renders for one region but not another") needs a name a widget author can report distinctly from an unstructured `codes.Internal`. - -## Required vs. optional support — summary matrix +## Error taxonomy -| Capability | Level | Notes | -|---|---|---| -| `RenderTree` node types render gracefully, including unknown/unspecialized ones | MUST | [`render-tree.md`](render-tree.md#rendertree) | -| Placement is a hint, never a mandate | MUST (frontend may reinterpret) | [`render-tree.md`](render-tree.md#placement--regions) | -| `overlay` visually distinct from ambient content | MUST | [`render-tree.md`](render-tree.md#placement--regions) | -| `Render` payloads carry a `schema_version`, echoed unchanged from emit time | MUST | [`render-tree.md`](render-tree.md#schema-versioning) | -| Frontend `Attach` is bidirectional and connection-scoped, multiplexing every subscribed session | MUST | [`frontend-protocol.md`](frontend-protocol.md#transport) | -| `Describe` reports this plugin build's own identity from the running process | MUST | [`frontend-protocol.md`](frontend-protocol.md#transport), [`widget-protocol.md`](widget-protocol.md#transport) | -| Streaming text via fast-path deltas, not per-token `Render` | MUST | [`frontend-protocol.md`](frontend-protocol.md#fast-path-vs-full-render) | -| `UserMessage` carries `repeated ContentBlock`, not a bare string | MUST | [`frontend-protocol.md`](frontend-protocol.md#usermessage-carries-contentblocks) | -| `plan_decision.corrected_input` re-validated against `input_schema` | MUST | [`frontend-protocol.md`](frontend-protocol.md#plan_decisioncorrected_input) | -| `plan_decision.scope` (`ONCE`/`SESSION`/`ALWAYS`) governs how durably a decision applies | MUST | [`frontend-protocol.md`](frontend-protocol.md#plan_decisioncorrected_input), [`agent-loop/plan-apply-gate.md`](../agent-loop/plan-apply-gate.md#plandecisionscope-semantics) | -| `interactive_request`/`interactive_response` for `kind: interactive` tool calls | MUST | [`frontend-protocol.md`](frontend-protocol.md#fast-path-vs-full-render) | -| Session lifecycle (create/attach/resume/detach/list) over `ClientEvent`/`ServerEvent` control variants, correlated by `request_id` | MUST | [`frontend-protocol.md`](frontend-protocol.md#session-lifecycle) | -| Backfill on attach is a bracketed, unicast replay batch, never broadcast | MUST | [`frontend-protocol.md`](frontend-protocol.md#backfill--the-replay-path-not-a-new-subsystem) | -| A COMPLETED/CANCELLED session MAY be re-opened to RUNNING via `ResumeSession`; bound-exhausted/FAILED sessions are replay-only | MUST | [`frontend-protocol.md`](frontend-protocol.md#resume-and-re-open-semantics) | -| No session deletion mechanism exists for any plugin category | MUST (by decision) | [`frontend-protocol.md`](frontend-protocol.md#no-session-deletion) | -| Multiple frontends MAY subscribe to the same session concurrently | MAY | [`README.md`](README.md#session-scope--multi-attach) | -| `ServerEvent`s broadcast identically to every frontend subscribed to a given session | MUST | [`frontend-protocol.md`](frontend-protocol.md#session-scope) | -| First-response-wins on `plan_decision`/`interactive_response`, per session; losers get a distinct error | MUST | [`frontend-protocol.md`](frontend-protocol.md#session-scope) | -| Widget `Attach` is server-streaming only (no bidi channel), and remains session-scoped, not connection-multiplexed | MUST | [`widget-protocol.md`](widget-protocol.md#transport) | -| Widget-initiated actions via `ActionNode` + frontend `action_trigger`, not a widget-specific RPC | MUST | [`widget-protocol.md`](widget-protocol.md#interactive-widgets) | -| Widgets derive state via `observe`-mode hooks, no separate data feed | MUST | [`widget-protocol.md`](widget-protocol.md#deriving-display-state--no-new-data-feed) | -| `PromptExpansionSpec` declarable by any provider category | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| `SlashCommandSpec` (direct-invoke) declared exclusively by a `slashcommand.v1` provider | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| Slash-command name collision, checked jointly across the direct-invoke and prompt-expansion lists | MUST be config-load-time error | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| Aggregate `SlashCommandRegistry` (both lists) sent on session attach and on registry change | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| Direct-invoke dispatch, via the owning `slashcommand.v1` provider's `SlashCommandService.Invoke`, bypasses the model turn | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| Prompt-expansion dispatch costs a model turn | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands) | -| Prompt-expansion scoping via `agent_profile.slash_commands` | MUST | [`frontend-protocol.md`](frontend-protocol.md#slash-commands), [`configuration/agent-profiles.md`](../configuration/agent-profiles.md) | -| `ActionNode`/`ActionTrigger` carry `provider`, since `tool_name` is only unique per provider | MUST | [`render-tree.md`](render-tree.md#interactive-content-the-action-node) | -| `ActionNode` dispatches through the same no-model-turn shape as a direct-invoke `SlashCommandService.Invoke` | MUST | [`render-tree.md`](render-tree.md#interactive-content-the-action-node) | -| Reference TUI's region layout | Not normative (one implementation) | [`examples.md`](examples.md#the-reference-tui) | -| Structured `FrontendError` taxonomy | MUST | [Error taxonomy](#error-taxonomy) | -| Structured `WidgetError` taxonomy | MUST | [Error taxonomy](#error-taxonomy) | +Frontend and widget errors use structured category enums on gRPC status details (`.claude/rules/grpc.md`). Region-unsupported categories are retired (reserved field numbers on the wire). -## Open questions +## Acceptance criterion -- **Whether `region_unsupported` should distinguish "frontend has no concept of this region at all" from "frontend recognizes the region but is out of space for it."** Both currently collapse to the same category; a widget author tuning `priority` might want to tell them apart. `supported_regions`/`supported_hook_points` capability advertisement (this revision) narrows this somewhat — a producer can now check `supported_regions` proactively — but doesn't fully resolve the reactive-error case. -- **Whether a future non-TUI frontend (web, voice) needs additional `Region` values**, or whether the existing six adequately cover every conforming implementation's actual layout needs — the abstract vocabulary was designed against one reference implementation (the TUI) plus a thought experiment (a hypothetical web/voice frontend), not a second built frontend to validate against. -- **`FRONTEND_ERROR_CATEGORY_SESSION_BUSY` currently has no triggering variant** in this protocol revision (no frontend-triggered session-mutating control event conflicts with a `RUNNING` session — `DetachSession` is always safe). It stays reserved for a future control event that would need it, rather than being removed, since a category enum value once shipped is never renumbered per `.claude/rules/proto.md`. +The design is only proven when a **second frontend** — even a deliberately minimal HTTP one — renders the same `SessionState` and the same metadata blocks with the same functionality. Unit tests cannot substitute for that. Shipping a second frontend is not required for wire/spec completion of this revision, but the contracts MUST make it possible without a TUI region vocabulary. diff --git a/docs/specifications/frontend/examples.md b/docs/specifications/frontend/examples.md index da1f856..9100e8a 100644 --- a/docs/specifications/frontend/examples.md +++ b/docs/specifications/frontend/examples.md @@ -1,186 +1,39 @@ -# Frontend & widget — examples +# Frontend examples -Wire-protocol excerpts for all three schemas this directory covers, a worked frontend `Attach` sequence, a worked widget example, and the reference TUI that instantiates the region vocabulary in practice. +## Wire sequence — attach and chat -## The wire protocols - -`FrontendService`: - -```protobuf -service FrontendService { - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - rpc Attach(stream ClientEvent) returns (stream ServerEvent); - rpc Describe(DescribeRequest) returns (DescribeResponse); -} ``` - -`WidgetService` — note the different `Attach` shape despite the identical RPC name: - -```protobuf -service WidgetService { - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - rpc Attach(AttachRequest) returns (stream WidgetUpdate); - rpc Describe(DescribeRequest) returns (DescribeResponse); -} +Frontend Kernel (callback channel) + | | + |-- CreateSession(profile=default) ->| + |<- SessionInfo (session_id=S) ------| + |-- GetSessionState(S) ------------->| + |<- SessionState --------------------| + |-- ListMetadata(S) ---------------->| + |<- blocks[] ------------------------| + |-- Subscribe([kernel.event.*, | + | kernel.state, | + | kernel.metadata]) --->| + |-- StreamDeltas(S) ---------------->| (open for life of session) + |-- SubmitInput(S, [Text("hi")]) --->| + |<- turn_id=T -----------------------| + |<- BusEvent kernel.event.message ---| + |<- TokenDelta{target, "Hel"} -------| + |<- TokenDelta{target, "lo"} --------| + |<- BusEvent kernel.event.message ---| (finished render path) ``` -The shared `RenderTree`/`Region` vocabulary: +## Metadata block -```protobuf -message RenderTree { - RenderNode root = 1; -} +A widget (or tool) publishes: -enum Region { - REGION_UNSPECIFIED = 0; - REGION_MAIN_CHAT = 1; - REGION_SIDEBAR = 2; - REGION_TOP_BAR = 3; - REGION_INPUT_BAR = 4; - REGION_HOTKEY_HINTS = 5; - REGION_OVERLAY = 6; -} ``` - -See [`render-tree.md`](render-tree.md) for the full `RenderNode` variant list and [`frontend-protocol.md`](frontend-protocol.md) / [`widget-protocol.md`](widget-protocol.md) for the full `ServerEvent`/ `ClientEvent`/`WidgetUpdate` shapes these excerpts are trimmed from. - -## A worked frontend `Attach` sequence - -A TUI frontend opens its one connection-scoped `Attach` stream, attaches an already-running session (backfilling its history first), receives a `plan_ready` event for a proposed file edit, renders it, and the operator approves it with a corrected argument scoped to this session only: - -```text -→ Attach() opens the bidirectional, connection-scoped stream. - -→ ClientEvent{ session_id: "", attach_session: { request_id: "r1", session_id: "sess_01H..." } } - -// Backfill: the kernel replays this session's persisted history, bracketed -// by session_attached and backfill_complete — frontend-protocol.md#backfill--the-replay-path-not-a-new-subsystem. -← ServerEvent{ session_id: "sess_01H...", request_id: "r1", - session_attached: { info: { session_id: "sess_01H...", profile: "default", - status: SESSION_STATUS_RUNNING, depth: 0, ... } } } -← ServerEvent{ session_id: "sess_01H...", render: { content: { ... } } } // replayed prior turns -← ServerEvent{ session_id: "sess_01H...", request_id: "r1", - backfill_complete: { last_sequence: 118 } } -← ServerEvent{ session_id: "sess_01H...", - slash_command_registry: { commands: [ ... ] } } - -// Live events for this session follow, sequence > 118: -← ServerEvent{ session_id: "sess_01H...", stream_delta: {target_id: "msg_7", text: "I'll fix the "}} -← ServerEvent{ session_id: "sess_01H...", stream_delta: {target_id: "msg_7", text: "off-by-one in main.go."}} -← ServerEvent{ session_id: "sess_01H...", - plan_ready: { - plan: { - turn_id: "turn_42", - items: [{ - id: "item_1", tool_call_id: "tc_9", provider: "filesystem", - tool_name: "write_file", - input: {"path": "main.go", "content": "...for i := 0; i <= n; i++..."}, - decision: PLAN_DECISION_PENDING, - kind: TOOL_KIND_RESOURCE, risk: RISK_CLASS_LOW, - description: "Write file contents, creating or overwriting the target path.", - }], - }, - }, - } -← ServerEvent{ session_id: "sess_01H...", - permission_request: { - plan_item: { id: "item_1", tool_call_id: "tc_9", provider: "filesystem", - tool_name: "write_file", decision: PLAN_DECISION_ASK }, - }, - } -← ServerEvent{ session_id: "sess_01H...", - render: { - content: { - region: REGION_OVERLAY, - content: { root: { diff: { hunks: [{ - old_start: 10, old_lines: 1, new_start: 10, new_lines: 1, - lines: [ - {op: DIFF_LINE_OP_REMOVE, text: "for i := 0; i <= n; i++ {"}, - {op: DIFF_LINE_OP_ADD, text: "for i := 0; i < n; i++ {"}, - ], - }]}}}, - replace: true, - }, - }, - } - -// The operator reviews the diff in the overlay and corrects the fix to -// use "<=" bounded on n-1 instead, rather than accepting or rejecting outright, -// and asks the kernel to remember this correction for the rest of the session: -→ ClientEvent{ session_id: "sess_01H...", - plan_decision: { - plan_item_id: "item_1", - decision: CLIENT_DECISION_ALLOW, - corrected_input: {"path": "main.go", "content": "...for i := 0; i < n-1; i++..."}, - scope: PLAN_DECISION_SCOPE_SESSION, - }, - } - -// The kernel re-validates corrected_input against write_file's input_schema -// (tool/data-types.md#toolschema), accepts it, applies the write, and the -// turn continues — the model sees the corrected content's tool_result on -// its next turn, per frontend-protocol.md#plan_decisioncorrected_input. The -// SESSION scope means a matching future write_file call this session skips -// the ask prompt entirely, per agent-loop/plan-apply-gate.md#plandecisionscope-semantics. +PublishMetadata(session_id=S, block={ + id: "git.branch", + priority: 10, + tone: TONE_INFO, + body: KeyValue{key: "branch", value: "main"}, +}) ``` -If a second, slower frontend also subscribed to `sess_01H...` sends its own `plan_decision` for `item_1` after the one above resolved it, the kernel rejects that second response with a `FrontendError{category: FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT}` back to that frontend alone — per [`frontend-protocol.md#session-scope`](frontend-protocol.md#session-scope)'s first-response-wins rule. The first frontend's approval stands unaffected. - -## A worked widget example - -A persistent status-bar widget derives context-budget state from the same event stream a frontend already observes, with no new data feed: - -```text -// The widget subscribes to post-model-response in observe mode -// (agent-loop/hook-dispatch.md) and watches each usage event go by. -// It never calls CountTokens itself — the kernel's own hook payload -// already carries the resolved usage figures. - -← WidgetUpdate{ - region: REGION_TOP_BAR, - content: { root: { text: { content: "estimated used: 51,204 / 200,000 tokens", - style: TEXT_STYLE_DIM } } }, - replace: true, - } - -// A later turn crosses a configured budget-warning threshold; the widget -// pushes a replacement update using TEXT_STYLE_WARNING instead, and adds a -// one-click follow-up via an ActionNode — the "interactive widget" case -// (render-tree.md#interactive-content-the-action-node): - -← WidgetUpdate{ - region: REGION_TOP_BAR, - content: { root: { group: { children: [ - { text: { content: "82% of context budget used", style: TEXT_STYLE_WARNING } }, - { action: { id: "act_compact", label: "Compact now", - tool_name: "compact_context", args: {} } }, - ]}}}, - replace: true, - } - -// Activating "Compact now" in the frontend dispatches: -→ ClientEvent{action_trigger: {node_id: "act_compact", tool_name: "compact_context", args: {}}} - -// The kernel handles this exactly like a direct_invoke slash command: the -// normal Invoke/plan-apply pipeline, no model turn, result appended to -// history as an ordinary tool_result. -``` - -Note the widget never received a `ClientEvent` of its own — the `action_trigger` above flows from the *frontend's* `Attach` stream, not the widget's. The widget only ever pushes `WidgetUpdate`s outward. - -## The reference TUI - -Not protocol, but a concrete instantiation worth documenting since it's the harness's primary interface: a full-screen terminal takeover, OpenCode-style. - -| Region | Reference layout | -|---|---| -| `top_bar` | Single line: session/profile name, active model, context-budget indicator | -| `main_chat` | The dominant, scrollable area — conversation flow, tool calls/results, collapsed `sub_session` nodes | -| `sidebar` | Right-hand column — widget-contributed panels (git status, background job state, etc.), stacked by `priority` | -| `input_bar` | Bottom-anchored — the operator's message composer | -| `hotkey_hints` | Directly above or beside `input_bar` — a compact reminder of available slash commands / keybindings | -| `overlay` | Full-screen or floating-pane takeover for `ask`-decision prompts and other interrupting content | - -The reference TUI **MUST** implement [`render-tree.md#placement--regions`](render-tree.md#placement--regions)'s graceful-fallback rule for any region it chooses not to visually distinguish (e.g. a narrow terminal collapsing `sidebar` into a toggleable pane rather than a permanent column) — the protocol only requires that placement be *honored or gracefully reinterpreted*, never that every region get permanent, dedicated screen real estate. This layout is not normative: it is one conforming implementation of the abstract region vocabulary, not a constraint on any other frontend. +Kernel stamps `producer` and `liveness=LIVE`, stores, and publishes on topic `kernel.metadata`. On plugin exit, each LIVE block flips to `DISCONNECTED` and is republished; frontends decide gray vs drop. diff --git a/docs/specifications/frontend/frontend-protocol.md b/docs/specifications/frontend/frontend-protocol.md index 3cf5bdd..9e22c6c 100644 --- a/docs/specifications/frontend/frontend-protocol.md +++ b/docs/specifications/frontend/frontend-protocol.md @@ -1,295 +1,91 @@ -# Frontend provider — protocol +# Frontend provider protocol -The frontend provider protocol: the plugin that owns the terminal (or window, or voice channel), sends operator input to the kernel as `ClientEvent`s over one connection-scoped stream, and receives session lifecycle and content updates back as `ServerEvent`s on the same stream. See [`README.md#transport--lifecycle`](README.md#transport--lifecycle) for how this category's `Attach` shape differs from the widget provider's. +A frontend owns how the operator sees and types. It does **not** own the agent loop, policy, or the state backend. Wire traffic with the kernel uses the **kernel callback channel** exclusively for session lifecycle, input, state, metadata, transcript, and token deltas; the category service is only the standard triple. ## Transport -Subprocess + gRPC via `hashicorp/go-plugin`, per [`architecture.md`](../architecture.md#transport). A frontend provider plugin exposes four RPCs: `GetCapabilities`, `Configure`, `Attach`, `Describe`. - -**`Attach` is a bidirectional stream** — the one genuinely bidi RPC in this directory, and one of only two in the entire protocol series (the other being the kernel-callback channel). Every other category's primary RPC (`StreamCompletion`, `Invoke`) is server-streaming-plus-cancellation; the widget provider's own `Attach` ([`widget-protocol.md#transport`](widget-protocol.md#transport)) is server-streaming only despite sharing the RPC name. A frontend genuinely needs both directions live on one connection, unlike a model or tool provider: the operator can type a message while prior content is still rendering, so `ClientEvent`s and `ServerEvent`s must be able to cross in flight. - -```protobuf -service FrontendService { - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - rpc Attach(stream ClientEvent) returns (stream ServerEvent); - rpc Describe(DescribeRequest) returns (DescribeResponse); -} -``` - -**`Attach` is connection-scoped, with per-session subscription — not one stream per session.** A frontend opens exactly one `Attach` stream per connection and keeps it open for the connection's whole lifetime; individual sessions are subscribed onto that single stream via the session-control `ClientEvent` variants ([Session lifecycle](#session-lifecycle) below), and every event on the wire — both directions — carries a top-level `session_id` field that multiplexes which session it belongs to. This is deliberate, not incidental: full session lifecycle needs connection-level operations (listing every session, the aggregate slash-command registry, creating a session before anything is attached to it) that have no natural home under a strictly per-session stream, and `.claude/rules/grpc.md`'s streaming-shape table permits exactly one genuinely bidirectional frontend RPC — multiplexing preserves that invariant rather than adding a second bidi stream or folding session control into the unrelated kernel-callback channel. - -`GetCapabilities` returns this frontend's `slash_commands` (see [Slash commands](#slash-commands) below), `ConfigSchema`, `supported_regions`, and `supported_hook_points`; it MUST be cheaply re-queryable and MUST NOT require a network call, the same guarantee [`model/protocol.md#getcapabilities`](../model/protocol.md#getcapabilities) requires of a model provider. `Configure` follows the same contract as [`model/protocol.md#configure`](../model/protocol.md#configure): a config value decoded from the provider's `agent.hcl` block via the schema-to-cty bridge, rejected with a structured error at configure time rather than deferred to the first `Attach`, and never echoing a received secret back out through any event, render, or log line. - -`Describe` reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process, rather than the kernel inferring it from a lock-file row. Every one of the seven category protocols gains this identical RPC in this protocol revision; it exists specifically for a `dev_overrides`-resolved binary, which has no `provider {}` lock-file entry to read identity from at all (`configuration/lock-file.md`'s "`dev_overrides` and identity without a lock entry"). - -## Fast path vs. full render - -Live token-by-token text streaming (a model provider's `text_delta`, per [`model/protocol.md#streamcompletion`](../model/protocol.md#streamcompletion)) does **not** round-trip through a producer's `Render` call per token — that would be far too slow. The kernel forwards raw text deltas to the frontend directly as they arrive, as `ServerEvent.stream_delta`; `Render` is invoked once per producer per logically-complete unit (a finished message, a finished tool result), not per token, and its result arrives as `ServerEvent.render` carrying a [`PlacedContent`](render-tree.md#placement--regions). - -```protobuf -message ServerEvent { - // Scopes this event to a session. Set for every session-scoped variant; - // empty only for the connection-level session_list variant. - string session_id = 100; - - // Correlates a response to the ClientEvent control message that - // triggered it (echoing that message's own request_id). Set on - // session_created/session_attached/backfill_complete/session_detached/ - // session_list, and on error when it answers a control request; unset - // for ordinary live session events not triggered by a specific client - // request. - optional string request_id = 101; - - oneof event { - StreamDelta stream_delta = 1; - Render render = 2; - PermissionRequest permission_request = 3; - PlanReady plan_ready = 4; - InteractiveRequest interactive_request = 5; - SessionTreeUpdate session_tree_update = 6; - Error error = 7; - SessionCreated session_created = 8; - SessionAttached session_attached = 9; - BackfillComplete backfill_complete = 10; - SessionDetached session_detached = 11; - SessionList session_list = 12; - // 13 is reserved, not assigned — see "No session deletion" below. - SlashCommandRegistry slash_command_registry = 14; - UsageUpdate usage_update = 15; - SessionStatusUpdate session_status_update = 16; - } - - message StreamDelta { - string target_id = 1; // correlates consecutive deltas into one growing piece of text - string text = 2; - } - - message Render { - pluggableharness.render.v1.PlacedContent content = 1; - } - - message PermissionRequest { - pluggableharness.plan.v1.PlanItem plan_item = 1; - } - - message PlanReady { - pluggableharness.plan.v1.Plan plan = 1; - } - - message InteractiveRequest { - string call_id = 1; - string tool_name = 2; - pluggableharness.render.v1.RenderTree prompt = 3; - } - - message SessionTreeUpdate { - string parent_session_id = 1; - string child_session_id = 2; - pluggableharness.session.v1.SessionStatus status = 3; - } - - message Error { - FrontendError error = 1; - } -} -``` - -`PermissionRequest` asks the operator to resolve a pending `ask` decision ([`agent-loop/plan-apply-gate.md`](../agent-loop/plan-apply-gate.md)): the kernel blocks that plan item's apply until a matching `ClientEvent.plan_decision` resolves it. `InteractiveRequest` carries a [`kind: interactive`](../tool/protocol.md#kind-interactive) tool call across the frontend boundary — the kernel renders the tool's own prompt content as an ordinary `RenderTree` (in the `overlay` region), and `call_id` correlates the request with the eventual `ClientEvent.interactive_response` the same way `tool_call_id` correlates an ordinary resource call and its result elsewhere in this protocol series. A frontend **MUST** render `InteractiveRequest.prompt` in the `overlay` region ([`render-tree.md#placement--regions`](render-tree.md#placement--regions)), the same visual treatment as an ordinary `ask` prompt. `SessionTreeUpdate` reports a **child** session's status change (a `RunSession`-spawned sub-agent); see [Session lifecycle](#session-lifecycle) below for `SessionStatusUpdate`, the parallel variant for the *attached* session's own status. - -## Client events - -```protobuf -enum ClientDecision { - CLIENT_DECISION_UNSPECIFIED = 0; - CLIENT_DECISION_ALLOW = 1; - CLIENT_DECISION_DENY = 2; -} - -enum PlanDecisionScope { - PLAN_DECISION_SCOPE_UNSPECIFIED = 0; - PLAN_DECISION_SCOPE_ONCE = 1; - PLAN_DECISION_SCOPE_SESSION = 2; - PLAN_DECISION_SCOPE_ALWAYS = 3; -} - -message ClientEvent { - // REQUIRED for user_message..interrupt (session-scoped variants); empty - // for the connection-level control variants (hello..list_sessions). - string session_id = 100; - - oneof event { - UserMessage user_message = 1; - SlashCommand slash_command = 2; - PlanDecision plan_decision = 3; - InteractiveResponse interactive_response = 4; - ActionTrigger action_trigger = 5; - Interrupt interrupt = 6; - Hello hello = 7; - CreateSession create_session = 8; - AttachSession attach_session = 9; - ResumeSession resume_session = 10; - DetachSession detach_session = 11; - ListSessions list_sessions = 12; - } - - message UserMessage { - // repeated ContentBlock, not a bare string — see "UserMessage carries - // ContentBlocks" below. - repeated pluggableharness.content.v1.ContentBlock content = 2; - } - - message SlashCommand { - string name = 1; // without the leading slash - string args = 2; // raw argument string - } - - message PlanDecision { - string plan_item_id = 1; - ClientDecision decision = 2; - optional google.protobuf.Struct corrected_input = 3; - PlanDecisionScope scope = 4; - } - - message InteractiveResponse { - string call_id = 1; - google.protobuf.Struct response = 2; - } - - message ActionTrigger { - string node_id = 1; - string tool_name = 2; - google.protobuf.Struct args = 3; - string provider = 4; - } - - message Interrupt {} -} -``` - -`ActionTrigger` is what a frontend dispatches when the operator activates a [`RenderTree`'s `ActionNode`](render-tree.md#interactive-content-the-action-node); `node_id`, `tool_name`, `args`, and `provider` are echoed unchanged from the originating node — `provider` disambiguates which provider's operation to invoke, since `tool_name` is only unique per provider. The kernel handles the resulting `action_trigger` through the normal `tool.v1` `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn — the same no-model-turn dispatch shape a direct-invoke slash command's own `SlashCommandService.Invoke` ([Slash commands](#slash-commands) below) takes. - -### UserMessage carries ContentBlocks - -`ClientEvent.UserMessage` no longer carries a bare `string text`; it carries `repeated content.v1.ContentBlock content`, the same block vocabulary [`model/data-types.md#canonical-message--content-block-schema`](../model/data-types.md#canonical-message--content-block-schema) uses everywhere else in this protocol series. The prior string-only shape had no entry point for a pasted image: `content.v1.ImageBlock` and a model's `supports_vision` capability flag already existed, but a frontend had no way to actually *submit* one as user input. A frontend sending ordinary typed text sends a single `TextBlock`; a frontend supporting paste/attachment sends `content` with more than one block (e.g. a `TextBlock` plus an `ImageBlock`), gated the same way any other `ImageBlock` is — the kernel MUST reject an image block against a model whose `ModelSpec.supports_vision` is false, per [`model/data-types.md`](../model/data-types.md). Field 1 (the old `text` field) is `reserved`, per `.claude/rules/proto.md`'s field-number discipline — never reused. - -### `plan_decision.corrected_input` - -`PlanDecision.corrected_input` is an opencode-style `CorrectedError` redirect: rather than a binary allow/deny, the operator can supply corrected tool arguments and have the kernel treat the item as allowed with those arguments instead of the model's originals. When present, the kernel **MUST** re-validate `corrected_input` against the tool's `input_schema` ([`tool/data-types.md#toolschema`](../tool/data-types.md#toolschema)) before treating the item as allowed — an invalid correction **MUST** be rejected back to the sending frontend as a distinct error, never silently coerced and never silently downgraded to a plain `deny`. This re-validation is mechanically part of the plan/apply gate's decision handling; see [`agent-loop/plan-apply-gate.md`](../agent-loop/plan-apply-gate.md) for where it slots into the turn algorithm — this document defines the field and the frontend-facing contract around it, the gate owns applying it. - -`PlanDecision.scope` is orthogonal to `decision`/`corrected_input`: it says how durably this verdict is remembered, not what the verdict is. `PLAN_DECISION_SCOPE_ONCE` (the default a frontend SHOULD send absent explicit operator intent) applies only to the named item; `SESSION` applies to the rest of the current session for matching calls; `ALWAYS` asks the kernel to persist the verdict as policy, outliving the session. See [`agent-loop/plan-apply-gate.md#plandecisionscope-semantics`](../agent-loop/plan-apply-gate.md#plandecisionscope-semantics) for evaluation order and the `ALWAYS` persistence obligation — this document defines the field, the gate owns applying it, exactly as with `corrected_input` above. +`FrontendService` exposes: -## Session lifecycle +| RPC | Shape | Purpose | +|---|---|---| +| `GetCapabilities` | unary | Slash commands, config schema, supported hook points | +| `Configure` | unary | Apply `agent.hcl` provider config | +| `Describe` | unary | Plugin identity from the running process | -A frontend's single `Attach` stream subscribes and unsubscribes individual sessions via six control `ClientEvent` variants, correlated to their `ServerEvent` responses by a client-generated `request_id` the frontend chooses and the kernel echoes back unchanged on `ServerEvent.request_id`: +There is no `Attach`. Capabilities MUST be cheaply re-queryable and MUST NOT require a network call. -| `ClientEvent` variant | Answered by | Purpose | -|---|---|---| -| `hello` | — (no response) | MAY be sent first, on stream open; asserts `protocol_version` only. Does not bind any session — attaching happens via the variants below. | -| `create_session` | `session_created` | Creates a new session under a given (or default) profile and working directory, optionally seeded with an `initial_prompt`. Auto-attaches the sending stream to the new session. | -| `attach_session` | `session_attached`, then a backfill batch, then `backfill_complete` | Subscribes an existing session (live or terminal) onto this stream. | -| `resume_session` | Same shape as `attach_session` | Attaches a historical session — see [Resume and re-open semantics](#resume-and-re-open-semantics) below for what "resume" permits beyond a plain attach. | -| `detach_session` | `session_detached` | Unsubscribes a session from this stream. Does not affect the session itself, or any other stream still attached to it. | -| `list_sessions` | `session_list` | The one connection-level (not session-scoped) control event — an optional `status`/`parent_session_id` filter and a `roots_only` flag. | +### Callback-channel RPCs a frontend uses -`CreateSession`, `AttachSession`, `ResumeSession`, `DetachSession`, and `ListSessions` all carry their own `request_id`; `AttachSession`/`ResumeSession`/`DetachSession` additionally carry the target `session_id` in their own body (the top-level `ClientEvent.session_id` is empty for all six control variants, since none of them yet has — or, for `list_sessions`, ever has — a session to scope to before the response arrives). +Documented fully in [`kernel-callbacks.md`](../kernel-callbacks.md); listed here as the frontend-facing contract: -### Backfill = the replay path, not a new subsystem +| Concern | RPCs | +|---|---| +| Session lifecycle | `CreateSession`, `AttachSession`, `ResumeSession`, `DetachSession`, `ListSessions` | +| Input | `SubmitInput` (returns `turn_id`), `InvokeSlashCommand`, `TriggerAction`, `Interrupt` | +| Plan / interactive | `ResolvePlanDecision`, `ResolveInteractive` | +| State | `GetSessionState` | +| Metadata | `ListMetadata`, and (for frontend-owned blocks) `PublishMetadata` / `RetractMetadata` | +| Transcript | `ReadEvents` | +| Live updates | `Subscribe` with topics `kernel.event.*`, `kernel.state`, `kernel.metadata` | +| Token fast path | `StreamDeltas` (server-streaming; not on the bus) | -`architecture.md`'s "replay is just feed old events through the same Render/Paint path" governs `attach_session`/`resume_session` identically to any other replay: on either, the kernel replays the session's persisted events in `sequence` order, re-rendering each via the "supersedes" model (the historical `ProducerRef`'s own `Render`), and emits them as ordinary `ServerEvent.render` — the identical wire shape a live render uses. `stream_delta` is live-only; replayed text arrives as finished `render`s, never as deltas. +## Four surfaces -The batch is bracketed: `SessionAttached` (carrying the session's current `SessionInfo`) opens it, the replayed `render` events follow, and `BackfillComplete { last_sequence }` closes it — the done-marker a frontend uses to know it has caught up, after which live events (`sequence > last_sequence`) continue on the same stream. **Backfill is unicast to the attaching stream only, never broadcast** to other frontends already subscribed to that session — a late joiner catching up must not re-flood everyone who was already there. +### Input -### Session scope +Operator input is a **capability**, not a region. The frontend collects content (text, pasted images as `ContentBlock`s) and calls `SubmitInput`. Correlation uses the returned `turn_id` plus the event stream. Slash commands and `ActionNode` activations are separate unaries (`InvokeSlashCommand`, `TriggerAction`) so they keep the no-model-turn plan/apply path. -**Multiple frontends MAY subscribe to the same session concurrently**, each on its own `Attach` stream — see [`README.md#session-scope--multi-attach`](README.md#session-scope--multi-attach) for why this is the current design, not a 1:1 attachment model. The operational rules, all re-scoped **per session** now that one connection's stream carries many sessions at once: +### State -- **`ServerEvent`s for a given session broadcast identically to every frontend subscribed to that session.** No partitioning, no "primary" frontend — every subscribed frontend observes that session's live stream in the same order. A frontend subscribed to session A does not receive session B's events at all, regardless of how many sessions each of its peers is subscribed to. -- **`ClientEvent`s are processed in kernel arrival order, per session.** `user_message`/`slash_command`/`action_trigger`/`interrupt` have no real conflict — multiple frontends sending these for the same session just interleave as ordinary sequential input, a legitimate pairing/multi-operator scenario, not an error case. -- **`plan_decision`/`interactive_response` name a specific pending item** (`plan_item_id`/`call_id`), implicitly scoped to the session that item belongs to. **First response for a given item wins.** Any subsequent response for an already-resolved item **MUST** be rejected back to the sending frontend with a distinct `invalid_client_event`-category error (see [Error taxonomy](#error-taxonomy)), never silently dropped and never silently re-applied — so that frontend's UI can show "already decided elsewhere" rather than appearing to hang. -- Connection-level control responses (`session_list`) are naturally exempt from all of the above — they answer the one requesting stream directly, correlated by `request_id`, and are never broadcast to any other frontend. +`SessionState` (`session.v1`) is a **fixed schema**: `SessionInfo` plus working directory, VCS summary, model, thinking/effort, context pressure, turn count, elapsed, total tokens. No extension point — that is what makes it renderable by a status bar, HTTP header, stdout line, or spoken sentence. -### Resume and re-open semantics +**Per-session:** every snapshot and bus payload names exactly one `session_id`. A frontend attached to several sessions holds one state per session. -`ResumeSession` targets a **historical** session — one that may be RUNNING (an ordinary late-attach, identical to `AttachSession`), or may already be terminal. What a terminal session's resume permits depends on which terminal status it's in: +Startup sequence: `GetSessionState` then `Subscribe` on topic `kernel.state` (payload carries `session_id`). Snapshot-then-subscribe cannot drop updates that were committed after the snapshot if the frontend also re-reads on mismatch; the kernel republishes on `kernel.state` whenever a watched field changes. -- **`SESSION_STATUS_COMPLETED` or `SESSION_STATUS_CANCELLED`** MAY be re-opened to `SESSION_STATUS_RUNNING` for new turns. A subsequent `user_message` against a re-opened session is accepted and starts a fresh turn; the session's bounds (`max_turns`, `max_budget_usd`, `max_wall_clock`) reset fresh from its originating profile — a resumed session does not inherit whatever fraction of its bounds it had already consumed before reaching its prior terminal status. The kernel MUST record this `status` transition (terminal → `RUNNING`) the same way any other status change is recorded in `session_meta` (`state-backend.md`'s "Schema migration & corruption recovery" notwithstanding — this is an ordinary in-place status update, not a schema change). -- **A bound-exhausted status (`SESSION_STATUS_ERROR_MAX_TURNS`, `SESSION_STATUS_ERROR_MAX_BUDGET_USD`, `SESSION_STATUS_ERROR_MAX_WALL_CLOCK`) or `SESSION_STATUS_FAILED`** is **replay-only** in this protocol revision. `ResumeSession` against one of these still succeeds — the kernel attaches the stream and backfills its full history exactly as for any other session — but the kernel **MUST** reject any subsequent `user_message` (or any other new-turn-inducing event) against it with `FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY`, a category distinct from `SESSION_BUSY` precisely because the session isn't running — it's terminal and specifically barred from new turns, not merely occupied. +### Metadata -A plain `AttachSession` (as opposed to `ResumeSession`) against a terminal session is a read-only backfill-and-watch — it never implicitly re-opens anything; only `ResumeSession` carries re-open intent, and even then only for the two statuses above. +Formerly "sidebar": a keyed collection of `MetadataBlock` (`metadata.v1`) with a closed body oneof (`KeyValue`, `Progress`, `Status`, `ItemList`, `Timer`), `Tone` token scale (`neutral`/`info`/`success`/`warning`/`danger`), and `Liveness` (`LIVE` / `DISCONNECTED`). -### No session deletion +- Plugin authors compose blocks via `pkg/metadata` builders; frontends map `Tone` to their own vocabulary (never a wire color). +- `PublishMetadata` upserts; producer is **server-derived**. +- Retraction and publisher exit flip `liveness` to `DISCONNECTED` and republish — the kernel **never deletes** a block. +- Snapshot: `ListMetadata`; live: topic `kernel.metadata` (payload is the block, including `session_id`). -**No `DeleteSession` (or any other deletion mechanism) exists anywhere in this protocol, for any plugin category, including frontends.** This is a deliberate omission, not a gap: sessions are protected from every plugin, per `state-backend.md`'s retention posture ("no implicit expiry; pruning is an explicit operator action") — pruning a session file is a kernel CLI command an operator runs at the keyboard, never something a frontend (or any other plugin) can trigger over the wire. `ServerEvent`'s field 13 — where a `session_deleted` variant would otherwise have gone, mirroring `session_created`/`session_attached`/`session_detached`'s numbering — is `reserved`, not assigned, specifically so that number is never silently repurposed for something unrelated if frontend-triggered deletion is ever reconsidered by a future protocol revision. +### Transcript -## Slash commands +Conversation content travels as durable events (`ReadEvents` / `kernel.event.{kind}`) with optional `Render` payloads decoded to `RenderTree` ([`render-tree.md`](render-tree.md)). There is no placement region: transcript is the conversation stream; chrome is state/metadata. + +### Token fast path -A slash command is one of two distinct kinds, declared and dispatched differently: +`StreamDeltas` delivers live `TokenDelta`s (session_id, target_id, text) on a dedicated server-streaming RPC on the callback channel — **not** the event bus (no topic matching, no shared queue). The kernel forwards each delta promptly and does **not** batch; coalescing is the frontend's decision. Deltas are live-only: replayed text arrives as finished renders, never as deltas. Per-stream FIFO only; outside `determinism.md` replay guarantees by construction. -- A **direct-invoke** command — a tool-shaped operation invoked without a model turn — is declared exclusively by a `pluggableharness.slashcommand.v1` provider's own `GetCapabilities` response, and invoked via that same provider's own `SlashCommandService.Invoke`. `SlashCommandSpec` and `SlashCommandService` are defined canonically in [`../slashcommand/protocol.md`](../slashcommand/protocol.md), not here. -- A **prompt-expansion** command — a static template the kernel expands and submits as an ordinary `user_message`, costing a model turn — is genuinely shared vocabulary: any provider category's own capability response (`model/protocol.md#getcapabilities`, `tool/protocol.md#getschema`, and the equivalent sections in `context/` and `memory/`, as well as this category's own `FrontendCapabilities.slash_commands`) MAY declare one, as a `pluggableharness.common.v1.PromptExpansionSpec`. That type is defined below, since it has no dependency on `slashcommand.v1`'s own vocabulary. +## Session lifecycle -The kernel aggregates every loaded provider's declared commands, of both kinds, into one profile-scoped `SlashCommandRegistry`: +- **CreateSession** — new session; auto-attaches the caller. Optional profile, working_directory, initial_prompt. +- **AttachSession** — subscribe to an existing (possibly live) session; backfill via `ReadEvents` + `ListMetadata`. +- **ResumeSession** — attach a historical session. `COMPLETED`/`CANCELLED` MAY re-open to `RUNNING` for new turns; bound-exhausted or `FAILED` attaches **replay-only** — subsequent `SubmitInput` is rejected with `FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY`. +- **DetachSession** — drop this frontend's subscription; other frontends and the session itself are unaffected. +- **ListSessions** — filtered summary list. -```protobuf -message SlashCommandRegistry { - repeated pluggableharness.slashcommand.v1.SlashCommandSpec direct_invoke_commands = 1; // every direct-invoke command, from every loaded slashcommand.v1 provider - repeated pluggableharness.common.v1.PromptExpansionSpec prompt_expansion_commands = 2; // every prompt-expansion command, from every loaded provider category -} -``` +No frontend-triggered session deletion. Pruning is a kernel CLI / operator action only. -sent to an attaching frontend as part of `session_attached` and again whenever the registry changes (a plugin reload, a config change) — a frontend does not need to separately call every category's `GetCapabilities`/`GetSchema` and merge the results itself. A command's name **MUST** be unique jointly across both lists — a name collision, whether within one list or across the two, **MUST** be a config-load-time error, per this protocol series' established "ambiguity is an error, not a silent pick" pattern. +## Plan and interactive resolution -A frontend parses typed input as `/name args`. Resolving `name` means checking both `direct_invoke_commands` and `prompt_expansion_commands` — the joint uniqueness guarantee above means at most one of the two lists can match — and dispatching accordingly: +When policy evaluates a plan item as `ASK`, or an interactive-kind tool blocks for input, the frontend learns via bus/hooks/render (plan-ready / interactive prompt content) and answers with `ResolvePlanDecision` or `ResolveInteractive`. -- **A `direct_invoke_commands` match**: the frontend maps `args` to the matched `SlashCommandSpec.input_schema` and sends a `ClientEvent.slash_command` naming it; the kernel dispatches it to the owning `slashcommand.v1` provider's `SlashCommandService.Invoke` ([`../slashcommand/protocol.md#invoke`](../slashcommand/protocol.md#invoke)) — the normal plan/apply pipeline, including policy evaluation, with **no model turn**. This is a real behavior difference from an ordinary tool call: the model never sees or decides on this invocation, only its eventual result (appended to history as an ordinary `tool_result`, so the model has full visibility on the *next* turn even though it didn't initiate this one). -- **A `prompt_expansion_commands` match**: the frontend expands that `PromptExpansionSpec.template` with the typed arguments and submits the result as an ordinary `ClientEvent.user_message` — this costs a model turn like any normal message; the only thing the slash command bought was not having to type the full instruction out. +- **First-response-wins** across multi-attach frontends for a given pending id. +- `ClientDecision` is allow/deny; `PlanDecisionScope` is once/session/always (`plan.v1`). ALWAYS that cannot be persisted MUST be rejected, never silently downgraded. +- Corrected input on a plan decision MUST be re-validated against the tool's input schema. -```protobuf -message PromptExpansionSpec { - string name = 1; // invoked as "/name"; MUST be unique jointly across - // every direct-invoke and prompt-expansion command - // loaded in the session - string description = 2; // shown in the hotkey_hints region - string template = 3; // "{arg}"-style placeholders substituted from - // the operator's typed arguments -} -``` +## Multi-attach arbitration -A profile's provider scoping determines which direct-invoke commands are available: a command whose owning `slashcommand.v1` provider is absent from the active profile's provider list simply isn't registered for that session. Prompt-expansion commands have no backing provider to scope against this way, so they're scoped separately, by an explicit `agent_profile.slash_commands` allow-list — see [`configuration/agent-profiles.md`](../configuration/agent-profiles.md) for the block itself. +`ClientEvent`-era connection multiplexing is gone. Multiple frontends each hold their own callback connection. Input and decisions are processed in kernel arrival order per session; the only single-winner rule is first-response-wins on pending plan/interactive ids. ## Error taxonomy -```protobuf -enum FrontendErrorCategory { - FRONTEND_ERROR_CATEGORY_UNSPECIFIED = 0; - FRONTEND_ERROR_CATEGORY_RENDER_FAILED = 1; - FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT = 2; - FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED = 3; - FRONTEND_ERROR_CATEGORY_UNKNOWN = 4; - FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND = 5; - FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED = 6; - FRONTEND_ERROR_CATEGORY_SESSION_BUSY = 7; - FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW = 8; - FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY = 9; -} - -message FrontendError { - FrontendErrorCategory category = 1; - string message = 2; -} -``` - -| Category | Meaning | Requirement | -|---|---|---| -| `render_failed` | A specific `RenderTree` node couldn't be painted (e.g. a malformed `diff`). | MUST fall back to a generic text rendering of whatever content is recoverable; MUST NOT crash the frontend process over one bad node. | -| `invalid_client_event` | Malformed input on the operator-facing side — including a `plan_decision`/`interactive_response` naming an already-resolved item (see [Session scope](#session-scope)), or a session-scoped variant arriving with an empty `session_id`. | Rare in the ordinary case, since the frontend itself constructs `ClientEvent`s; MUST be surfaced distinctly, not collapsed into `unknown`. | -| `region_unsupported` | A producer targeted a `Region` this frontend has no fallback behavior for at all. | SHOULD be logged; MUST NOT be treated as fatal. | -| `session_not_found` | `attach_session`, `resume_session`, `detach_session`, or `list_sessions`' `parent_session_id` filter named a `session_id` the kernel has no record of. | MUST be surfaced distinctly, correlated by `request_id`. | -| `session_create_failed` | `create_session` failed — an invalid profile, or an unusable working directory. | MUST be surfaced distinctly, correlated by `request_id`. | -| `session_busy` | Reserved for a future session-mutating control event that conflicts with a `RUNNING` session. No variant in this protocol revision currently triggers it. | — | -| `schema_too_new` | `resume_session` named a session file with a `PRAGMA user_version` newer than this kernel understands (`state-backend.md`'s "Schema migration"). | MUST be surfaced distinctly; the kernel MUST refuse to open the file, per `state-backend.md`. | -| `session_replay_only` | A new-turn-inducing event (`user_message`, ...) targeted a session attached replay-only — see [Resume and re-open semantics](#resume-and-re-open-semantics). | MUST be surfaced distinctly, never silently ignored. | -| `unknown` | Anything else. | Structured error taxonomy applies here as everywhere else in this protocol series — see [`conformance.md`](conformance.md#error-taxonomy). | - -`ConfigureResponse` errors surface as a gRPC status carrying a `FrontendError` in its structured detail, not as an in-band field on the response message. Errors encountered mid-`Attach` surface as `ServerEvent.error`, carrying `request_id` when they answer a specific control event. +Structured `FrontendError` / `FrontendErrorCategory` on gRPC status details for Configure and residual frontend-local failures. Session and input errors from callback RPCs use the same category vocabulary where applicable (`SESSION_NOT_FOUND`, `SESSION_CREATE_FAILED`, `SESSION_REPLAY_ONLY`, `INVALID_REQUEST`, …). Region-unsupported is retired with placement regions. + +## Slash commands + +Unchanged aggregation model: direct-invoke from `slashcommand.v1` providers; prompt-expansion from any category's capability response. Registry delivery is via bus/session attach side channel as specified in the slashcommand docs — not via a bidi Attach stream. diff --git a/docs/specifications/frontend/render-tree.md b/docs/specifications/frontend/render-tree.md index e38d05d..ea92869 100644 --- a/docs/specifications/frontend/render-tree.md +++ b/docs/specifications/frontend/render-tree.md @@ -1,146 +1,34 @@ -# RenderTree +# RenderTree intermediate representation -The display-agnostic intermediate representation every category's optional `Render` RPC returns — [`model/protocol.md#render`](../model/protocol.md#render), [`tool/protocol.md#render`](../tool/protocol.md#render), and the equivalent sections in `context/` and `memory/` all return exactly this type. A frontend paints it; nothing upstream of `Render` needs to know how. This document is the canonical, standalone definition; every other category's `Render` section links back here rather than re-describing the shape. +`RenderTree` is the intermediate representation returned by every category's optional `Render` RPC (model, tool, context, memory) and painted by frontends as **transcript** content. It is deliberately **not** a placement system. -The wire type is deliberately factored into its own vocabulary, separate from both the frontend and widget protocols — both the frontend provider protocol ([`frontend-protocol.md`](frontend-protocol.md)) and the widget provider protocol ([`widget-protocol.md`](widget-protocol.md)) place content into the same `Region` enum, and neither category should depend on the other's definitions. +## Placement is not this package's job -## RenderTree +There is no `Region` enum and no `PlacedContent` wrapper. State, metadata, and input are typed kernel surfaces ([`frontend/README.md`](README.md#four-surfaces)). Only conversation transcript content travels as `RenderTree`. -```protobuf -// RenderTree is the return type of every category's Render() RPC. The tree -// root is just a node, so RenderTree wraps a single root RenderNode — this -// gives every Render() RPC across every category one stable, named response -// type, with room to grow (e.g. a schema_version) without changing -// RenderNode itself. -message RenderTree { - RenderNode root = 1; -} -``` +## RenderTree / RenderNode -`RenderNode` is a `oneof` — exactly one variant is set per node: +`RenderTree` wraps a single root `RenderNode`. The tree root is just a node; multi-root content is a `Group`, `List`, or `Collapsible`. -```protobuf -message RenderNode { - oneof node { - TextNode text = 1; - CodeBlockNode code_block = 2; - DiffNode diff = 3; - TableNode table = 4; - LinkNode link = 5; - ListNode list = 6; - GroupNode group = 7; - CollapsibleNode collapsible = 8; - SubSessionNode sub_session = 9; - ActionNode action = 10; - } -} -``` +Node variants (every frontend MUST render every variant gracefully, with a generic fallback — never error, never silently drop): -Recursion happens via `ListNode.items`, `GroupNode.children`, and `CollapsibleNode.children`; every other variant is a leaf. +| Node | Role | +|---|---| +| `Text` | Plain or styled text | +| `CodeBlock` | Source with optional language hint | +| `Diff` | Unified-diff shaped hunks; no-diff frontends fall back to plain before/after text | +| `Table` | Flat string cells | +| `Link` | Text + URL | +| `List` | Ordered or unordered children | +| `Group` | Transparent container | +| `Collapsible` | Summary + children, default expanded/collapsed | +| `SubSession` | Nested agent transcript pointer | +| `Action` | Interactive control; activation → `TriggerAction` with tool_name/args/provider unchanged | -A frontend **MUST render every node type gracefully** — falling back to a reasonable generic treatment (e.g. a `diff` rendered as plain before/after text on a frontend with no diff view) for a node type it doesn't have a specialized widget for, including a variant added to this enum after that frontend shipped — rather than erroring or silently dropping content it doesn't recognize. This is what makes `RenderTree` genuinely frontend-agnostic rather than TUI-shaped in practice. +## Schema versioning for opaque Emit payloads -### Node types +Producers that `Emit` then optionally `Render` version their opaque payload via `schema_version` on the emit envelope. Frontends that only paint `RenderTree` never need the opaque bytes. -| Node | Fields | Notes | -|---|---|---| -| `TextNode` | `content: string`, `style: TextStyle?` | Plain or styled text, the most common leaf. `style` unset means "frontend's own default"; `TEXT_STYLE_NORMAL` is a producer *explicitly* requesting plain styling, a distinct state from unset. | -| `CodeBlockNode` | `language: string?`, `content: string` | `language` unset means no syntax highlighting. | -| `DiffNode` | `hunks: []DiffHunk` | Unified-diff shaped. A frontend with no diff view MUST fall back to plain before/after text rather than dropping the node. | -| `TableNode` | `headers: []string`, `rows: []TableRow` | Deliberately flat — string cells only, no nested `RenderNode` per cell. | -| `LinkNode` | `text: string`, `url: string` | A hyperlink. | -| `ListNode` | `items: []RenderNode`, `ordered: bool` | Ordered (numbered) or unordered (bulleted). | -| `GroupNode` | `children: []RenderNode` | A plain, transparent container — no implied wrapper (border, indentation, label) beyond what the frontend chooses to apply. | -| `CollapsibleNode` | `summary: string`, `children: []RenderNode`, `collapsed_by_default: bool` | A labeled container with a default expanded/collapsed state. | -| `SubSessionNode` | `session_id: string`, `summary: string` | The reserved node type for a nested agent transcript reference (e.g. a `RunSession`-spawned child) — rendered as a pointer to that session rather than inlining its full content. See [`kernel-callbacks.md`](../kernel-callbacks.md) and [`agent-loop/subagents.md`](../agent-loop/subagents.md). | -| `ActionNode` | `id: string`, `label: string`, `tool_name: string`, `args: JSON` | An interactive/clickable element — see [Interactive content: the `action` node](#interactive-content-the-action-node) below. | +## ActionNode -`DiffHunk` mirrors a standard unified-diff hunk header (`@@ -old_start,old_lines +new_start,new_lines @@`) plus its lines, each tagged `DIFF_LINE_OP_CONTEXT` / `_ADD` / `_REMOVE`: - -```protobuf -message DiffHunk { - int32 old_start = 1; - int32 old_lines = 2; - int32 new_start = 3; - int32 new_lines = 4; - repeated DiffLine lines = 5; -} -``` - -### `TextStyle` - -`TextNode.style`'s full value set — a frontend with no visual distinction for a given style MUST still render the underlying text rather than dropping it, the same graceful-fallback obligation the node-type table states above: - -```protobuf -TextStyle = enum { - normal // explicitly plain — distinct from style being unset entirely - bold - italic - code // inline code/monospace, distinct from a full CodeBlockNode - dim // de-emphasized, e.g. secondary/auxiliary information - error // something went wrong - warning // worth the operator's attention, short of an error - success // a positive/completed outcome -} -``` - -### Interactive content: the `action` node - -`ActionNode` is what makes a `RenderTree` interactive, not just displayed — added specifically so a widget could present something the operator clicks or activates, rather than only passive display state: - -```protobuf -message ActionNode { - string id = 1; - string label = 2; - string tool_name = 3; - google.protobuf.Struct args = 4; - string provider = 5; -} -``` - -`provider` is the declared name of the tool provider plugin `tool_name` belongs to — `tool_name` is only unique *per provider*, not globally, matching the same naming precedent already established by [`plan/protocol`](../agent-loop/plan-apply-gate.md)'s `PlanItem.provider`. A frontend rendering an `ActionNode` **MUST** make it interactive (a clickable button, a keybindable list item, whatever fits its own UI) and, on activation, **MUST** dispatch a `ClientEvent.action_trigger` carrying that node's `tool_name`/`args`/`provider` unchanged ([`frontend-protocol.md#client-events`](frontend-protocol.md#client-events)). The kernel then handles the resulting `action_trigger` through the normal `tool.v1` `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn — the same no-model-turn dispatch shape a direct-invoke slash command's own `SlashCommandService.Invoke` ([`../slashcommand/protocol.md#invoke`](../slashcommand/protocol.md#invoke)) takes. No action-specific dispatch mechanism exists beyond this — `action` nodes are a second way to *reach* that same dispatch shape, via a click instead of typed text. - -This generalizes past widgets for free, since any producer's `Render` output can include an `ActionNode`, not only a widget's: a `tool_result` diff could include an action offering "undo this change," a memory record could offer "forget this," and so on. Widgets are simply the category that motivated adding it — see [`widget-protocol.md#interactive-widgets`](widget-protocol.md#interactive-widgets) for the widget-specific angle. - -## Placement & regions - -Every `RenderTree` is shown somewhere. `PlacedContent` pairs a tree with where it goes and how it interacts with that region's prior content from the same producer: - -```protobuf -enum Region { - REGION_UNSPECIFIED = 0; - REGION_MAIN_CHAT = 1; - REGION_SIDEBAR = 2; - REGION_TOP_BAR = 3; - REGION_INPUT_BAR = 4; - REGION_HOTKEY_HINTS = 5; - REGION_OVERLAY = 6; -} - -message PlacedContent { - Region region = 1; - RenderTree content = 2; - bool replace = 3; // true: replace this producer's prior content in - // `region`; false: append (the default for - // REGION_MAIN_CHAT) - optional int32 priority = 4; // ordering/eviction hint; unset = declaration order -} -``` - -**Every region is plugin-contributable** — there is no region reserved as pure, non-extensible chrome. This vocabulary is deliberately abstract: a hypothetical future web or voice frontend isn't required to have a right sidebar. The reference TUI ([`examples.md#the-reference-tui`](examples.md#the-reference-tui)) is *one* conforming implementation of this vocabulary, not the protocol itself. - -- **`main_chat`** is the ordinary conversation flow — messages, tool calls, tool results. This is where content lands by default when a producer emits without specifying a region at all. -- **`top_bar`**, **`hotkey_hints`**, **`input_bar`** are typically small, single-producer spaces; a frontend SHOULD apply `priority` to decide what's visible when multiple producers compete for limited room. -- **`overlay`** is for content that should visually interrupt (a modal confirmation, an inline `ask`-decision prompt per [`agent-loop/plan-apply-gate.md`](../agent-loop/plan-apply-gate.md)) — a frontend **MUST** render `overlay` content in a way that's visually distinct from ambient `main_chat`/`sidebar` content, even if the specific implementation (a floating pane, a full-screen takeover) is its own choice. - -A frontend that lacks a given region (e.g. a plain line-based CLI with no sidebar) MAY silently drop `PlacedContent` targeting it, or fold it into another region as a fallback (e.g. sidebar content appended to `main_chat` instead) — placement is always a hint the frontend is free to reinterpret for its own layout, never a mandate the producer can rely on being honored literally. Multiple producers MAY target the same region with `replace: true`; the frontend orders/allocates space among them by `priority` rather than treating the region as a single-writer slot — coexistence, not exclusivity, is the default, and no config-load-time conflict is raised over two producers sharing a region. - -## Schema versioning - -Every category's own local `RenderRequest` message (`model/v1/rpc_request.proto`, `tool/v1/rpc_request.proto`, `context/v1/rpc_request.proto`, `memory/v1/rpc_request.proto` each declare their own — see each category's `protocol.md#render`) carries a `string schema_version = 2;` field alongside the opaque `payload` this document's package comment and `.claude/rules/grpc.md`'s Emit/Render carve-out already describe. This section is the canonical, single definition of what that string means; every category's `Render` section links back here rather than re-explaining it. - -`schema_version` is round-tripped, not invented at `Render` time. A producer sets it once, at emit time, on [`kernel-callbacks.md#emit`](../kernel-callbacks.md#emit)'s `EmitRequest.schema_version` — "versions the shape of `payload`" — and the kernel persists it verbatim alongside that event's opaque `payload` byte-for-byte, per [`state-backend.md`](../state-backend.md)'s `events.schema_version` column. When the kernel later calls that category's `Render` RPC (live, or as part of a historical replay), it passes the *same* string back on `RenderRequest.schema_version` — the value is never recomputed, guessed, or defaulted by the kernel at render time, only threaded through unchanged from what the producer originally declared. - -This is what makes the "supersedes" replay model ([`architecture.md#versioning--schema-drift--supersedes`](../architecture.md#versioning--schema-drift--supersedes)) actually work across a payload-shape change, not just a plugin-version change: a plugin build MAY change what bytes its opaque `payload` contains between releases (a genuine schema evolution, not merely a bug fix), and `schema_version` is how that same build's `Render` implementation tells old-shaped payloads apart from new-shaped ones without having to sniff the bytes. A plugin's `Render` implementation MUST branch on `schema_version` (not attempt to auto-detect a payload's shape from its bytes) whenever it has ever changed its `payload` shape across a released version, and MUST continue to decode every `schema_version` it has ever emitted for as long as any retained session references it — the same permanence guarantee `.claude/rules/proto.md`'s wire-compatibility rule already places on the proto message shapes themselves, applied one level down to the opaque bytes those messages carry. - -`schema_version` is a plain string, not an integer or a proto package version — a producer is free to use whatever scheme fits its own release cadence (`"1"`, `"2024-03-shape"`, semver, ...); the kernel and this protocol series never parse or compare it, only store and echo it back. A producer that has never changed its payload shape MAY use a single constant value indefinitely; there is no requirement to bump it on every release, only on a release that actually changes what `payload`'s bytes mean. +`ActionNode` carries `id`, `label`, `tool_name`, `args`, and `provider` (tool names are unique per provider). On activation the frontend calls `KernelCallbackService.TriggerAction`; the kernel runs the normal Invoke/plan-apply pipeline with **no model turn**. diff --git a/docs/specifications/frontend/widget-protocol.md b/docs/specifications/frontend/widget-protocol.md index 45cd047..c26126a 100644 --- a/docs/specifications/frontend/widget-protocol.md +++ b/docs/specifications/frontend/widget-protocol.md @@ -1,85 +1,29 @@ -# Widget provider — protocol +# Widget provider protocol -The widget provider protocol: a plugin that contributes content *into* whichever frontend is attached, without owning the terminal/window/voice channel itself. A git-status panel or a context-budget indicator is the canonical example — content that isn't naturally "a tool" or "a context provider," it just wants to put something on screen. +A widget contributes typed metadata (or other observe-mode work) without owning the frontend. It is a genuine plugin category. ## Transport -Subprocess + gRPC via `hashicorp/go-plugin`. A widget provider plugin exposes four RPCs: `GetCapabilities`, `Configure`, `Attach`, `Describe`. +`WidgetService` exposes only: -**Unlike the frontend provider's bidirectional, connection-scoped `Attach` ([`frontend-protocol.md#transport`](frontend-protocol.md#transport)), this `Attach` is server-streaming only and stays session-scoped** (one call per session, per `AttachRequest.session_id`, not multiplexed across sessions on one connection). Widgets are passive/display-only in v1 — a widget wanting to trigger an action (not just display state) does so by *also* being a slashcommand provider ([`../slashcommand/protocol.md`](../slashcommand/protocol.md)), implementing `SlashCommandService` directly, not through this channel. A widget MAY implement `WidgetService` and `SlashCommandService` in the same plugin process — `hashicorp/go-plugin` natively muxes multiple gRPC services over one subprocess connection, the same precedent `HookSubscriberService` already establishes ([`agent-loop/hook-dispatch.md`](../agent-loop/hook-dispatch.md)). Sharing the RPC name `Attach` with the frontend protocol while having a genuinely different streaming shape is a real gotcha worth stating plainly: **frontend `Attach` is bidi and connection-multiplexed, widget `Attach` is neither.** +| RPC | Shape | +|---|---| +| `GetCapabilities` | unary | +| `Configure` | unary | +| `Describe` | unary | -```protobuf -service WidgetService { - rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); - rpc Configure(ConfigureRequest) returns (ConfigureResponse); - rpc Attach(AttachRequest) returns (stream WidgetUpdate); - rpc Describe(DescribeRequest) returns (DescribeResponse); -} -``` +There is **no** `Attach` stream. A widget that wants screen presence calls `KernelCallbackService.PublishMetadata` on the callback channel — the same path a tool provider uses for a status block. Observe-mode hooks remain available via `HookSubscriberService` for deriving what to publish. -`GetCapabilities` **MUST** be cheaply re-queryable and **MUST NOT** require a network call, the same guarantee every other category's capability RPC carries. It returns which regions this widget intends to contribute to, its config schema, and which hook points it can subscribe to: +Capabilities: config schema and supported hook points. There is no region list. -```protobuf -message WidgetCapabilities { - repeated pluggableharness.render.v1.Region regions = 1; // MUST — see render-tree.md#placement--regions - pluggableharness.config.v1.ConfigSchema config_schema = 2; - repeated pluggableharness.common.v1.HookPoint supported_hook_points = 3; -} -``` +## Screen presence -`supported_hook_points` lets the kernel reject an `agent.hcl` `hook{}` block naming a point this widget can't actually serve at config-load time, rather than discovering the mismatch at first dispatch — the same "ambiguity/misconfig is a load-time error" pattern this protocol series applies everywhere else. +Publish `MetadataBlock` values (`metadata.v1`) with a stable `id` per logical contribution. Upsert on change; call `RetractMetadata` (or rely on process-exit disconnect) when the contribution should go gray/disappear — the frontend maps `Liveness.DISCONNECTED`. -`Configure` follows the same contract as [`model/protocol.md#configure`](../model/protocol.md#configure): config decoded from the widget's `agent.hcl` block, rejected with a structured error at configure time rather than deferred, never echoing a received secret back out. +## Interactive content -`Describe` reports this plugin build's own identity — `{name, version, source, category, protocol_version}` — directly from the running process, rather than the kernel inferring it from a lock-file row. Every one of the seven category protocols gains this identical RPC in this protocol revision; it exists specifically for a `dev_overrides`-resolved binary, which has no `provider {}` lock-file entry to read identity from at all (`configuration/lock-file.md`'s "`dev_overrides` and identity without a lock entry"). - -`Attach` opens a server-streaming feed of this widget's rendered updates for one session: - -```protobuf -message AttachRequest { - string session_id = 1; -} - -message WidgetUpdate { - pluggableharness.render.v1.Region region = 1; - pluggableharness.render.v1.RenderTree content = 2; - bool replace = 3; // true: replace this widget's prior content in `region`; false: append -} -``` - -This stream is purely how the widget pushes its rendered updates out; it never receives anything back on this channel — there is no equivalent of the frontend protocol's `ClientEvent`. Cancellation is the kernel closing the gRPC stream; the plugin **MUST** treat this as normal control flow, never as an error, the same cancellation discipline every server-streaming RPC in this protocol series requires ([`model/README.md#transport--lifecycle`](../model/README.md#transport--lifecycle)). - -## Deriving display state — no new data feed - -A widget provider gets no special session-state API. It is implicitly available to subscribe to hook points in `observe` mode ([`agent-loop/hook-dispatch.md`](../agent-loop/hook-dispatch.md)) — exactly the same mechanism a cross-cutting audit-logger already uses — and derives whatever it wants to display from the events it observes: - -- A context-budget indicator watches `post-model-response`'s usage figures. -- A git-status panel watches `post-tool-call` for filesystem-provider writes. - -`Attach`'s `WidgetUpdate` stream is how the *result* of that observation reaches the frontend; `observe`-mode hook subscription is how the widget *derives* it in the first place. No parallel session-state feed exists alongside hook dispatch for this purpose — reusing the existing mechanism was a deliberate choice over inventing a second one. - -## Interactive widgets - -A widget's `WidgetUpdate.content` **MAY** include [`ActionNode`s](render-tree.md#interactive-content-the-action-node) the same way any other `RenderTree` can — no widget-specific protocol addition was needed beyond the general `action` node mechanism ([`render-tree.md#interactive-content-the-action-node`](render-tree.md#interactive-content-the-action-node)). A clickable sidebar item (a widget offering "dismiss," "retry," or "open in editor," for example) is expressed exactly like an action node contributed by any other producer: the frontend renders it interactive, and on activation dispatches `ClientEvent.action_trigger` ([`frontend-protocol.md#client-events`](frontend-protocol.md#client-events)), which the kernel handles through the normal `tool.v1` `Invoke`/plan-apply pipeline, including policy evaluation, with no model turn — the same no-model-turn dispatch shape a direct-invoke slash command's own `SlashCommandService.Invoke` ([`../slashcommand/protocol.md#invoke`](../slashcommand/protocol.md#invoke)) takes. - -This was the specific gap that motivated adding `ActionNode` to [`render-tree.md`](render-tree.md) in the first place — widgets needed a way to trigger something, not just display state — but the resulting mechanism generalizes past widgets: any producer's rendered content can offer a one-click follow-up action, not only widget-contributed panels. +Clickable transcript content remains `ActionNode` in a `RenderTree` (transcript surface). Activation is `TriggerAction` on the callback channel, not a widget stream. A widget that also needs an operator action SHOULD also be a tool provider (or emit an `ActionNode` via a tool/model render path), not invent a second action channel. ## Error taxonomy -The widget provider category has a structured error type, `WidgetError`, mirroring `FrontendError`'s shape ([`frontend-protocol.md#error-taxonomy`](frontend-protocol.md#error-taxonomy)) — this resolves [`conformance.md`](conformance.md#error-taxonomy)'s prior open question of whether widgets needed a categorized, in-band error channel of their own: - -```protobuf -enum WidgetErrorCategory { - WIDGET_ERROR_CATEGORY_UNSPECIFIED = 0; - WIDGET_ERROR_CATEGORY_RENDER_FAILED = 1; - WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED = 2; - WIDGET_ERROR_CATEGORY_UNKNOWN = 3; -} - -message WidgetError { - WidgetErrorCategory category = 1; - string message = 2; -} -``` - -Unlike the frontend category — whose `Attach` errors surface in-band via `ServerEvent.error`, since `Attach` is a long-lived bidirectional stream where tearing down the connection over one recoverable error would be disruptive — widget `Attach` is server-streaming only, with no return channel besides the stream itself. `WidgetError` is therefore carried in the structured detail of a gRPC status on `Configure` or `Attach`, mapped per the canonical `codes` table (`.claude/rules/grpc.md`): `codes.InvalidArgument` for a malformed config value or a render this widget can't produce, `codes.Internal` for anything unmapped, `codes.Canceled` for ordinary stream cancellation (never an error). A widget encountering a partial-failure condition (e.g. "this update renders for `top_bar` but not `sidebar`") reports it the same way: a `WidgetError{category: WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED}` in a gRPC status detail, since there is no in-band error variant on `WidgetUpdate` itself. +`WidgetError` / `WidgetErrorCategory` on gRPC status details for Configure (and any future unary). `REGION_UNSUPPORTED` is retired. Remaining: `RENDER_FAILED`, `UNKNOWN`. diff --git a/docs/specifications/kernel-callbacks.md b/docs/specifications/kernel-callbacks.md index 8a5c5d3..d31f4b9 100644 --- a/docs/specifications/kernel-callbacks.md +++ b/docs/specifications/kernel-callbacks.md @@ -1,6 +1,6 @@ # Kernel callback service -This formalizes the **plugin-to-kernel** direction of communication — the reverse of every other protocol in this series, which covers kernel-to-plugin RPCs (`GetCapabilities`, `Configure`, `StreamCompletion`, `Invoke`, `Attach`, and so on). Twelve primitives live here, grouped by concern: +This formalizes the **plugin-to-kernel** direction of communication — the reverse of every other protocol in this series, which covers kernel-to-plugin RPCs (`GetCapabilities`, `Configure`, `StreamCompletion`, `Invoke`, and so on). Primitives live here, grouped by concern: - **`RunSession`** — runs a full agent session on a plugin's behalf. Used for sub-agent spawns today, and reserved for a future non-interactive pipeline mode. Full turn-by-turn semantics live in [`agent-loop/subagents.md`](agent-loop/subagents.md); this document defines only the wire-level calling mechanism. `RunSessionResult` carries, alongside `final_message` and `status`, three aggregate usage fields — `total_cost_usd`, `total_input_tokens`, `total_output_tokens` — summed across every turn the child session ran, including any of its own descendant sub-agent sessions. These are deliberately flat fields, not a reference to a single completion's `Usage` shape: `RunSessionResult` reports a whole-session rollup (the same `cost_ledger` SUM [`state-backend.md`](state-backend.md) already computes), a different thing from one model call's per-call token counts. The rollup lets a calling plugin do budget-aware fan-out — checking a just-finished child's actual spend before deciding whether to spawn another — without separately re-summing the child's event history itself. - **`CountTokens`** — resolves an exact-if-possible token count for a string, the shared primitive every other category's `tokens` field routes through. @@ -10,6 +10,13 @@ This formalizes the **plugin-to-kernel** direction of communication — the reve - **`GetConfig`** — returns the calling plugin's own resolved `agent.hcl` configuration, the same already-decoded shape `Configure` received. - **`Publish`** / **`Subscribe`** — the event bus: ephemeral, best-effort, cross-plugin pub/sub, distinct from `Emit`'s durable per-session log and from hook dispatch's synchronous, `agent.hcl`-declared subscriber chain. See [`event-bus.md`](event-bus.md). - **`ReadEvents`** / **`GetSession`** — read-back primitives over the calling plugin's own session: its persisted event log, and its metadata plus live budget rollups. +- **Frontend state surfaces** (also on this channel, because the plugin is the gRPC server and cannot receive a push stream on a category service): + - **`GetSessionState`** — fixed-schema "where am I" snapshot (`session.v1.SessionState`). + - **`SubmitInput`** — operator input as `ContentBlock`s; returns `turn_id`. + - **`ResolvePlanDecision`** / **`ResolveInteractive`** / **`Interrupt`** / **`InvokeSlashCommand`** / **`TriggerAction`** — operator control. + - **`CreateSession`** / **`AttachSession`** / **`ResumeSession`** / **`DetachSession`** / **`ListSessions`** — session lifecycle (moved off the retired frontend `Attach` stream). + - **`PublishMetadata`** / **`RetractMetadata`** / **`ListMetadata`** — typed metadata blocks; retraction flips `liveness` to `DISCONNECTED`, never deletes. + - **`StreamDeltas`** — live-only token fast path (server-streaming, **not** on the bus; kernel does not batch). See [`glossary.md`](glossary.md) for how these terms fit the wider vocabulary, and [`architecture.md`](architecture.md) for the surrounding system (transport, hook dispatch, plan/apply, state backend). @@ -36,7 +43,7 @@ KernelCallbackService { } ``` -This channel and the frontend provider's `Attach` RPC are the **only** two genuinely bidirectional RPCs in the whole system — every other category RPC, and every RPC on this service, is server-streaming or unary. `Subscribe` and `ReadEvents` are server-streaming; the other ten are unary. A new primitive added to this service does not get to default to bidi "just in case"; the shape here is a consequence of `hashicorp/go-plugin`'s native plugin→kernel channel existing at all, not a free design choice repeated per RPC — and even that channel's own two RPCs (`RunSession`, `CountTokens`) are, at the application level, simple request/response calls riding a connection that happens to be bidirectional at the transport layer, not calls that themselves stream both ways. +This channel is the **only** genuinely bidirectional *transport* surface in the whole system — the plugin is the gRPC client here. Every application RPC on this service is unary or server-streaming (`Subscribe`, `ReadEvents`, `StreamDeltas`). Frontend and widget no longer expose an `Attach` stream; their category services are the standard GetCapabilities/Configure/Describe triple. A new primitive added to this service does not get to default to bidi "just in case"; the shape here is a consequence of `hashicorp/go-plugin`'s native plugin→kernel channel existing at all, not a free design choice repeated per RPC. The callback channel uses a **fixed, well-known broker ID**, not a wire-negotiated one — safe because the kernel is the only party that ever accepts this broker connection, so no collision is possible. Producer identity (`{category, name, version}`) is a property of *which broker connection a call arrived on*, established at handshake, and is server-derived, never client-supplied (see "Emit" and "Log" below) — a plugin cannot declare a producer identity other than its own. diff --git a/docs/specifications/memory/protocol.md b/docs/specifications/memory/protocol.md index bdbdaa6..48084fd 100644 --- a/docs/specifications/memory/protocol.md +++ b/docs/specifications/memory/protocol.md @@ -95,7 +95,7 @@ Content-quality guidance (what's worth remembering, what isn't, promoting verbos Memory providers MAY implement `Render` per the general Emit→Render→Paint pipeline ([`architecture.md`](../architecture.md#emit--render--paint-pipeline)), returning the `RenderTree` formally defined in [`frontend/render-tree.md`](../frontend/render-tree.md). A reference implementation might render a `pending` record specially (e.g. a review-inbox UI element distinct from ordinary recall) — this is exactly the kind of case where custom rendering matters more than the generic fallback. If not implemented, the kernel falls back to its generic default rendering. -`RenderRequest` carries `schema_version` alongside the opaque `payload` — see [`frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning) for what the value means and how a `Render` implementation is expected to use it. +`RenderRequest` carries `schema_version` alongside the opaque `payload` — see [`frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads`](../frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads) for what the value means and how a `Render` implementation is expected to use it. ## `Describe` diff --git a/docs/specifications/model/data-types.md b/docs/specifications/model/data-types.md index 0b1bc7a..7207b35 100644 --- a/docs/specifications/model/data-types.md +++ b/docs/specifications/model/data-types.md @@ -153,9 +153,12 @@ StreamEvent = oneof { tool_call_start { id: string, name: string } tool_call_delta { id: string, arguments_fragment: string } // partial-JSON accumulation tool_call_done { id: string } - usage { input_tokens, output_tokens, cache_read_tokens?, cache_write_tokens?, reasoning_tokens?, rate_limits[] } - stop { reason: StopReason, matched_stop_sequence?: string } + usage { input_tokens, output_tokens, cache_read_tokens?, cache_write_tokens?, reasoning_tokens?, rate_limits[], + vendor_cost?, vendor_total_tokens?, components[], reasoning_already_counted? } + stop { reason: StopReason, matched_stop_sequence?: string, model_affirmed?: bool } error ModelError // see conformance.md#error-taxonomy + metadata StreamMetadata // non-content facts; see #stream-metadata + safety_notice { kind: SafetyKind, message?: string, attrs{} } } StopReason = enum { @@ -190,6 +193,30 @@ It is a separate early event rather than a field on `stop` deliberately: an id t It is repeated because vendors publish several budgets at once and they exhaust independently — OpenAI and xAI return separate request and token headers, Anthropic reports input and output separately. Naming *which* budget is close to empty is the entire value: "you have 2% left" is unactionable without saying 2% of what, and a user whose session stops mid-task without being told which ceiling they hit cannot act on it. Every numeric field on a snapshot is optional, so an adapter reports the subset its vendor actually returned rather than inventing the rest. +A snapshot also carries `limit_id`, `limit_name`, `window_role` (`primary` | `secondary`), `used_percent`, and `window_seconds`. These exist for subscription products, which meter several windows at once and frequently publish only a percentage. Before them, an adapter facing such a vendor had to pick a `RateLimitKind` per window and fake `limit = 100`, `remaining = 100 - percent` to fit the absolute fields — a mapping that reads as authoritative and is not. **An adapter MUST NOT derive one form from the other**: a vendor publishing real counts sets `remaining`/`limit`; a vendor publishing a percentage sets `used_percent`; neither is computed from the other, and a budget the vendor did not report is absent rather than zero. + +### `usage.vendor_cost` — reported, never authoritative + +Some vendors return their own price for a completion (xAI's `cost_in_usd_ticks`). `vendor_cost` carries it as `{ amount, unit, currency? }`, where `amount` is an **exact decimal string** — not a double, because these reconcile against invoices and binary floating point cannot represent them exactly — and `unit` names the vendor's own denomination (`"usd"`, `"xai_ticks_1e10"`). + +**This does not change who computes cost.** The kernel still derives and persists `cost_usd` from the token counts plus the matching [`PricingTier`](#pricing) at the completion's receipt time, and every rollup, budget check, and replay reads that computed figure. `vendor_cost` is persisted beside it so an operator can see that list price and actual bill disagree, and so a subscription session — where computed cost is structurally `0.00` under `free: true` — has something truthful to show. Making the vendor figure authoritative would put two costs in the ledger with no deterministic rule for which one a replay reproduces, which the repository's replay-determinism rule (`.claude/rules/determinism.md`) treats as a correctness bug. The kernel MUST NOT convert between units: a conversion it invented would be one more unaudited number in the ledger. + +`vendor_total_tokens` and `components[]` follow the same reporting-not-deriving rule. `vendor_total_tokens` is recorded only when the vendor publishes a total that is *not* the sum of the parts — the disagreement is the point, so the kernel MUST NOT fill it in by addition. `components[]` carries vendor-defined counters with no first-class field (`{ name, value }`): per-modality input tokens, accepted/rejected prediction tokens, hosted-tool source counts. They are opaque — stored and surfaced, never interpreted — and **the kernel MUST sort them by `name` before persisting**, or the event log would inherit the adapter's own map iteration order. + +`reasoning_already_counted` is set only on an explicit vendor signal (OpenAI's `X-Reasoning-Included`) that `reasoning_tokens` is already inside `output_tokens`. Absent means "not stated", and the kernel applies the default above: a distinct count. It exists to stop the kernel double-counting reasoning in its own estimates, so guessing at it defeats the purpose. + +### `stream_metadata` — how the vendor is serving this request + +`metadata` carries non-content facts: `actual_model`, `system_fingerprint`, `service_tier`, `rate_limits[]`, `live_context_window`, `live_max_output_tokens`, `catalog_etag`, `sticky_turn_token`, and an `attrs{}` escape hatch. + +It is separate from `stream_start` because the two have different schedules. `stream_start` fires once when the vendor accepts the request; metadata may not be knowable until headers land, may change mid-stream, and **MAY be emitted more than once** — a later event supersedes an earlier one *field by field*, and an absent field means "no new information", never "cleared". + +`actual_model` is the load-bearing field. Vendors remap for safety routing, capacity, and deprecation (`grok-4` resolving to `grok-4.3`), and dropping that fact has two costs: a silent quality change becomes unattributable, and the kernel's own `cost_usd` cites pricing for a model that never ran. + +**Neither `metadata` nor `safety_notice` is a content-block boundary.** Both carry no content and both may arrive mid-stream, so a kernel accumulating a message MUST NOT close an open `text` or `thinking` block on receiving one — doing so splits a run of deltas around a header revision or a moderation notice. + +`safety_notice` reports the vendor interposing: `buffering` (output held for review, so a stall is expected and is not a hang), `moderation`, or `verification_required` (the account must complete a challenge the kernel cannot satisfy itself). A kernel that does not recognize a `kind` MUST ignore the event rather than failing the turn — an unexplained stall is strictly worse than an unrecognized notice. + A plugin MUST classify every terminal failure via a `stop` event's `content_filtered` reason or an `error` event carrying a `ModelError` ([`conformance.md#error-taxonomy`](conformance.md#error-taxonomy)) — the in-band `error` variant is how a plugin reports a classified failure *within* an otherwise-open stream, distinct from the stream simply being torn down at the transport level (a gRPC-level status, or the kernel closing the stream on cancellation). A plugin whose backend fails outright before producing any events MAY end the stream with just an `error` event and no preceding `stop`. ## Canonical message & content-block schema diff --git a/docs/specifications/model/protocol.md b/docs/specifications/model/protocol.md index a41031c..e392b7e 100644 --- a/docs/specifications/model/protocol.md +++ b/docs/specifications/model/protocol.md @@ -100,7 +100,29 @@ cost_usd = input_tokens * pricing.input_per_mtok / 1e6 Model providers MAY implement `Render` per the general Emit→Render→Paint pipeline ([`architecture.md`](../architecture.md#emit--render--paint-pipeline)), returning the `RenderTree` formally defined in [`frontend/render-tree.md`](../frontend/render-tree.md) — e.g. to render a `thinking` block collapsed by default, or to render usage/cost info specially. If not implemented, the kernel falls back to its generic default rendering. This is a MAY, not a SHOULD — most model-provider payloads (plain text, tool calls) render fine under the generic fallback; the tool-result side (owned by tool providers) is where custom rendering matters more. -`RenderRequest.schema_version` MUST be set alongside `payload` — the schema version the payload was emitted under, so a `Render` implementation can interpret a payload emitted by an older plugin version consistently when a session is replayed. See [`frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning) for the versioning scheme itself. +`RenderRequest.schema_version` MUST be set alongside `payload` — the schema version the payload was emitted under, so a `Render` implementation can interpret a payload emitted by an older plugin version consistently when a session is replayed. See [`frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads`](../frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads) for the versioning scheme itself. + +## `GetAccount` + +```text +GetAccount(GetAccountRequest{}) -> GetAccountResponse{ + account: AccountSnapshot{ method, metering, plan?, labels{}, quotas[], fetched_at? } +} +``` + +Reports the live account and entitlement state behind this plugin's credential: which pool completions are charged against, what plan is in force, and whatever quota the vendor publishes outside a completion. + +**MAY be implemented.** A provider with no account concept — a bare API key against a metered endpoint, a locally served model — returns `codes.Unimplemented`, and the kernel MUST tolerate that exactly as it tolerates an absent `Render`. Absence means "no account state to report", never an error. + +It is separate from [`GetCapabilities`](#getcapabilities) because the two have different lifetimes. Capabilities are the static roster fixed at `Configure`; account state is live, changes as quota burns down, and is the only way an operator learns a subscription pool is nearly empty *before* the turn that strands them. The kernel MUST NOT cache it as part of the capability advertisement. + +`quotas[]` reuses [`RateLimitSnapshot`](data-types.md#usagerate_limits) rather than introducing a parallel shape — pool headroom and a per-completion rate-limit budget are the same concept read at different times, and two types for it would guarantee two frontend renderers that disagree. `fetched_at` lets a frontend show how stale a reading is instead of presenting a cached figure as live, which is the specific failure that makes an operator stop trusting a usage meter. + +`method` (`api_key` | `product_session` | `deployment_key`) and `metering` (`subscription_pool` | `metered_api`) are not one-to-one: a product session can bill against credits once its pool is exhausted. The same pair is available statically on `Capabilities.auth` for a provider that knows its credential shape without a network call. + +**Nothing in `AccountSnapshot` may be a credential or leak one** — no key material, no token, no full account identifier, `labels{}` included. + +The kernel MUST NOT persist this into the session event log: it is a live reading of external state, and recording it would put a value into the replay path that no replay can reproduce (the repository's replay-determinism rule, `.claude/rules/determinism.md`). ## `Describe` diff --git a/docs/specifications/slashcommand/conformance.md b/docs/specifications/slashcommand/conformance.md index 906994c..a53e03a 100644 --- a/docs/specifications/slashcommand/conformance.md +++ b/docs/specifications/slashcommand/conformance.md @@ -33,7 +33,7 @@ Reused verbatim from [`tool/conformance.md#the-idempotent--retry-interaction`](. | Structured `ToolError` taxonomy, including `process_crashed` | MUST | reused from [`tool/conformance.md#error-taxonomy`](../tool/conformance.md#error-taxonomy) | | `output_schema` | Not applicable | `SlashCommandSpec` declares none — a direct-invoke command is never model-callable, see [`data-types.md#slashcommandspec`](data-types.md#slashcommandspec) | | Best-effort partial-mutation report on cancellation | MUST, for `resource` commands | see [`protocol.md#invoke`](protocol.md#invoke) | -| `Render` | MAY | generic fallback exists; `RenderRequest.schema_version` per [`frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning) | +| `Render` | MAY | generic fallback exists; `RenderRequest.schema_version` per [`frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads`](../frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads) | | `Preview` | MAY | [`protocol.md#preview`](protocol.md#preview); kernel MUST fall back to raw `arguments` when absent; MUST NOT mutate anything when implemented | ## Open questions diff --git a/docs/specifications/slashcommand/protocol.md b/docs/specifications/slashcommand/protocol.md index 0dfc30f..e89586e 100644 --- a/docs/specifications/slashcommand/protocol.md +++ b/docs/specifications/slashcommand/protocol.md @@ -34,7 +34,7 @@ A `SlashCommandCall` produces a `pluggableharness.plan.v1.PlanItem` with `produc ## Render -Same optionality and reasoning as [`tool/protocol.md#render`](../tool/protocol.md#render) — returning the `RenderTree` formally defined in [`frontend/render-tree.md`](../frontend/render-tree.md). If not implemented, the kernel falls back to its generic default (pretty-printed JSON payload). `RenderRequest.schema_version` names which version of the plugin's own emitted-payload schema `payload` was written under, per [`frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning). +Same optionality and reasoning as [`tool/protocol.md#render`](../tool/protocol.md#render) — returning the `RenderTree` formally defined in [`frontend/render-tree.md`](../frontend/render-tree.md). If not implemented, the kernel falls back to its generic default (pretty-printed JSON payload). `RenderRequest.schema_version` names which version of the plugin's own emitted-payload schema `payload` was written under, per [`frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads`](../frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads). ## Preview diff --git a/docs/specifications/state-backend.md b/docs/specifications/state-backend.md index 4731afe..25ba753 100644 --- a/docs/specifications/state-backend.md +++ b/docs/specifications/state-backend.md @@ -66,7 +66,7 @@ CREATE TABLE session_meta ( The one table that isn't append-only — a single row, updated in place as the session progresses. This is the table [Cross-session queries](#cross-session-queries)'s "scan session files directly" approach reads: cheap enough (one row) that opening every session file in the directory to check `parent_session_id`/ `status` is inexpensive even at moderate history size. -`status` is not append-only-monotonic: a `completed` or `cancelled` row MAY transition back to `running` — a re-open, per [`frontend/frontend-protocol.md#resume-and-re-open-semantics`](frontend/frontend-protocol.md#resume-and-re-open-semantics)'s `ResumeSession` — and `ended_at` is cleared back to `NULL` on that transition, the same as for a session's original creation. This is an ordinary in-place `UPDATE` of the existing row, not a new row and not a schema change; `error_max_turns`/`error_max_budget_usd`/`error_max_wall_clock`/`failed` never make this transition — those statuses are terminal and replay-only, never re-opened to `running`. `session.v1.SessionInfo` (the frontend protocol's wire-level read model of this row, see below) carries the same status value either way, so a frontend distinguishes "still on its first run" from "re-opened after completing" only via the sequence of `SessionStatusUpdate` events it has observed, not from `SessionInfo` alone. +`status` is not append-only-monotonic: a `completed` or `cancelled` row MAY transition back to `running` — a re-open, per [`frontend/frontend-protocol.md#session-lifecycle`](frontend/frontend-protocol.md#session-lifecycle)'s `ResumeSession` — and `ended_at` is cleared back to `NULL` on that transition, the same as for a session's original creation. This is an ordinary in-place `UPDATE` of the existing row, not a new row and not a schema change; `error_max_turns`/`error_max_budget_usd`/`error_max_wall_clock`/`failed` never make this transition — those statuses are terminal and replay-only, never re-opened to `running`. `session.v1.SessionInfo` (the frontend protocol's wire-level read model of this row, see below) carries the same status value either way, so a frontend distinguishes "still on its first run" from "re-opened after completing" only via the sequence of `SessionStatusUpdate` events it has observed, not from `SessionInfo` alone. `session.v1.SessionInfo` — the message `frontend/frontend-protocol.md`'s `SessionCreated`/`SessionAttached`/`SessionList` `ServerEvent` variants carry — mirrors this table's columns field-for-field (`session_id`, `parent_session_id`, `profile`, `status`, `depth`, `started_at`, `ended_at`), plus one derived field with no column of its own: `cost_usd`, a cheap `SUM(cost_usd)` over this session's `cost_ledger` rows (below), computed at read time rather than cached in `session_meta` — the same indexed-`SUM` query [`cost_ledger`](#cost_ledger)'s own description already establishes as cheap. @@ -185,7 +185,7 @@ Three things deliberately do **not** get their own `kind`: `session_meta.parent_session_id` exists for **post-hoc** queries — reconstructing a session tree after the fact (a CLI command, an audit), by scanning files (see [Cross-session queries](#cross-session-queries)). It is **not** how live mechanisms like cost-rollup or depth-budget threading ([`agent-loop/subagents.md#depth-limits`](agent-loop/subagents.md#depth-limits)) work — those operate on the kernel's own in-memory session state while `RunSession` calls are actively executing, via the callback data flow already defined in [`kernel-callbacks.md`](kernel-callbacks.md), and never need to open a sqlite file to find an ancestor. The two mechanisms answer different questions (what's happening right now vs. what happened previously) and deliberately don't share a code path. -Replay is the other live/post-hoc distinction worth being explicit about: replaying an old session means feeding its persisted events back through the same Render/Paint path a live session uses, against the state backend instead of the live loop ([`architecture.md#emit--render--paint-pipeline`](architecture.md#emit--render--paint-pipeline)). [`frontend/frontend-protocol.md#backfill--the-replay-path-not-a-new-subsystem`](frontend/frontend-protocol.md#backfill--the-replay-path-not-a-new-subsystem) is exactly this mechanism wearing its frontend-facing name: a newly attaching (or resuming) frontend's backfill batch is this same event-replay-through-Render walk over `events`, in `sequence` order, using each event's own `producer_category`/`producer_name`/`producer_version`/`schema_version` columns to invoke the correct (possibly historical, "supersedes"-resolved) plugin build's `Render` — not a separate, frontend-specific replay implementation. Telemetry is the one thing that must *not* replay faithfully — trace/span IDs are genuinely non-deterministic and MUST NOT be persisted to `events`, `cost_ledger`, or `plan_items`: this schema deliberately has no `trace_id`/`span_id` column anywhere. Consequently, whatever eventually implements replay MUST select a no-op telemetry driver unconditionally, ignoring whatever driver was configured for the live session. A replayed session re-emitting production telemetry, or attempting to reproduce identical trace/span IDs, would both be wrong in ways this schema's silence on trace/span columns is designed to make structurally impossible. +Replay is the other live/post-hoc distinction worth being explicit about: replaying an old session means feeding its persisted events back through the same Render/Paint path a live session uses, against the state backend instead of the live loop ([`architecture.md#emit--render--paint-pipeline`](architecture.md#emit--render--paint-pipeline)). [`frontend/frontend-protocol.md#transcript`](frontend/frontend-protocol.md#transcript) is exactly this mechanism wearing its frontend-facing name: a newly attaching (or resuming) frontend's backfill batch is this same event-replay-through-Render walk over `events`, in `sequence` order, using each event's own `producer_category`/`producer_name`/`producer_version`/`schema_version` columns to invoke the correct (possibly historical, "supersedes"-resolved) plugin build's `Render` — not a separate, frontend-specific replay implementation. Telemetry is the one thing that must *not* replay faithfully — trace/span IDs are genuinely non-deterministic and MUST NOT be persisted to `events`, `cost_ledger`, or `plan_items`: this schema deliberately has no `trace_id`/`span_id` column anywhere. Consequently, whatever eventually implements replay MUST select a no-op telemetry driver unconditionally, ignoring whatever driver was configured for the live session. A replayed session re-emitting production telemetry, or attempting to reproduce identical trace/span IDs, would both be wrong in ways this schema's silence on trace/span columns is designed to make structurally impossible. ## Cross-session queries diff --git a/docs/specifications/tool/conformance.md b/docs/specifications/tool/conformance.md index 88b96f8..353c054 100644 --- a/docs/specifications/tool/conformance.md +++ b/docs/specifications/tool/conformance.md @@ -79,7 +79,7 @@ This interacts with, but is distinct from, `concurrency_conflict`'s existing "re | Structured `ToolError` taxonomy, including `process_crashed` | MUST | | | Strict `output_schema` enforcement | MUST | [`protocol.md#invoke`](protocol.md#invoke) | | Best-effort partial-mutation report on cancellation | MUST, for `resource` operations | see [`protocol.md#invoke`](protocol.md#invoke) | -| `Render` | MAY | generic fallback exists; `RenderRequest.schema_version` per [`../frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning) | +| `Render` | MAY | generic fallback exists; `RenderRequest.schema_version` per [`../frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads`](../frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads) | | `Preview` | MAY | [`protocol.md#preview`](protocol.md#preview); kernel MUST fall back to raw `arguments` when absent; MUST NOT mutate anything when implemented | ## Open questions diff --git a/docs/specifications/tool/protocol.md b/docs/specifications/tool/protocol.md index 4f82c8d..9f010b7 100644 --- a/docs/specifications/tool/protocol.md +++ b/docs/specifications/tool/protocol.md @@ -77,7 +77,7 @@ Semantics: Same optionality as [`model/protocol.md#render`](../model/protocol.md#render) — returning the `RenderTree` formally defined in [`frontend/render-tree.md`](../frontend/render-tree.md) — but tool-result rendering is where custom `Render` matters *more* than it does for model providers (per [`architecture.md`](../architecture.md#emit--render--paint-pipeline), "the tool-result side ... is where custom rendering matters more"). Reference examples: an `edit_file` result rendering as a unified diff rather than raw before/after text; an `exec` result's accumulated `output_chunk`s rendering as a scrollback pane; a `spawn_subagent` result rendering as a collapsible sub-session node ([`architecture.md`](../architecture.md#emit--render--paint-pipeline)'s `RenderTree` already reserves a node type for this). If not implemented, the kernel falls back to its generic default (pretty-printed JSON payload). -`RenderRequest.schema_version` names which version of the plugin's own emitted-payload schema `payload` was written under, per [`frontend/render-tree.md#schema-versioning`](../frontend/render-tree.md#schema-versioning) — the canonical definition of the versioning scheme every category's `Render` shares. It lets a long-lived plugin decode a `payload` that an older build of itself emitted, without the kernel needing to know anything about the plugin's internal payload format. +`RenderRequest.schema_version` names which version of the plugin's own emitted-payload schema `payload` was written under, per [`frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads`](../frontend/render-tree.md#schema-versioning-for-opaque-emit-payloads) — the canonical definition of the versioning scheme every category's `Render` shares. It lets a long-lived plugin decode a `payload` that an older build of itself emitted, without the kernel needing to know anything about the plugin's internal payload format. ## Preview diff --git a/examples/provider/conformance_test.go b/examples/provider/conformance_test.go deleted file mode 100644 index 5a445d7..0000000 --- a/examples/provider/conformance_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import ( - "testing" - - "github.com/pluggableharness/agent/pkg/model/modeltest" -) - -// TestConformance runs the shared conformance suite against this example. -// -// It is the check that makes the example trustworthy as a starting point: -// a reference an author copies from must itself satisfy the requirements -// it is meant to demonstrate. Running from a separate module also proves -// modeltest is reachable and usable by a third party, which is the whole -// premise of shipping it in pkg/. -func TestConformance(t *testing.T) { - t.Parallel() - - // No WithExpectedIdentity: in-process the identity is modeltest's own, - // so the expectation is unverifiable there. RunBinary is where a - // plugin's own identity stamping gets checked. - modeltest.Run(t, &echoProvider{greeting: "hello"}) -} diff --git a/examples/provider/go.mod b/examples/provider/go.mod deleted file mode 100644 index 2047968..0000000 --- a/examples/provider/go.mod +++ /dev/null @@ -1,40 +0,0 @@ -// This is a SEPARATE module on purpose. See main.go's package comment. -module github.com/pluggableharness/agent-example-provider - -go 1.26 - -require ( - github.com/pluggableharness/agent v0.0.0 - google.golang.org/protobuf v1.36.11 -) - -require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/fatih/color v1.13.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-plugin v1.8.0 // indirect - github.com/hashicorp/yamux v0.1.2 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/oklog/run v1.1.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect - google.golang.org/grpc v1.82.1 // indirect -) - -// Resolved from the working tree rather than the module proxy so this -// example is built against the pkg/ surface as it exists in this commit, -// not against the last published release. Without it, CI would prove -// nothing about the change under review. -replace github.com/pluggableharness/agent => ../.. diff --git a/examples/provider/go.sum b/examples/provider/go.sum deleted file mode 100644 index d3b10aa..0000000 --- a/examples/provider/go.sum +++ /dev/null @@ -1,80 +0,0 @@ -github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= -github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= -github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= -github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= -github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= -github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= -github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/provider/main.go b/examples/provider/main.go deleted file mode 100644 index 6397b21..0000000 --- a/examples/provider/main.go +++ /dev/null @@ -1,252 +0,0 @@ -// Command provider is a minimal, complete model-provider plugin, and the -// reference an author starts from when writing a real one. -// -// # Why this is a separate Go module -// -// It exists to prove a property nothing else in this repository can: -// that `pkg/` is sufficient to write a plugin against, from outside this -// module. `internal/anthropic` is held to a depguard rule that forbids it -// importing any other `internal/` package, which *simulates* that -// isolation — but a simulation cannot catch an unexported type leaking -// through an exported signature, or a `pkg/` package that only compiles -// because something in the main module already resolved a dependency for -// it. Building this module in CI does. -// -// Its own go.mod carries a replace directive back to the working tree, so -// it is built against `pkg/` as it exists in the commit under review -// rather than the last published release. -// -// # What it demonstrates -// -// The three MUST RPCs (docs/specifications/model/conformance.md's summary -// matrix), the optional TokenCounter, and the plugin.Serve wiring. It -// invents no vendor: StreamCompletion echoes a canned completion, so the -// example stays about the SDK surface rather than about HTTP. -// -// A real provider replaces echoProvider's bodies with vendor calls and -// its catalog with a real roster. Everything else here — the identity -// stamping, the Serve call, the capability declaration shape — is what -// that provider would also do. -package main - -import ( - "context" - "fmt" - "strings" - - "google.golang.org/protobuf/types/known/structpb" - - commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - "github.com/pluggableharness/agent/pkg/config" - configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" - "github.com/pluggableharness/agent/pkg/plugin" -) - -// modelID is the single model this example serves. -const modelID = "example-echo-1" - -// attrGreeting is the one config attribute, present so the example shows -// a real ConfigSchema round trip rather than an empty one. -const attrGreeting = "greeting" - -// Identity this build reports through Describe. Variables rather than -// constants so a release build can stamp them with -ldflags, matching how -// cmd/anthropic does it. -var ( - pluginName = "example-echo" - pluginVersion = "0.0.0" - pluginSource = "github.com/pluggableharness/agent/examples/provider" -) - -// echoProvider implements model.Provider without a vendor behind it. -type echoProvider struct { - greeting string -} - -// Compile-time proof this type serves the three MUST RPCs and the SHOULD -// one. Render (MAY) is deliberately absent — the kernel's generic -// fallback renders plain text fine, which is what a provider with nothing -// unusual to show should do. -var ( - _ model.Provider = (*echoProvider)(nil) - _ model.TokenCounter = (*echoProvider)(nil) -) - -// Capabilities declares the roster and config schema. -// -// No network call and no caching needed: the roster is a package -// constant. A provider fronting a gateway, whose roster is genuinely -// dynamic, resolves it once in Configure and serves it from memory here -// instead — see docs/specifications/model/protocol.md#getcapabilities. -func (p *echoProvider) Capabilities(context.Context) (*model.Capabilities, error) { - greeting, err := config.Attribute(attrGreeting, configv1.AttrType_ATTR_TYPE_STRING, - config.WithDefault(`"hello"`), - config.WithDescription("Prefix this provider prepends to every echoed completion."), - ) - if err != nil { - return nil, fmt.Errorf("example: config schema: %w", err) - } - schema, err := config.Schema(greeting) - if err != nil { - return nil, fmt.Errorf("example: config schema: %w", err) - } - - return model.NewCapabilities([]model.Spec{{ - ID: modelID, - ContextWindow: 200_000, - MaxOutputTokens: 4096, - SupportsToolUse: true, - // Thinking and Caching left at their zero values: this model does - // neither, and the zero value is the valid declaration for that. - Thinking: model.ThinkingSpec{}, - Caching: model.CachingSpec{}, - // Free, so the kernel's cost ledger stays at zero. A real provider - // declares at least one PricingTier here, and exactly one tier must - // match any (timestamp, input_token_count) pair. - Pricing: model.Pricing{Currency: "USD", Free: true}, - SupportedToolChoiceModes: []modelv1.ToolChoiceMode{ - modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO, - }, - }}, schema) -} - -// Configure decodes the provider's agent.hcl block. -// -// It is written to be safely re-callable — the kernel may Configure a -// running plugin again when its configuration changes — so it replaces -// state wholesale rather than mutating it in place. A real provider -// rebuilds its vendor client here for the same reason. -func (p *echoProvider) Configure(_ context.Context, cfg *structpb.Struct) error { - greeting := "hello" - if v, ok := cfg.GetFields()[attrGreeting]; ok && v.GetStringValue() != "" { - greeting = v.GetStringValue() - } - p.greeting = greeting - return nil -} - -// StreamCompletion echoes the last user message back. -// -// Note what a Provider is responsible for even with no vendor involved: -// exactly one terminal event (Sink enforces this), a usage event so the -// kernel has something to account, and treating cancellation as normal -// control flow rather than an error. -func (p *echoProvider) StreamCompletion(ctx context.Context, req *modelv1.StreamCompletionRequest, sink *model.Sink) error { - if req.GetModelId() != modelID { - return &model.Error{ - Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, - Message: fmt.Sprintf("example: unknown model %q", req.GetModelId()), - Retryable: false, - } - } - - // Content this model does not declare support for MUST be rejected, - // never silently dropped: a dropped image means the model answers a - // question about a picture it was never shown, and nothing upstream - // can tell that happened. - for _, m := range req.GetMessages() { - for _, b := range m.GetContent() { - switch b.GetBlock().(type) { - case *contentv1.ContentBlock_Image, *contentv1.ContentBlock_Document: - return &model.Error{ - Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, - Message: "example: this model accepts text only", - Retryable: false, - } - } - } - } - - // provider_options is pass-through: the kernel never reads a key, so a - // vendor knob lives here rather than needing a protocol change. This - // example uses it to let an operator override the greeting per request. - greeting := p.greeting - if v, ok := model.ProviderOptions(req).LookupString(attrGreeting); ok { - greeting = v - } - - reply := greeting + ", " + lastUserText(req.GetMessages()) - - // Cancellation is normal control flow: return ctx.Err() unwrapped so - // errors.Is(err, context.Canceled) works, and let pkg/model map it to - // a bare codes.Canceled rather than an application error. - if err := ctx.Err(); err != nil { - return err - } - if err := sink.TextDelta(reply); err != nil { - return err - } - if err := sink.Usage(model.Usage{ - InputTokens: int64(len(reply) / 4), - OutputTokens: int64(len(reply) / 4), - }); err != nil { - return err - } - return sink.Stop(modelv1.StopReason_STOP_REASON_END_TURN, "") -} - -// CountTokens implements the optional TokenCounter. -// -// It counts a whole request — messages, assembled context, and tool -// declarations — because that is what the RPC measures. A real provider -// calls its vendor's counting endpoint with the same three; the point -// here is that tool schemas are counted at all, since they are frequently -// the largest contributor and the easiest thing to forget. -func (p *echoProvider) CountTokens(_ context.Context, req *modelv1.CountTokensRequest) (int64, error) { - var bytes int - for _, m := range req.GetMessages() { - for _, b := range m.GetContent() { - bytes += len(b.GetText().GetText()) - } - } - for _, s := range req.GetAssembledContext() { - for _, b := range s.GetContent() { - bytes += len(b.GetText().GetText()) - } - } - for _, t := range req.GetTools() { - bytes += len(t.GetName()) + len(t.GetDescription()) - } - return int64((bytes + 3) / 4), nil -} - -// lastUserText returns the text of the most recent user message, or a -// placeholder when there is none. -func lastUserText(messages []*contentv1.Message) string { - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].GetRole() != contentv1.Role_ROLE_USER { - continue - } - var sb strings.Builder - for _, b := range messages[i].GetContent() { - sb.WriteString(b.GetText().GetText()) - } - if sb.Len() > 0 { - return sb.String() - } - } - return "(nothing to echo)" -} - -func main() { - identity := plugin.Identity{ - Name: pluginName, - Version: pluginVersion, - Source: pluginSource, - } - - // Constructed here and handed to both Serve and the service, but never - // dialed from main: pkg/plugin's callback-timing trap means - // Callback.Client may only be called from inside an RPC handler. - callback := plugin.NewCallback() - - plugin.Serve(plugin.Config{ - Identity: identity, - Category: commonv1.Category_CATEGORY_MODEL, - Callback: callback, - Services: []plugin.Service{model.NewService(&echoProvider{greeting: "hello"}, identity, callback)}, - }) -} diff --git a/go.mod b/go.mod index ff290d5..03c3a58 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,6 @@ module github.com/pluggableharness/agent go 1.26 require ( - charm.land/bubbletea/v2 v2.0.8 - charm.land/lipgloss/v2 v2.0.5 - github.com/charmbracelet/x/ansi v0.11.7 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.8.0 github.com/hashicorp/hcl/v2 v2.24.0 @@ -44,13 +41,6 @@ require ( github.com/apparentlymart/go-textseg/v17 v17.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.13.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -59,17 +49,12 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oklog/run v1.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.57.0 // indirect diff --git a/go.sum b/go.sum index 6c3c11b..46957f5 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,15 @@ -charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= -charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= -charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= -charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/apparentlymart/go-textseg/v17 v17.0.1 h1:bpMXRgQ5cEoRNuQke1a80/Nl6w3G5eoIbWo9f3gXkAs= github.com/apparentlymart/go-textseg/v17 v17.0.1/go.mod h1:fa8X4jgGeevslICIY6LcdjkSecWnXmYd9Lk34z/VxZs= -github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= -github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= -github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -70,8 +46,6 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -79,12 +53,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= @@ -96,14 +66,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4= github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= @@ -156,8 +122,6 @@ go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6Tb go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= diff --git a/internal/anthropic/CLAUDE.md b/internal/anthropic/CLAUDE.md deleted file mode 100644 index 0d4d9d1..0000000 --- a/internal/anthropic/CLAUDE.md +++ /dev/null @@ -1,53 +0,0 @@ -# internal/anthropic — agent notes - -## This package's whole point is that it has no privileges - -`internal/anthropic` is the reference model provider. It exists to answer a question the rest of the repository cannot answer about itself: **is `pkg/` actually sufficient to write a real plugin against?** That answer is only worth anything if this package is held to exactly what a third party gets — `pkg/...` plus the standard library, nothing else. - -So the import rule is not a style preference: - -> `internal/anthropic/**` (non-test files) MUST NOT import any other `internal/...` package. - -A `depguard` rule in [`.golangci.yml`](../../.golangci.yml) (`anthropic-plugin-isolation`) enforces it mechanically. If you find yourself wanting something from `internal/`, that is the signal that the thing belongs in `pkg/` — move it there and let every plugin author have it. Do not widen the depguard rule, and do not route around it with an indirection. - -Test files are exempt, deliberately: the integration tier imports `internal/pluginruntime` to launch the built binary the way the kernel does. That is the kernel-launches-plugin direction, not a dependency of the plugin on the kernel. - -## The telemetry rule does not apply here, and that is a real deviation - -[`logging-telemetry.md`](../../.claude/rules/logging-telemetry.md) makes `internal/telemetry` mandatory for any `internal/` package that does I/O, and this package does plenty. It is not wired up, because it cannot be: `internal/telemetry` is an `internal/` package, and importing it would break the isolation property above — the property this package exists to demonstrate. - -`log/slog` **is** used (stdlib, so no conflict) and carries the DEBUG/WARN/ERROR obligations the rule describes. What is missing is spans and metrics. - -This is a gap in the plugin-author surface, not a shortcut taken here: a third-party plugin has no way to emit a span either. The protocol already anticipates the answer — `observability.md`'s relay model has plugins export finished spans through the kernel callback channel — but no `pkg/` package exposes it yet. When one does, wire it up here first; this package is the natural proving ground for it. - -## No retries. None. Anywhere. - -Classify the failure, set `Retryable` and `RetryAfter`, return. The kernel's `internal/modelcall` owns retry and backoff, and [`grpc.md`](../../.claude/rules/grpc.md) states it plainly: *"a provider does not invent its own retry policy inside the plugin; it returns the right code and lets the kernel decide."* - -This is also why the vendor SDK is not a dependency — see [`messages/CLAUDE.md`](messages/CLAUDE.md). - -## No cost arithmetic either - -The plugin reports token counts through `Sink.Usage` and declares `Pricing` in [`catalog/`](catalog/). The kernel multiplies them and persists the result ([`protocol.md#cost-computation`](../../docs/specifications/model/protocol.md#cost-computation)). If a diff in this package ever computes a dollar figure, that is a second source of truth for a number that is supposed to have exactly one. - -## Secrets: the API key arrives exactly once, through Configure - -It comes in `ConfigureRequest.config`, already resolved from `env(...)` by the kernel's HCL bridge. It **never** comes from `os.Getenv` — `internal/pluginruntime`'s `buildEnv` gives a launched subprocess only `PATH`/`HOME`/`TMPDIR` plus an OTel resource stamp, so the kernel's own environment is not visible here at all. A plugin reaching for `os.Getenv("ANTHROPIC_API_KEY")` would work only by accident on a developer's machine and fail under the real launcher. - -It must not reach a log line, an error message, an emitted event, or a `Render` output ([`protocol.md#configure`](../../docs/specifications/model/protocol.md#configure)). `config_test.go`'s `TestDecodeSettings_neverEchoesTheKey` runs every rejection path with a real-looking key present specifically so a future edit that interpolated the whole config into a message fails there rather than in production. - -## base_url allows plain http, but only to loopback - -`validateBaseURL` rejects `http://` to any remote host, because the API key rides in a request header. Loopback is exempt so the integration tier can point the plugin at an `httptest.Server`. The loopback check parses the host as an IP (or matches `localhost` exactly) — never a string prefix, because `127.0.0.1.evil.example.com` is a remote hostname and a prefix check would accept it. There is a test for exactly that. - -## Where things live - -| Concern | Home | -|---|---| -| `model.Provider` implementation, the RPC surface | `provider.go` | -| `agent.hcl` schema, decoding, validation | `config.go` | -| Secret-safe `*model.Error` construction | `errors.go` | -| Model roster and pricing | [`catalog/`](catalog/) | -| Everything Anthropic-shaped: JSON types, translation, SSE, HTTP, classification | [`messages/`](messages/) | - -Read `messages/CLAUDE.md` before touching anything under `messages/` — two of the rules there look like obvious simplifications and are not. diff --git a/internal/anthropic/README.md b/internal/anthropic/README.md deleted file mode 100644 index 364fb9a..0000000 --- a/internal/anthropic/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# internal/anthropic - -The Anthropic model provider — this repository's reference implementation of the [`ModelService`](../../docs/specifications/model/README.md) contract, served as a `hashicorp/go-plugin` subprocess by [`cmd/anthropic`](../../cmd/anthropic). - -It is the first plugin in the tree that talks to a real vendor, and it is built the way a third party would build one: against [`pkg/model`](../../pkg/model), [`pkg/plugin`](../../pkg/plugin), [`pkg/config`](../../pkg/config), and [`pkg/content`](../../pkg/content) alone, plus the standard library. That constraint is enforced by a `depguard` rule rather than left to discipline — see [`CLAUDE.md`](CLAUDE.md). - -## Layout - -| Package | Owns | -|---|---| -| `internal/anthropic` | The `model.Provider` implementation, the `agent.hcl` config schema and its decoding, and secret-safe error construction | -| [`catalog/`](catalog/) | The model roster and its pricing — pure data, no I/O | -| [`messages/`](messages/) | Everything vendor-shaped: Anthropic's JSON types, canonical↔vendor translation, the SSE reader, the HTTP client, and error classification | - -## Configuration - -```hcl -required_providers { - anthropic = { - source = "github.com/pluggableharness/agent-provider-anthropic" - version = "~> 1.0" - } -} - -provider "anthropic" { - api_key = env("ANTHROPIC_API_KEY") -} -``` - -| Attribute | Required | Default | Notes | -|---|---|---|---| -| `api_key` | yes | — | Sensitive, so `agent.hcl` may only reach it through `env(...)`. The kernel resolves the indirection; the plugin receives the literal value. | -| `base_url` | no | `https://api.anthropic.com` | For a gateway or a proxy. Plain `http://` is accepted only for a loopback host. | -| `request_timeout_seconds` | no | `600` | Ceiling on one request, not a target. A high-effort completion legitimately runs for minutes. | - -`Configure` validates all three and fails immediately on a bad one, rather than deferring the failure to the first completion ([`protocol.md#configure`](../../docs/specifications/model/protocol.md#configure)). - -## What it deliberately does not do - -- **No vendor SDK.** Two endpoints are hand-rolled on `net/http`. Adding `anthropic-sdk-go` to `go.mod` would tax every downstream plugin author with a dependency only this plugin needs, and the SDK's built-in retry behavior directly conflicts with the kernel owning retry. See [`messages/CLAUDE.md`](messages/CLAUDE.md). -- **No retries.** Every failure is classified into a `model.Error` with `Retryable`/`RetryAfter` set; `internal/modelcall` decides what happens next. -- **No cost arithmetic.** The plugin reports token counts and declares pricing; the kernel multiplies and persists. -- **No cache-breakpoint placement.** The kernel decides where breakpoints go — it is the side that knows each context section's `Stability`. The adapter only translates the breakpoints it is handed into vendor `cache_control` markers. - -## Tests - -Three tiers, per [`go-testing.md`](../../.claude/rules/go-testing.md): - -```sh -go test ./internal/anthropic/... # unit — fully offline -go test -tags=integration ./internal/anthropic/... # launches the real binary against an httptest server -AGENT_E2E_LIVE=1 ANTHROPIC_API_KEY=... \ - go test -tags=e2e ./internal/anthropic/... # one real, billed call -``` - -The e2e tier is double-gated on both `ANTHROPIC_API_KEY` **and** `AGENT_E2E_LIVE=1`, so a key present for unrelated reasons never silently spends money. It is not part of the required CI checks. diff --git a/internal/anthropic/catalog/CLAUDE.md b/internal/anthropic/catalog/CLAUDE.md deleted file mode 100644 index 1f39e4c..0000000 --- a/internal/anthropic/catalog/CLAUDE.md +++ /dev/null @@ -1,32 +0,0 @@ -# internal/anthropic/catalog — agent notes - -## Never write a number here from memory - -Every figure in `catalog.go` — model ID, context window, output ceiling, and above all every rate — MUST come from Anthropic's own current published documentation, fetched at the time of the edit. Not from recall, not from another file in this repo, not from a training-data prior about what Claude models cost. - -This is stricter than ordinary care because of where the numbers end up. The kernel computes `cost_usd` from `Pricing` **the moment each `usage` event arrives** and persists the dollar amount into `cost_ledger` ([`protocol.md#cost-computation`](../../../docs/specifications/model/protocol.md#cost-computation)). Nothing ever recomputes it. A rate that is wrong today produces ledger rows that stay wrong forever, in every session that ran against it, with no error anywhere to notice it by. [`determinism.md`](../../../.claude/rules/determinism.md) treats that as a correctness bug. - -The `sourcedOn` constant records when the table was last verified. If it is materially stale and you are touching this file, re-verify the whole table and move the date — don't edit one row against fresh data and leave the rest carrying an old date's authority. - -Sources: `https://platform.claude.com/docs/en/about-claude/models/overview` (roster, context windows, output ceilings) and `https://platform.claude.com/docs/en/about-claude/pricing` (every rate, including the cache and batch columns). - -## Model IDs are pinned snapshots, not aliases - -From the 4.6 generation onward, `claude-opus-5` and friends are dateless **and pinned** — they are not evergreen pointers that silently move to a newer model. Do not "modernize" an ID by appending a date suffix (`claude-opus-5-20260609`); that is a 404. Older models (Haiku 4.5) do have dated IDs, and their bare alias resolves to the dated one — `claude-haiku-4-5` is correct and preferred. - -## Two ThinkingSpec judgment calls worth not re-litigating - -Both are explained in full in `catalog.go`'s own comments; this is the short form so a reviewer knows they were deliberate. - -- **Fable 5 is `DISCRETE_EFFORT` with `CanDisable: false`, not `ALWAYS_ON_ADAPTIVE`.** Its reasoning genuinely cannot be switched off, which is what `ALWAYS_ON_ADAPTIVE` describes — but that mode also means "no caller-selectable effort level", and Fable 5 *does* expose the full effort ladder. `DISCRETE_EFFORT` + `CanDisable: false` carries both facts; the other choice carries only one. -- **Opus 5 declares `CanDisable: true` even though disabling is effort-conditional.** Anthropic accepts `thinking: {type: "disabled"}` only at effort `high` or below. The protocol has no field for a conditional disable, and `false` would be the larger lie — it would deny a control that exists across three of the five effort levels. - -## Only the 5-minute cache-write rate is quoted - -Anthropic publishes two cache-write rates (5-minute at 1.25x input, 1-hour at 2x). `PricingTier` has exactly one `CacheWritePerMtok` field, and the adapter never sets a `ttl` on a breakpoint, so 5-minute is the only rate this plugin can actually incur. Quoting the 1-hour rate would overstate every cached turn by 60%. If the adapter ever gains 1-hour breakpoints, that needs a protocol change, not a quiet edit to this number. - -## Adding a model - -1. Verify the full table against the two live doc pages above; update `sourcedOn`. -2. Add the constructor next to its generation-mates and register it in `Models()`, keeping the newest-first ordering. -3. Run `go test ./internal/anthropic/catalog/...`. The tests are transcription guards, not restatements — a broken cache/batch ratio or a non-parsing tier window means a typo, not a test that needs relaxing. Fix the number, never the assertion. diff --git a/internal/anthropic/catalog/README.md b/internal/anthropic/catalog/README.md deleted file mode 100644 index 8d7abb3..0000000 --- a/internal/anthropic/catalog/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# internal/anthropic/catalog - -The Anthropic model roster — one [`model.Spec`](../../../pkg/model/model.go) per model the provider plugin can serve, with the context window, output ceiling, capability flags, reasoning controls, and pricing the kernel needs to route to it and to bill for it. - -Pure data. No I/O, no network, no vendor call. [`protocol.md#getcapabilities`](../../../docs/specifications/model/protocol.md#getcapabilities) requires `GetCapabilities` to be cheap to call repeatedly and to avoid a vendor round trip, so the roster is a compiled-in table refreshed by editing this package, not by querying `/v1/models` at runtime. - -## The roster - -Eight models, newest generation first: - -| Model | Context | Max output | Input $/MTok | Output $/MTok | Reasoning control | -|---|---|---|---|---|---| -| `claude-fable-5` | 1M | 128k | 10.00 | 50.00 | effort ladder, cannot be disabled | -| `claude-opus-5` | 1M | 128k | 5.00 | 25.00 | effort ladder | -| `claude-opus-4-8` | 1M | 128k | 5.00 | 25.00 | effort ladder | -| `claude-opus-4-7` | 1M | 128k | 5.00 | 25.00 | effort ladder | -| `claude-opus-4-6` | 1M | 128k | 5.00 | 25.00 | effort ladder (no `xhigh`) | -| `claude-sonnet-5` | 1M | 128k | 2.00 → 3.00 | 10.00 → 15.00 | effort ladder | -| `claude-sonnet-4-6` | 1M | 128k | 3.00 | 15.00 | effort ladder (no `xhigh`) | -| `claude-haiku-4-5` | 200k | 64k | 1.00 | 5.00 | token budget | - -Claude Sonnet 5's two rates are the introductory price (through 2026-08-31) and the standard price that follows — modeled as two adjacent, half-open [`PricingTier`](../../../docs/specifications/model/data-types.md#pricing) windows rather than a single figure, so a session run on either side of the cutover replays showing the rate it actually paid. - -## Two deliberate omissions - -- **Claude Mythos 5** shares Fable 5's specs and pricing exactly, but access is invitation-only through Project Glasswing. Advertising it would make it a routing candidate that fails at request time for nearly every operator. -- **Claude Opus 4.1** and everything older is deprecated or retired. A deprecated model in the roster is a fallback candidate that stops working on a date nobody is watching for. - -## Why the pricing figures are treated as load-bearing - -The kernel computes `cost_usd` from `Pricing` at the instant each `usage` event arrives and **persists the dollar figure**, per [`protocol.md#cost-computation`](../../../docs/specifications/model/protocol.md#cost-computation). Nothing recomputes it later. A mistyped rate here is therefore a permanently wrong `cost_ledger` row in every session that ran against it — a correctness bug under [`determinism.md`](../../../.claude/rules/determinism.md), not a display issue. - -`catalog_test.go` guards against transcription errors rather than restating the table: Anthropic publishes cache and batch rates as fixed multipliers of the base rate (cache write 1.25x input, cache read 0.1x input, batch 0.5x both directions), so the tests assert those ratios hold. A mistyped digit breaks a ratio even when the number still looks plausible on its own. - -## Updating the roster - -See [`CLAUDE.md`](CLAUDE.md) for the procedure and the rule about where the numbers may come from. diff --git a/internal/anthropic/catalog/catalog.go b/internal/anthropic/catalog/catalog.go deleted file mode 100644 index 848238a..0000000 --- a/internal/anthropic/catalog/catalog.go +++ /dev/null @@ -1,347 +0,0 @@ -package catalog - -import ( - "time" - - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// sourcedOn is the date every figure in this file was transcribed from -// Anthropic's published documentation (platform.claude.com's models -// overview and pricing pages). It is a plain string rather than a -// time.Time because nothing computes with it — it exists so a reader can -// tell at a glance how stale the roster is, and so the staleness check in -// this package's CLAUDE.md has a single value to compare against. -const sourcedOn = "2026-07-25" - -// currency is the only currency this catalog quotes. -// docs/specifications/model/data-types.md#pricing constrains v1 to "USD". -const currency = "USD" - -// Token-count constants shared across several models, named so a reader -// sees the shape of the roster rather than a wall of digits. -const ( - contextWindow1M = 1_000_000 - contextWindow200K = 200_000 - - maxOutput128K = 128_000 - maxOutput64K = 64_000 -) - -// effortLevels5 is the full effort ladder Anthropic exposes on Claude -// Opus 4.7 and later (xhigh was introduced with Opus 4.7, between high -// and max). effortLevels4 is the pre-4.7 ladder, still current for the -// 4.6 generation. -var ( - effortLevels5 = []string{"low", "medium", "high", "xhigh", "max"} - effortLevels4 = []string{"low", "medium", "high", "max"} -) - -// defaultEffort is the level Anthropic applies when a request omits -// output_config.effort entirely, for every model in this roster that -// exposes an effort ladder. -const defaultEffort = "high" - -// sonnet5IntroEnd is the instant Claude Sonnet 5's introductory pricing -// stops applying. Anthropic states the intro rate holds "through -// August 31, 2026", so the first instant of the standard rate is -// 2026-09-01T00:00:00Z — the exclusive upper bound of the intro tier and -// the inclusive lower bound of the standard one, matching -// docs/specifications/model/data-types.md#pricing's half-open -// effective_from/effective_until convention. -// -// This pair of tiers is the one place in the roster where PricingTier's -// time dimension does real work: a session run before the cutover must -// replay showing the intro rate it actually paid, which is exactly why -// the kernel persists cost_usd at usage-event time rather than -// recomputing it later. -var sonnet5IntroEnd = time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC) - -// Models returns the roster, freshly built on every call so a caller -// mutating a returned Spec (or the slices inside it) cannot corrupt the -// roster every later caller sees. Order is fixed and deterministic: -// newest generation first, then descending capability tier. -func Models() []model.Spec { - return []model.Spec{ - fable5(), - opus5(), - opus48(), - opus47(), - opus46(), - sonnet5(), - sonnet46(), - haiku45(), - } -} - -// base returns the capability fields every model in this roster shares. -// All eight accept text, images, PDFs, and tool declarations; all eight -// stream; all eight can return several tool_use blocks in one turn; all -// eight accept every tool_choice shape the protocol models; and all eight -// use Anthropic's explicit cache_control markers rather than automatic -// caching. -// -// Anthropic's per-model minimum cacheable prefix (512 tokens on Opus 5 -// and Fable 5, 1024 on Opus 4.8 / Sonnet 5 / Sonnet 4.6, 2048 on -// Opus 4.7, 4096 on Opus 4.6 and Haiku 4.5) is deliberately not modeled: -// CachingSpec has no field for it, and a prefix below the threshold -// simply does not cache rather than erroring, so the kernel loses nothing -// by not knowing it. -func base(id string, contextWindow, maxOutput int64) model.Spec { - return model.Spec{ - ID: id, - ContextWindow: contextWindow, - MaxOutputTokens: maxOutput, - SupportsToolUse: true, - SupportsVision: true, - SupportsStreaming: true, - SupportsParallelToolCalls: true, - SupportsDocuments: true, - Caching: model.CachingSpec{ - Supported: true, - ExplicitMarkers: true, - // The plugin runs no background cache-keepalive loop. A - // keepalive would mean issuing extra billed requests on the - // operator's behalf without them asking, which is not a - // decision a provider plugin should make silently. - KeepaliveSupported: false, - }, - SupportedToolChoiceModes: []modelv1.ToolChoiceMode{ - modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO, - modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_ANY, - modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE, - modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_SPECIFIC, - }, - } -} - -// effortThinking builds the ThinkingSpec for a model that reasons -// adaptively and exposes a named effort ladder (output_config.effort) on -// top of it. disable says whether, and when, that reasoning can be turned -// off — see fable5 and opus5 for the two models where the answer is not a -// plain yes. -// -// AdaptiveByDefault is true for every model built here: omitting thinking -// config entirely still reasons, and the adapter sends -// thinking:{type:"adaptive"} alongside the effort level rather than -// instead of it. Both facts are declarable now that ThinkingSpec's axes -// are independent; the earlier single-mode shape could only say one, and -// said the effort half. -// -// None of these models declares a BudgetControl. Anthropic removed -// budget_tokens outright on Opus 4.7 and later, and while the 4.6 -// generation reportedly still honors it transitionally, this roster has -// never claimed that and adding the claim needs its own pass against the -// live docs — see this package's CLAUDE.md on never writing a capability -// here from memory. -// -// levels is copied rather than aliased: effortLevels4/effortLevels5 are -// package-level slices shared by several models, so handing one straight -// to a caller would let that caller's mutation reach every later Models() -// call, defeating the whole point of rebuilding the roster per call. -func effortThinking(levels []string, disable modelv1.ThinkingDisableSupport) model.ThinkingSpec { - return model.ThinkingSpec{ - Supported: true, - Effort: &model.EffortControl{ - Levels: append([]string(nil), levels...), - Default: defaultEffort, - }, - AdaptiveByDefault: true, - Disable: disable, - } -} - -// flatPricing builds a single-tier Pricing with no time or input-size -// bounds — the shape every model here uses except Claude Sonnet 5, whose -// introductory window needs two tiers. -func flatPricing(input, output, cacheWrite, cacheRead, batchInput, batchOutput float64) model.Pricing { - return model.Pricing{ - Currency: currency, - Tiers: []model.PricingTier{{ - InputPerMtok: input, - OutputPerMtok: output, - CacheWritePerMtok: &cacheWrite, - CacheReadPerMtok: &cacheRead, - BatchInputPerMtok: &batchInput, - // Named rather than inlined so its address is stable — every - // PricingTier rate that can be absent is a pointer, and taking - // the address of a parameter is the least surprising way to - // build one from a plain float. - BatchOutputPerMtok: &batchOutput, - }}, - } -} - -// opusPricing is the rate card shared by Claude Opus 5, 4.8, 4.7, and -// 4.6: $5/$25 per MTok, 5-minute cache writes at 1.25x input, cache reads -// at 0.1x input, batch at 50% off both directions. -// -// Only the 5-minute cache-write rate is quoted. Anthropic also publishes -// a 1-hour cache-write rate at 2x input ($10/MTok here), but PricingTier -// has exactly one cache_write_per_mtok field and the kernel places every -// breakpoint without a ttl, so 5-minute is the rate this plugin can -// actually incur. Quoting the 1-hour rate would overstate every cached -// turn's cost by 60%. -func opusPricing() model.Pricing { - return flatPricing(5.00, 25.00, 6.25, 0.50, 2.50, 12.50) -} - -// fable5 is Claude Fable 5 — Anthropic's most capable widely released -// model. -// -// Its thinking is declared DISCRETE_EFFORT with can_disable false rather -// than ALWAYS_ON_ADAPTIVE, which is a deliberate choice between two modes -// that each capture half the truth. Fable 5's reasoning genuinely cannot -// be switched off (an explicit thinking:{type:"disabled"} is a 400), which -// is what ALWAYS_ON_ADAPTIVE describes — but it *does* expose the full -// output_config.effort ladder, and ALWAYS_ON_ADAPTIVE means "no -// caller-selectable effort level or budget", which would hide a control -// the kernel can legitimately use. DISCRETE_EFFORT plus can_disable:false -// carries both facts; the reverse choice carries only one. -// -// Claude Mythos 5 shares Fable 5's specs and pricing exactly but is -// invitation-only through Project Glasswing, so it is deliberately absent -// from this roster: advertising a model most operators cannot call would -// make it a routing candidate that fails at request time. -func fable5() model.Spec { - s := base("claude-fable-5", contextWindow1M, maxOutput128K) - s.Thinking = effortThinking(effortLevels5, modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_NEVER) - s.Pricing = flatPricing(10.00, 50.00, 12.50, 1.00, 5.00, 25.00) - return s -} - -// opus5 is Claude Opus 5, the current default for complex agentic coding. -// -// can_disable is true, but with a caveat the ThinkingSpec shape cannot -// express: thinking:{type:"disabled"} is accepted only at effort high or -// below, and returns a 400 paired with xhigh or max. The protocol has no -// field for a conditional disable, and declaring can_disable:false would -// be the larger lie — it would tell the kernel a control exists nowhere -// when it in fact exists across three of the five effort levels. The -// adapter does not attempt to reconcile the two: the kernel sends effort -// and the adapter forwards it, so this combination only arises if the -// kernel explicitly asks for both. -func opus5() model.Spec { - s := base("claude-opus-5", contextWindow1M, maxOutput128K) - s.Thinking = effortThinking(effortLevels5, modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_CONDITIONAL) - s.Pricing = opusPricing() - return s -} - -// opus48 is Claude Opus 4.8 — the previous Opus generation, still -// current and the recommended fallback target for an Opus 5 refusal. -func opus48() model.Spec { - s := base("claude-opus-4-8", contextWindow1M, maxOutput128K) - s.Thinking = effortThinking(effortLevels5, modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_ALWAYS) - s.Pricing = opusPricing() - return s -} - -// opus47 is Claude Opus 4.7. -func opus47() model.Spec { - s := base("claude-opus-4-7", contextWindow1M, maxOutput128K) - s.Thinking = effortThinking(effortLevels5, modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_ALWAYS) - s.Pricing = opusPricing() - return s -} - -// opus46 is Claude Opus 4.6, the last generation before the xhigh effort -// level existed — hence effortLevels4 rather than effortLevels5. -func opus46() model.Spec { - s := base("claude-opus-4-6", contextWindow1M, maxOutput128K) - s.Thinking = effortThinking(effortLevels4, modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_ALWAYS) - s.Pricing = opusPricing() - return s -} - -// sonnet5 is Claude Sonnet 5 — the only model in this roster with more -// than one pricing tier, because Anthropic's introductory $2/$10 rate -// runs through 2026-08-31 and the standard $3/$15 rate takes over on -// 2026-09-01. -// -// The two tiers are half-open and adjacent on the time axis, so exactly -// one matches any given instant — the invariant -// docs/specifications/model/data-types.md#pricing requires and -// model.NewCapabilities checks for overlap. -func sonnet5() model.Spec { - s := base("claude-sonnet-5", contextWindow1M, maxOutput128K) - s.Thinking = effortThinking(effortLevels5, modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_ALWAYS) - - introEnd := sonnet5IntroEnd - standardStart := sonnet5IntroEnd - - introCacheWrite, introCacheRead := 2.50, 0.20 - introBatchIn, introBatchOut := 1.00, 5.00 - stdCacheWrite, stdCacheRead := 3.75, 0.30 - stdBatchIn, stdBatchOut := 1.50, 7.50 - - s.Pricing = model.Pricing{ - Currency: currency, - Tiers: []model.PricingTier{ - { - // Nil EffectiveFrom means "since this plugin version was - // published", which is the correct reading: the intro rate - // was already in force before this build existed. - EffectiveUntil: &introEnd, - InputPerMtok: 2.00, - OutputPerMtok: 10.00, - CacheWritePerMtok: &introCacheWrite, - CacheReadPerMtok: &introCacheRead, - BatchInputPerMtok: &introBatchIn, - BatchOutputPerMtok: &introBatchOut, - }, - { - EffectiveFrom: &standardStart, - InputPerMtok: 3.00, - OutputPerMtok: 15.00, - CacheWritePerMtok: &stdCacheWrite, - CacheReadPerMtok: &stdCacheRead, - BatchInputPerMtok: &stdBatchIn, - BatchOutputPerMtok: &stdBatchOut, - }, - }, - } - return s -} - -// sonnet46 is Claude Sonnet 4.6. -func sonnet46() model.Spec { - s := base("claude-sonnet-4-6", contextWindow1M, maxOutput128K) - s.Thinking = effortThinking(effortLevels4, modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_ALWAYS) - s.Pricing = flatPricing(3.00, 15.00, 3.75, 0.30, 1.50, 7.50) - return s -} - -// haiku45 is Claude Haiku 4.5 — the only model in this roster on the -// older token-budget reasoning control rather than the effort ladder, and -// the only one with a 200k context window and a 64k output ceiling. -// -// The budget range's upper bound is one token below MaxOutputTokens -// because Anthropic requires budget_tokens < max_tokens; the lower bound -// is Anthropic's documented 1024 minimum. -// -// It declares no EffortControl — output_config.effort errors on this -// model — and AdaptiveByDefault is false, because omitting the thinking -// parameter here means no thinking at all rather than adaptive reasoning. -// That pair is the exact opposite of every other model in this roster, and -// it is the case the older single-mode ThinkingSpec handled worst: a -// nil BudgetControl.Default now says "zero reasoning tokens by default" -// directly, where before it had to be smuggled through a Default field -// typed as a string holding "0". -func haiku45() model.Spec { - s := base("claude-haiku-4-5", contextWindow200K, maxOutput64K) - s.Thinking = model.ThinkingSpec{ - Supported: true, - Budget: &model.BudgetControl{ - Range: model.ThinkingBudgetRange{ - Min: 1024, - Max: maxOutput64K - 1, - }, - }, - AdaptiveByDefault: false, - Disable: modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_ALWAYS, - } - s.Pricing = flatPricing(1.00, 5.00, 1.25, 0.10, 0.50, 2.50) - return s -} diff --git a/internal/anthropic/catalog/catalog_test.go b/internal/anthropic/catalog/catalog_test.go deleted file mode 100644 index 8d3cf68..0000000 --- a/internal/anthropic/catalog/catalog_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package catalog - -import ( - "math" - "testing" - "time" - - "github.com/pluggableharness/agent/pkg/config" - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// TestModels_satisfiesCapabilityValidation is the load-bearing test in -// this package: model.NewCapabilities enforces every MUST-level invariant -// docs/specifications/model/data-types.md states about ModelSpec, -// ThinkingSpec, and Pricing, so a roster that survives it is a roster the -// kernel will accept. -func TestModels_satisfiesCapabilityValidation(t *testing.T) { - t.Parallel() - - schema, err := config.Schema() - if err != nil { - t.Fatalf("config.Schema: %v", err) - } - if _, err := model.NewCapabilities(Models(), schema); err != nil { - t.Fatalf("NewCapabilities(Models()): %v", err) - } -} - -// TestModels_idsAreUniqueAndNonEmpty guards the one roster-level property -// NewCapabilities does not check: two entries claiming the same id would -// make model selection ambiguous. -func TestModels_idsAreUniqueAndNonEmpty(t *testing.T) { - t.Parallel() - - seen := make(map[string]bool, len(Models())) - for _, m := range Models() { - if m.ID == "" { - t.Fatal("a model has an empty id") - } - if seen[m.ID] { - t.Errorf("duplicate model id %q", m.ID) - } - seen[m.ID] = true - } - if len(seen) == 0 { - t.Fatal("roster is empty") - } -} - -// TestModels_returnsAFreshCopy proves a caller mutating what Models -// returned cannot corrupt what the next caller sees. GetCapabilities is -// called repeatedly over a process's life, so a shared package-level -// slice would be a real aliasing hazard rather than a theoretical one. -func TestModels_returnsAFreshCopy(t *testing.T) { - t.Parallel() - - first := Models() - first[0].ID = "mutated" - first[0].Pricing.Tiers[0].InputPerMtok = 999 - first[0].Thinking.Effort.Levels[0] = "mutated" - - second := Models() - if second[0].ID == "mutated" { - t.Error("mutating a returned Spec.ID changed the next call's roster") - } - if second[0].Pricing.Tiers[0].InputPerMtok == 999 { - t.Error("mutating a returned PricingTier changed the next call's roster") - } - if second[0].Thinking.Effort.Levels[0] == "mutated" { - t.Error("mutating a returned effort Levels slice changed the next call's roster") - } -} - -// TestPricing_multipliersMatchAnthropicsPublishedRatios is a transcription -// guard, not a restatement of the data. Anthropic publishes the cache and -// batch rates as fixed multipliers of the base input/output rate — -// 5-minute cache write 1.25x input, cache read 0.1x input, batch 0.5x both -// directions — so a mistyped digit in any of those four figures shows up -// here as a broken ratio even though the number still looks plausible on -// its own. -func TestPricing_multipliersMatchAnthropicsPublishedRatios(t *testing.T) { - t.Parallel() - - for _, m := range Models() { - for i, tier := range m.Pricing.Tiers { - checkRatio(t, m.ID, i, "cache write", *tier.CacheWritePerMtok, tier.InputPerMtok*1.25) - checkRatio(t, m.ID, i, "cache read", *tier.CacheReadPerMtok, tier.InputPerMtok*0.10) - checkRatio(t, m.ID, i, "batch input", *tier.BatchInputPerMtok, tier.InputPerMtok*0.50) - checkRatio(t, m.ID, i, "batch output", *tier.BatchOutputPerMtok, tier.OutputPerMtok*0.50) - } - } -} - -// checkRatio compares two dollar-per-MTok figures with a tolerance well -// below a cent per million tokens — tight enough that a transcription -// error cannot hide, loose enough that binary floating point cannot -// produce a spurious failure. -func checkRatio(t *testing.T, id string, tier int, label string, got, want float64) { - t.Helper() - if math.Abs(got-want) > 1e-9 { - t.Errorf("%s tier %d: %s = %v, want %v (Anthropic's published multiplier)", id, tier, label, got, want) - } -} - -// TestPricing_outputCostsMoreThanInput is a coarse sanity check that -// catches a swapped pair — the transcription error the multiplier test -// above cannot see, because swapping input and output preserves neither -// ratio but would survive a careless reading of a single row. -func TestPricing_outputCostsMoreThanInput(t *testing.T) { - t.Parallel() - - for _, m := range Models() { - for i, tier := range m.Pricing.Tiers { - if tier.OutputPerMtok <= tier.InputPerMtok { - t.Errorf("%s tier %d: output %v is not dearer than input %v — a swapped pair?", - m.ID, i, tier.OutputPerMtok, tier.InputPerMtok) - } - } - } -} - -// TestSonnet5_exactlyOneTierMatchesAnyInstant exercises the roster's only -// multi-tier model against the resolution rule -// docs/specifications/model/data-types.md#pricing states: exactly one tier -// MUST match any given (timestamp, input_token_count) pair. The kernel -// resolves the tier per usage event, so a gap or an overlap here would be -// a wrong ledger row rather than a startup failure. -func TestSonnet5_exactlyOneTierMatchesAnyInstant(t *testing.T) { - t.Parallel() - - spec := findModel(t, "claude-sonnet-5") - if len(spec.Pricing.Tiers) != 2 { - t.Fatalf("claude-sonnet-5 has %d tiers, want 2 (intro + standard)", len(spec.Pricing.Tiers)) - } - - tests := []struct { - name string - at time.Time - wantInput float64 - }{ - {"well inside the intro window", time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), 2.00}, - {"the last instant of the intro window", sonnet5IntroEnd.Add(-time.Nanosecond), 2.00}, - {"the first instant of standard pricing", sonnet5IntroEnd, 3.00}, - {"well after the cutover", time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC), 3.00}, - {"long before this build existed", time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC), 2.00}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - var matched []model.PricingTier - for _, tier := range spec.Pricing.Tiers { - if tierCovers(tier, tc.at) { - matched = append(matched, tier) - } - } - if len(matched) != 1 { - t.Fatalf("%d tiers match %s, want exactly 1", len(matched), tc.at) - } - if matched[0].InputPerMtok != tc.wantInput { - t.Errorf("input rate at %s = %v, want %v", tc.at, matched[0].InputPerMtok, tc.wantInput) - } - }) - } -} - -// tierCovers reports whether at falls in tier's half-open -// [EffectiveFrom, EffectiveUntil) window, treating a nil bound as -// unbounded on that side. -func tierCovers(tier model.PricingTier, at time.Time) bool { - if tier.EffectiveFrom != nil && at.Before(*tier.EffectiveFrom) { - return false - } - if tier.EffectiveUntil != nil && !at.Before(*tier.EffectiveUntil) { - return false - } - return true -} - -// TestThinking_modeMatchesTheDeclaredControls checks each model's -// ThinkingSpec is internally coherent beyond what validateThinkingSpec -// already enforces — specifically that an effort-controlled model quotes a -// ladder containing its own declared default, and that a budget-controlled -// model's range is ordered and fits inside its output ceiling. -func TestThinking_declaredControlsAreInternallyConsistent(t *testing.T) { - t.Parallel() - - for _, m := range Models() { - if !m.Thinking.Supported { - t.Errorf("%s: every model in this roster reasons; Supported is false", m.ID) - continue - } - if m.Thinking.Effort == nil && m.Thinking.Budget == nil { - t.Errorf("%s: reasoning declared with neither an effort nor a budget control", m.ID) - continue - } - if e := m.Thinking.Effort; e != nil { - if !contains(e.Levels, e.Default) { - t.Errorf("%s: default effort %q is absent from %v", m.ID, e.Default, e.Levels) - } - } - if b := m.Thinking.Budget; b != nil { - r := b.Range - if r.Min <= 0 || r.Min >= r.Max { - t.Errorf("%s: budget range [%d,%d] is not an ordered positive range", m.ID, r.Min, r.Max) - } - if r.Max >= m.MaxOutputTokens { - t.Errorf("%s: budget max %d is not below max output %d, which the vendor rejects", - m.ID, r.Max, m.MaxOutputTokens) - } - } - if m.Thinking.Disable == modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_UNSPECIFIED { - t.Errorf("%s: reasoning declared without a disable value", m.ID) - } - } -} - -// TestThinking_effortModelsAreAdaptiveByDefault pins the pairing the older -// single-mode ThinkingSpec could not express and that -// internal/anthropic/messages relies on: Anthropic's effort ladder rides -// on top of adaptive reasoning rather than replacing it, so buildThinking -// emits thinking:{type:"adaptive"} AND output_config.effort together. -func TestThinking_effortModelsAreAdaptiveByDefault(t *testing.T) { - t.Parallel() - - for _, m := range Models() { - if m.Thinking.Effort == nil { - continue - } - if !m.Thinking.AdaptiveByDefault { - t.Errorf("%s: declares an effort ladder but not AdaptiveByDefault", m.ID) - } - } -} - -// TestCaching_everyModelDeclaresExplicitMarkers pins the assumption -// internal/anthropic/messages relies on when it translates the kernel's -// cache_breakpoints into vendor cache_control markers: Anthropic has no -// implicit-caching model, so an adapter that silently dropped breakpoints -// would be a caching regression with no error anywhere. -func TestCaching_everyModelDeclaresExplicitMarkers(t *testing.T) { - t.Parallel() - - for _, m := range Models() { - if !m.Caching.Supported { - t.Errorf("%s: caching is not declared supported", m.ID) - } - if !m.Caching.ExplicitMarkers { - t.Errorf("%s: ExplicitMarkers = false, want true", m.ID) - } - if m.Caching.KeepaliveSupported { - t.Errorf("%s: this plugin runs no keepalive loop, so the flag must be false", m.ID) - } - } -} - -// TestSourcedOn_isAParseableDate keeps the staleness marker honest: a -// reader (or a future audit) comparing today's date against it needs it to -// actually be a date. -func TestSourcedOn_isAParseableDate(t *testing.T) { - t.Parallel() - - if _, err := time.Parse(time.DateOnly, sourcedOn); err != nil { - t.Fatalf("sourcedOn %q does not parse as a date: %v", sourcedOn, err) - } -} - -// findModel returns the roster entry with the given id, failing the test -// if the roster no longer carries it. -func findModel(t *testing.T, id string) model.Spec { - t.Helper() - for _, m := range Models() { - if m.ID == id { - return m - } - } - t.Fatalf("roster has no model %q", id) - return model.Spec{} -} - -// contains reports whether haystack holds needle. -func contains(haystack []string, needle string) bool { - for _, s := range haystack { - if s == needle { - return true - } - } - return false -} diff --git a/internal/anthropic/catalog/doc.go b/internal/anthropic/catalog/doc.go deleted file mode 100644 index a61853a..0000000 --- a/internal/anthropic/catalog/doc.go +++ /dev/null @@ -1,19 +0,0 @@ -// Package catalog is the Anthropic model roster: one model.Spec per model -// the provider plugin can serve, including the pricing the kernel uses to -// compute and persist cost_usd -// (docs/specifications/model/protocol.md#cost-computation). -// -// It is pure data with no I/O. GetCapabilities MUST be cheap to call -// repeatedly and MUST NOT require a network call to the vendor -// (docs/specifications/model/protocol.md#getcapabilities), so the roster -// is a compiled-in table rather than a live query against the vendor's -// /v1/models endpoint. -// -// Every figure here is transcribed from Anthropic's own published -// documentation on the date recorded in the sourcedOn constant. Cost -// figures in particular are load-bearing: the kernel computes cost_usd -// from Pricing at the moment each usage event arrives and persists the -// dollar amount forever, so a wrong rate here is a permanent, silently -// incorrect ledger row, not a display bug — see -// .claude/rules/determinism.md and this package's CLAUDE.md. -package catalog diff --git a/internal/anthropic/config.go b/internal/anthropic/config.go deleted file mode 100644 index 36ceb60..0000000 --- a/internal/anthropic/config.go +++ /dev/null @@ -1,204 +0,0 @@ -package anthropic - -import ( - "fmt" - "net" - "net/url" - "strings" - "time" - - "google.golang.org/protobuf/types/known/structpb" - - "github.com/pluggableharness/agent/pkg/config" - configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" -) - -// Config attribute names, as an operator writes them in the provider -// block of agent.hcl. Kept as constants so the schema declaration and the -// decoder cannot drift apart into a field the schema advertises and the -// decoder never reads. -const ( - attrAPIKey = "api_key" - attrBaseURL = "base_url" - attrRequestTimeout = "request_timeout_seconds" -) - -// Defaults for the two optional attributes. -const ( - // DefaultBaseURL is Anthropic's public API endpoint. base_url exists - // to be overridden by a proxy or a test server, not because the - // endpoint is expected to vary in normal use. - DefaultBaseURL = "https://api.anthropic.com" - - // DefaultRequestTimeout bounds one HTTP request/stream. Ten minutes - // matches Anthropic's own SDK default and is deliberately generous: - // a high-effort completion on a large context legitimately runs for - // minutes, and a timeout that fires mid-stream looks to the kernel - // like a provider failure rather than a client impatience. - DefaultRequestTimeout = 10 * time.Minute - - // maxRequestTimeoutSeconds bounds what an operator may configure. A - // timeout this long is already far past any legitimate completion; a - // larger one is a typo (or a missing decimal point) that would wedge - // a turn for hours rather than failing it. - maxRequestTimeoutSeconds = 3600 -) - -// settings is the decoded, validated form of the provider's agent.hcl -// block — what Configure produces and StreamCompletion reads. -type settings struct { - // apiKey is the operator's Anthropic API key. Never logged, never - // echoed into an error message or an emitted event - // (docs/specifications/model/protocol.md#configure). - apiKey string - // baseURL is the API endpoint, without a trailing slash. - baseURL string - // requestTimeout bounds one HTTP request. - requestTimeout time.Duration -} - -// ConfigSchema returns the provider's agent.hcl schema, per -// docs/specifications/model/protocol.md#getcapabilities — the kernel needs -// it before it ever calls Configure, so it rides along on -// GetCapabilities' response. -func ConfigSchema() (*configv1.ConfigSchema, error) { - apiKey, err := config.Attribute(attrAPIKey, configv1.AttrType_ATTR_TYPE_STRING, - config.WithRequired(), - // Sensitive restricts the attribute's agent.hcl expression to - // env(...) indirection and keeps the value out of anything the - // kernel renders or logs. It also forbids a default, which is - // correct: there is no sane fallback for a credential. - config.WithSensitive(), - config.WithDescription("Anthropic API key. Written as env(\"ANTHROPIC_API_KEY\"); the kernel resolves the indirection before Configure is called."), - ) - if err != nil { - return nil, fmt.Errorf("anthropic: config schema: %w", err) - } - - baseURL, err := config.Attribute(attrBaseURL, configv1.AttrType_ATTR_TYPE_STRING, - config.WithDefault(`"`+DefaultBaseURL+`"`), - config.WithDescription("API endpoint override, for a proxy or a gateway. Defaults to Anthropic's public endpoint."), - ) - if err != nil { - return nil, fmt.Errorf("anthropic: config schema: %w", err) - } - - timeout, err := config.Attribute(attrRequestTimeout, configv1.AttrType_ATTR_TYPE_NUMBER, - config.WithDefault(fmt.Sprintf("%d", int(DefaultRequestTimeout.Seconds()))), - config.WithDescription("Per-request timeout in seconds. A long completion legitimately runs for minutes; this is a ceiling, not a target."), - ) - if err != nil { - return nil, fmt.Errorf("anthropic: config schema: %w", err) - } - - schema, err := config.Schema(apiKey, baseURL, timeout) - if err != nil { - return nil, fmt.Errorf("anthropic: config schema: %w", err) - } - return schema, nil -} - -// decodeSettings converts the Struct the kernel's schema-to-cty bridge -// produced into validated settings. -// -// Every failure here is MODEL_ERROR_CATEGORY_INVALID_REQUEST rather than -// AUTH_ERROR, including a missing api_key: at Configure time the key has -// not been presented to Anthropic, so nothing has rejected it — what is -// wrong is the operator's config, which is what invalid_request means. -// AUTH_ERROR is reserved for a key the vendor actually refused. -// -// Configure MUST fail here rather than deferring to the first -// StreamCompletion call (docs/specifications/model/protocol.md#configure), -// which is why this validates rather than filling in blanks. -func decodeSettings(cfg *structpb.Struct) (settings, error) { - fields := cfg.GetFields() - - apiKey, err := requiredString(fields, attrAPIKey) - if err != nil { - return settings{}, err - } - - baseURL := DefaultBaseURL - if v, ok := fields[attrBaseURL]; ok && v.GetStringValue() != "" { - baseURL = v.GetStringValue() - } - if err := validateBaseURL(baseURL); err != nil { - return settings{}, err - } - - timeout := DefaultRequestTimeout - if v, ok := fields[attrRequestTimeout]; ok { - seconds := v.GetNumberValue() - if seconds <= 0 || seconds > maxRequestTimeoutSeconds { - return settings{}, configError(fmt.Sprintf( - "%s must be between 1 and %d, got %v", attrRequestTimeout, maxRequestTimeoutSeconds, seconds)) - } - timeout = time.Duration(seconds * float64(time.Second)) - } - - return settings{ - apiKey: apiKey, - baseURL: strings.TrimRight(baseURL, "/"), - requestTimeout: timeout, - }, nil -} - -// requiredString reads a non-empty string attribute, or reports which one -// was missing. The value itself is never included in an error, because -// the only required attribute is the API key. -func requiredString(fields map[string]*structpb.Value, name string) (string, error) { - v, ok := fields[name] - if !ok { - return "", configError(name + " is required") - } - s := v.GetStringValue() - if s == "" { - return "", configError(name + " is required and must be a non-empty string") - } - return s, nil -} - -// validateBaseURL rejects an endpoint the HTTP client could not use, and -// rejects a plaintext one that could leave the machine: the API key -// travels in a header on every request, so http:// to a remote host would -// hand it to anything on the path. An operator with a genuine remote HTTP -// proxy is better served by terminating TLS at that proxy than by this -// plugin quietly downgrading. -// -// Plain http:// to a loopback host is allowed, and that carve-out is -// deliberate rather than a convenience: it is what lets the integration -// tier point this plugin at an httptest.Server replaying a recorded -// transcript, and a loopback request never reaches a network anyone else -// can observe. A real Anthropic endpoint is never on loopback, so the -// exemption cannot widen into the case it is protecting against. -func validateBaseURL(raw string) error { - u, err := url.Parse(raw) - if err != nil { - return configError(fmt.Sprintf("%s is not a valid URL: %v", attrBaseURL, err)) - } - if u.Host == "" { - return configError(attrBaseURL + " must be an absolute URL, e.g. https://api.anthropic.com") - } - if u.Scheme == "https" { - return nil - } - if u.Scheme == "http" && isLoopbackHost(u.Hostname()) { - return nil - } - return configError(fmt.Sprintf( - "%s must use https (got %q) — the API key is sent as a request header on every call; plain http is accepted only for a loopback host", - attrBaseURL, u.Scheme)) -} - -// isLoopbackHost reports whether host is unambiguously this machine. -// "localhost" is matched by name because it is not an IP literal, and -// every other case is decided by net.IP rather than by string prefix — -// "127.0.0.1.evil.com" is a hostname, not a loopback address, and a -// prefix check would wave it through. -func isLoopbackHost(host string) bool { - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} diff --git a/internal/anthropic/config_test.go b/internal/anthropic/config_test.go deleted file mode 100644 index 7248df1..0000000 --- a/internal/anthropic/config_test.go +++ /dev/null @@ -1,292 +0,0 @@ -package anthropic - -import ( - "errors" - "strings" - "testing" - "time" - - "google.golang.org/protobuf/types/known/structpb" - - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// TestConfigSchema_declaresTheThreeAttributes pins the schema shape the -// kernel reads before it ever calls Configure, and in particular that -// api_key is both required and sensitive — sensitive is what restricts it -// to env(...) indirection in agent.hcl and keeps it out of rendered -// output. -func TestConfigSchema_declaresTheThreeAttributes(t *testing.T) { - t.Parallel() - - schema, err := ConfigSchema() - if err != nil { - t.Fatalf("ConfigSchema: %v", err) - } - - byName := make(map[string]bool) - for _, attr := range schema.GetAttributes() { - byName[attr.GetName()] = true - switch attr.GetName() { - case attrAPIKey: - if !attr.GetRequired() { - t.Error("api_key must be required") - } - if !attr.GetSensitive() { - t.Error("api_key must be sensitive") - } - if attr.GetDefaultJson() != "" { - t.Error("a credential must not carry a default") - } - case attrBaseURL, attrRequestTimeout: - if attr.GetRequired() { - t.Errorf("%s must be optional", attr.GetName()) - } - if attr.GetDefaultJson() == "" { - t.Errorf("%s must declare a default", attr.GetName()) - } - } - } - for _, want := range []string{attrAPIKey, attrBaseURL, attrRequestTimeout} { - if !byName[want] { - t.Errorf("schema is missing %q", want) - } - } -} - -// TestDecodeSettings_accepts covers the shapes an operator can legally -// write, including the two optional attributes falling back to their -// documented defaults. -func TestDecodeSettings_accepts(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - fields map[string]any - wantBaseURL string - wantTimeout time.Duration - }{ - { - name: "only the required key", - fields: map[string]any{attrAPIKey: "sk-ant-test"}, - wantBaseURL: DefaultBaseURL, - wantTimeout: DefaultRequestTimeout, - }, - { - name: "every attribute set", - fields: map[string]any{ - attrAPIKey: "sk-ant-test", - attrBaseURL: "https://gateway.example.com", - attrRequestTimeout: 42.0, - }, - wantBaseURL: "https://gateway.example.com", - wantTimeout: 42 * time.Second, - }, - { - name: "a trailing slash on base_url is trimmed", - fields: map[string]any{ - attrAPIKey: "sk-ant-test", - attrBaseURL: "https://gateway.example.com/", - }, - wantBaseURL: "https://gateway.example.com", - wantTimeout: DefaultRequestTimeout, - }, - { - name: "an empty base_url falls back to the default", - fields: map[string]any{ - attrAPIKey: "sk-ant-test", - attrBaseURL: "", - }, - wantBaseURL: DefaultBaseURL, - wantTimeout: DefaultRequestTimeout, - }, - { - // The carve-out the integration tier depends on: an - // httptest.Server listens on plain http at 127.0.0.1. - name: "plain http to a loopback IP", - fields: map[string]any{ - attrAPIKey: "sk-ant-test", - attrBaseURL: "http://127.0.0.1:53219", - }, - wantBaseURL: "http://127.0.0.1:53219", - wantTimeout: DefaultRequestTimeout, - }, - { - name: "plain http to localhost by name", - fields: map[string]any{ - attrAPIKey: "sk-ant-test", - attrBaseURL: "http://localhost:8080", - }, - wantBaseURL: "http://localhost:8080", - wantTimeout: DefaultRequestTimeout, - }, - { - name: "plain http to the IPv6 loopback", - fields: map[string]any{ - attrAPIKey: "sk-ant-test", - attrBaseURL: "http://[::1]:8080", - }, - wantBaseURL: "http://[::1]:8080", - wantTimeout: DefaultRequestTimeout, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got, err := decodeSettings(mustStruct(t, tc.fields)) - if err != nil { - t.Fatalf("decodeSettings: %v", err) - } - if got.apiKey != "sk-ant-test" { - t.Errorf("apiKey = %q, want the configured key", got.apiKey) - } - if got.baseURL != tc.wantBaseURL { - t.Errorf("baseURL = %q, want %q", got.baseURL, tc.wantBaseURL) - } - if got.requestTimeout != tc.wantTimeout { - t.Errorf("requestTimeout = %v, want %v", got.requestTimeout, tc.wantTimeout) - } - }) - } -} - -// TestDecodeSettings_rejects covers every way a config can be wrong. All -// of them must be invalid_request and non-retryable: retrying the same -// bad config produces the same failure, and nothing has been presented to -// the vendor yet for an auth_error to be the honest classification. -func TestDecodeSettings_rejects(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - fields map[string]any - wantSubstr string - }{ - {"no api_key at all", map[string]any{}, "api_key is required"}, - {"an empty api_key", map[string]any{attrAPIKey: ""}, "api_key is required"}, - { - "a plaintext base_url to a remote host", - map[string]any{attrAPIKey: "k", attrBaseURL: "http://gateway.example.com"}, - "must use https", - }, - { - // The loopback carve-out must not be reachable by a hostname - // that merely starts with a loopback literal — that is a real - // remote host, and a prefix check would wave it through. - "a plaintext host that only looks like loopback", - map[string]any{attrAPIKey: "k", attrBaseURL: "http://127.0.0.1.evil.example.com"}, - "must use https", - }, - { - "a non-loopback private address over plaintext", - map[string]any{attrAPIKey: "k", attrBaseURL: "http://10.0.0.5:8080"}, - "must use https", - }, - { - "an unsupported scheme", - map[string]any{attrAPIKey: "k", attrBaseURL: "ftp://example.com"}, - "must use https", - }, - { - "a relative base_url", - map[string]any{attrAPIKey: "k", attrBaseURL: "/v1"}, - "must be an absolute URL", - }, - { - "an unparseable base_url", - map[string]any{attrAPIKey: "k", attrBaseURL: "https://exa mple.com/\x7f"}, - attrBaseURL, - }, - { - "a zero timeout", - map[string]any{attrAPIKey: "k", attrRequestTimeout: 0.0}, - attrRequestTimeout, - }, - { - "a negative timeout", - map[string]any{attrAPIKey: "k", attrRequestTimeout: -1.0}, - attrRequestTimeout, - }, - { - "an absurdly long timeout", - map[string]any{attrAPIKey: "k", attrRequestTimeout: 999999.0}, - attrRequestTimeout, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - _, err := decodeSettings(mustStruct(t, tc.fields)) - if err == nil { - t.Fatal("decodeSettings accepted an invalid config") - } - - var modelErr *model.Error - if !errors.As(err, &modelErr) { - t.Fatalf("error is %T, want a *model.Error the kernel can classify", err) - } - if modelErr.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST { - t.Errorf("category = %v, want INVALID_REQUEST", modelErr.Category) - } - if modelErr.Retryable { - t.Error("a bad config is not retryable — the same config fails identically") - } - if !strings.Contains(modelErr.Message, tc.wantSubstr) { - t.Errorf("message %q does not mention %q", modelErr.Message, tc.wantSubstr) - } - }) - } -} - -// TestDecodeSettings_neverEchoesTheKey guards -// docs/specifications/model/protocol.md#configure's rule that a plugin -// MUST NOT put a secret into an error message. The rejection paths below -// all run with a real-looking key present, so any handler that -// interpolated the config wholesale would leak it here. -func TestDecodeSettings_neverEchoesTheKey(t *testing.T) { - t.Parallel() - - const secret = "sk-ant-super-secret-value" - bad := []map[string]any{ - {attrAPIKey: secret, attrBaseURL: "http://insecure.example.com"}, - {attrAPIKey: secret, attrBaseURL: "not-a-url"}, - {attrAPIKey: secret, attrRequestTimeout: -5.0}, - } - - for _, fields := range bad { - _, err := decodeSettings(mustStruct(t, fields)) - if err == nil { - t.Fatal("expected a rejection") - } - if strings.Contains(err.Error(), secret) { - t.Fatalf("the api key leaked into an error message: %q", err.Error()) - } - } -} - -// TestDecodeSettings_nilStruct is the degenerate input the kernel would -// send for a provider block with no attributes at all. It must be the -// same clean rejection as an empty struct, not a panic. -func TestDecodeSettings_nilStruct(t *testing.T) { - t.Parallel() - - if _, err := decodeSettings(nil); err == nil { - t.Fatal("a nil config must be rejected, not defaulted") - } -} - -// mustStruct builds the structpb.Struct the kernel's schema-to-cty bridge -// would have produced for these fields. -func mustStruct(t *testing.T, fields map[string]any) *structpb.Struct { - t.Helper() - s, err := structpb.NewStruct(fields) - if err != nil { - t.Fatalf("structpb.NewStruct(%v): %v", fields, err) - } - return s -} diff --git a/internal/anthropic/conformance_test.go b/internal/anthropic/conformance_test.go deleted file mode 100644 index b259b24..0000000 --- a/internal/anthropic/conformance_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package anthropic_test - -import ( - "io" - "net/http" - "strings" - "testing" - - "google.golang.org/protobuf/types/known/structpb" - - "github.com/pluggableharness/agent/internal/anthropic" - "github.com/pluggableharness/agent/pkg/model/modeltest" -) - -// TestConformance runs the shared conformance suite against the real -// Anthropic provider, pointed at a canned in-process vendor rather than -// the network. -// -// This is what keeps the suite honest in both directions: a protocol -// change that the suite does not understand fails here, and a suite -// assertion that no real provider could satisfy fails here too. The -// declarative half — every capability and pricing invariant across the -// whole roster — is exercised regardless of what the fake vendor returns. -func TestConformance(t *testing.T) { - t.Parallel() - - p := anthropic.New(anthropic.WithTransport(cannedVendor{})) - - cfg, err := structpb.NewStruct(map[string]any{ - "api_key": "sk-ant-conformance-fixture", - // Loopback http is permitted precisely so a test can point the - // provider at a fake vendor; see internal/anthropic/CLAUDE.md. - "base_url": "http://127.0.0.1:1", - }) - if err != nil { - t.Fatalf("structpb.NewStruct: %v", err) - } - - modeltest.Run(t, p, modeltest.WithConfig(cfg)) -} - -// cannedVendor answers every request with a minimal, well-formed -// Anthropic SSE stream, so the behavioral checks exercise the real -// translation path with no network. -type cannedVendor struct{} - -func (cannedVendor) RoundTrip(req *http.Request) (*http.Response, error) { - const stream = `event: message_start -data: {"type":"message_start","message":{"usage":{"input_tokens":8,"output_tokens":0}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} - -event: message_stop -data: {"type":"message_stop"} - -` - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{ - "Content-Type": []string{"text/event-stream"}, - // Exercises the stream_start path: the adapter reads this from - // headers before any content arrives. - "Request-Id": []string{"req_conformance_fixture"}, - }, - Body: io.NopCloser(strings.NewReader(stream)), - Request: req, - }, nil -} diff --git a/internal/anthropic/doc.go b/internal/anthropic/doc.go deleted file mode 100644 index 65fb716..0000000 --- a/internal/anthropic/doc.go +++ /dev/null @@ -1,33 +0,0 @@ -// Package anthropic implements the Anthropic model provider — the -// repository's reference implementation of -// docs/specifications/model/README.md's ModelService contract, served as -// a hashicorp/go-plugin subprocess by cmd/anthropic. -// -// It is deliberately built the way a third party would build it: against -// pkg/model, pkg/plugin, pkg/config, and pkg/content alone, plus the -// standard library. It never imports another internal/ package, and a -// depguard rule in .golangci.yml enforces that mechanically rather than -// leaving it to good intentions — see CLAUDE.md for why that rule is the -// point of this package rather than an incidental tidiness. -// -// The package splits three ways: -// -// - This directory owns the model.Provider implementation itself -// (provider.go), the agent.hcl config schema and its decoding -// (config.go), and the secret-safe error construction both use -// (errors.go). -// - catalog/ owns the model roster and its pricing — pure data. -// - messages/ owns everything vendor-shaped: Anthropic's own JSON -// types, the canonical-to-vendor request translation, the SSE reader, -// the vendor-event-to-Sink translation, the HTTP client, and the -// HTTP-status-to-model.Error classification table. -// -// Two things this package deliberately does not do. It computes no cost: -// the kernel owns that, from the Usage counts this plugin reports plus -// the catalog's declared Pricing -// (docs/specifications/model/protocol.md#cost-computation). And it -// retries nothing: every failure is classified into the right -// model.Error category with Retryable and RetryAfter set, and the -// kernel's own retry loop decides what to do with it -// (.claude/rules/grpc.md). -package anthropic diff --git a/internal/anthropic/errors.go b/internal/anthropic/errors.go deleted file mode 100644 index 521bacc..0000000 --- a/internal/anthropic/errors.go +++ /dev/null @@ -1,65 +0,0 @@ -package anthropic - -import ( - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// configError builds the invalid_request model error every Configure-time -// failure reports. -// -// invalid_request rather than auth_error even for a missing api_key: at -// Configure time nothing has been presented to Anthropic, so nothing has -// been refused. What is wrong is the operator's agent.hcl, which is -// exactly what docs/specifications/model/conformance.md's error taxonomy -// means by invalid_request. auth_error is reserved for a credential the -// vendor actually rejected, and the kernel treats the two very -// differently — auth_error MUST NOT be retried or fallen back from, and -// surfaces to a human. -// -// Never retryable: the same config produces the same failure. -// -// The caller is responsible for keeping secrets out of message. Every -// call site in this package passes either a fixed string or an attribute -// name, never a config value — see config_test.go's -// TestDecodeSettings_neverEchoesTheKey, which runs the rejection paths -// with a real-looking key present specifically so a future edit that -// interpolated the config wholesale would fail there. -func configError(message string) error { - return &model.Error{ - Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, - Message: "anthropic: configure: " + message, - Retryable: false, - } -} - -// notConfiguredError is what StreamCompletion and CountTokens report when -// they are called before Configure succeeded. The kernel always calls -// Configure first, so this is a kernel-side ordering bug rather than an -// operator mistake — invalid_request is the taxonomy's slot for -// "almost always a kernel/adapter bug", and it is explicitly -// non-retryable because the ordering will not fix itself. -func notConfiguredError(rpc string) error { - return &model.Error{ - Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, - Message: "anthropic: " + rpc + ": provider is not configured — Configure must succeed first", - Retryable: false, - } -} - -// unknownModelError reports a model_id this provider does not serve. The -// kernel resolves a model against GetCapabilities before dispatching, so -// reaching here means the kernel's view and the catalog's disagree — -// again a kernel/adapter bug rather than a vendor condition, and again -// not something a retry can fix. -// -// The requested id is safe to include: it came from the kernel's own -// request, not from configuration, and naming it is the whole diagnostic -// value of the message. -func unknownModelError(rpc, modelID string) error { - return &model.Error{ - Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, - Message: "anthropic: " + rpc + ": unknown model " + modelID, - Retryable: false, - } -} diff --git a/internal/anthropic/messages/CLAUDE.md b/internal/anthropic/messages/CLAUDE.md deleted file mode 100644 index 41b0499..0000000 --- a/internal/anthropic/messages/CLAUDE.md +++ /dev/null @@ -1,66 +0,0 @@ -# internal/anthropic/messages — agent notes - -Two of the rules below look like obvious simplifications. Both would reintroduce real, silent bugs. Read them before touching anything in this package. - -## 1. Never use `protojson`. Ever. - -Two fields in this package start life as protobuf and end up as bytes on Anthropic's wire: - -- `ToolUseBlock.arguments` (a `structpb.Struct`) → a `tool_use` block's `input` -- `schema.v1.Schema` (containing a `map`) → a tool's `input_schema` - -Both MUST be serialized by converting to native Go values first — `(*structpb.Struct).AsMap()`, or a hand-built `map[string]any` tree — and then `encoding/json.Marshal`. **Never `protojson.Marshal`.** - -`protojson` deliberately injects non-deterministic whitespace into its output. That is not a bug; it is an explicit design decision by the protobuf authors to discourage anyone from byte-comparing its output. Here, byte-comparison is exactly what happens — just not by us. - -**Why it matters concretely:** Anthropic's prompt cache is a **byte-exact prefix match**. If a tool call's arguments serialize differently on turn N+1 than they did on turn N, every byte after that point is a cache miss. So a single `protojson.Marshal` here silently and permanently disables prompt caching for the entire remainder of any conversation that contains a tool call — and there is no error, no warning, and no log line anywhere. The only symptom is a bill that is several times larger than it should be, discovered weeks later. - -`encoding/json` sorts map keys. That is what makes the `properties` map deterministic despite Go map iteration order being randomized, and it is why `.claude/rules/determinism.md`'s "sort the keys" rule is satisfied without an explicit sort here. Do not replace it with a "faster" marshaler that does not sort. - -There is a regression test — 100 marshals of the same input asserted byte-identical — specifically so a future edit that reaches for `protojson` fails loudly instead of costing money quietly. If it ever fails, the number is not the problem; the marshaler is. - -## 2. Thinking signatures and redacted-thinking bytes are opaque. Pass them through untouched. - -On Anthropic's wire, `thinking.signature` and `redacted_thinking.data` are base64 **strings**. On our side they are `[]byte` holding **the literal ASCII bytes of that base64 text** — not the decoded payload. - -- Receiving: `[]byte(theBase64String)`. Do **not** `base64.Decode`. -- Sending: `string(theBytes)`. Do **not** `base64.Encode`. - -It looks wrong. `[]byte` alongside base64 reads like an invitation to decode. Resist it. - -**Why:** these values carry a vendor integrity check. A decode-then-re-encode round trip is not guaranteed to reproduce the vendor's exact output — padding and alphabet choices differ between encoders — and any deviation makes the block fail the vendor's check on the next turn. Anthropic's documented behavior there is to reject **the whole conversation**, not just the offending block. So the failure mode is "every multi-turn thinking conversation breaks on turn two", which is both severe and easy to miss in a single-turn test. - -Note the contrast with `ImageBlock.data` and `DocumentBlock.data`: those genuinely are raw binary and genuinely do need `base64.StdEncoding.EncodeToString`. Two neighbouring fields, opposite handling. That is the trap. - -## 3. The vendor JSON structs are not a second representation of a PluggableHarness message - -[`go-layout.md`](../../../.claude/rules/go-layout.md) forbids `internal/` from defining a parallel Go type for a wire message that already has a generated one. `types.go` is not that. - -`types.go` describes **Anthropic's own wire format** — a foreign schema this repository does not own and cannot regenerate. [`architecture.md`](../../../docs/specifications/architecture.md#canonical-message--tool-schema-format) explicitly assigns each model-provider adapter the job of translating between the canonical schema and its vendor's, and that translation needs both shapes present in Go. The rule that would be violated is the opposite one: importing `pkg/content` types *into* the vendor structs, so that one struct tried to be both the canonical message and the Anthropic message at once. - -So: do not "simplify" `types.go` by embedding `contentv1` types in it, and do not delete it in favour of building `map[string]any` literals inline. The rule it appears to break is not the rule it is governed by. - -## 4. No vendor SDK, and the reason is not just dependency weight - -`github.com/anthropics/anthropic-sdk-go` is deliberately absent from `go.mod`, and adding it would be a regression on two independent counts: - -- **Dependency tax.** This module is the plugin-author SDK every third party imports. Two endpoints (`POST /v1/messages`, `POST /v1/messages/count_tokens`) are used by exactly one plugin; putting a vendor SDK in the root dependency graph makes every downstream plugin author carry it. -- **Retry conflict.** The official SDK retries by default. [`grpc.md`](../../../.claude/rules/grpc.md) is explicit: *"a provider does not invent its own retry policy inside the plugin; it returns the right code and lets the kernel decide."* The kernel's `internal/modelcall` owns retry and backoff. An SDK retrying underneath us would multiply the kernel's retry budget by its own, invisibly. - -If a future change needs a third endpoint, hand-roll it. The threshold for reconsidering is a lot of endpoints, not one more. - -## 5. No retries here. Classify and return. - -Set `Category`, `Retryable`, and `RetryAfter` on a `*model.Error`, then return it. Do not sleep, do not loop, do not back off. The kernel decides. - -## 6. Cancellation is not an error - -A canceled context returns `ctx.Err()` unwrapped so `errors.Is(err, context.Canceled)` works upstream, is **not** converted into a `*model.Error`, and is **not** logged at ERROR. `pkg/model`'s `statusFromErr` maps it to a bare `codes.Canceled` before it crosses the plugin boundary. A cancellation logged as a failure trains operators to ignore real failures. - -## 7. Context-length detection is message-sniffing, and that is knowingly fragile - -Anthropic has no distinct error type for an over-long prompt: it is a `400 invalid_request_error` whose *message* says the prompt is too long. `classify.go` substring-matches that message to upgrade the category to `context_length_exceeded`. - -This will silently stop working if Anthropic rewords the message. That was accepted rather than avoided because the failure direction is safe: the classification degrades to `invalid_request`, which the kernel treats as non-retryable — so a context overflow becomes a clean failure rather than a retry loop against a request that can never succeed. The alternative (not detecting it at all) loses the kernel's ability to shrink context and retry, which is the whole reason the category exists. - -If you find the sniff has broken, fix the substrings — do not remove the mechanism, and do not make the fallback retryable. diff --git a/internal/anthropic/messages/README.md b/internal/anthropic/messages/README.md deleted file mode 100644 index 5ce986b..0000000 --- a/internal/anthropic/messages/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# internal/anthropic/messages - -Everything Anthropic-shaped. This package is the only place in the repository that knows what Anthropic's wire format looks like; the rest of [`internal/anthropic`](..) deals in `pkg/model` domain types. - -## What it owns - -| File | Concern | -|---|---| -| `types.go` | Anthropic's own JSON schema — request body, content blocks, tools, streamed events, error envelopes — plus every wire string literal as a named constant | -| `schema.go` | The restricted [`schema.v1`](../../../api/pluggableharness/schema/v1/types.proto) subset → JSON Schema, deterministically | -| `request.go` | Canonical `StreamCompletionRequest` → Anthropic request body: messages, content blocks, system content, tools, tool choice, generation params, cache breakpoints | -| `sse.go` | The server-sent-event reader | -| `events.go` | Anthropic stream events → `model.Sink` calls, behind a small interface seam | -| `client.go` | The `net/http` client for `POST /v1/messages` and `POST /v1/messages/count_tokens` | -| `classify.go` | HTTP status and vendor error type → `model.Error` category, retryability, and retry-after | - -## The two directions - -**Outbound** (`request.go`, `schema.go`): the kernel hands over a canonical conversation, a tool list, generation params, and a set of cache breakpoints it has already decided the placement of. This package translates each into Anthropic's equivalent — `assembled_context` sections become the top-level `system` array, cache breakpoints become `cache_control` markers, the restricted JSON-Schema subset becomes a tool's `input_schema`. - -**Inbound** (`sse.go`, `events.go`): Anthropic's SSE events become `model.Sink` calls. `text_delta` → `TextDelta`, `input_json_delta` → `ToolCallDelta`, `signature_delta` → `ThinkingSignature`, and so on, with the vendor's cumulative `usage` merged across `message_start` and `message_delta` and emitted exactly once. - -## The testability seam - -`events.go` defines an `EventSink` interface covering the subset of `*model.Sink` the translator uses, with a compile-time anchor: - -```go -var _ EventSink = (*model.Sink)(nil) -``` - -`*model.Sink` can only be constructed by `pkg/model`'s own gRPC handler, so without this seam the translator would be untestable without a live stream. With it, a hand-written recording fake asserts exact call sequences offline. The anchor is what stops the seam drifting away from the real type. - -## Determinism is load-bearing here - -Two serialization paths in this package feed Anthropic's prompt cache, which is a byte-exact prefix match. Both are pinned to `encoding/json` over native Go values, never `protojson`, and both have a 100-iteration byte-identity regression test. - -Separately, thinking signatures and redacted-thinking payloads pass through as opaque bytes and are never decoded or re-encoded. - -Both rules look like things a future editor would "clean up". [`CLAUDE.md`](CLAUDE.md) explains what breaks if they do — read it first. - -## What this package will not do - -No retries, no backoff, no cost arithmetic, and no cache-breakpoint placement. It classifies, translates, and returns; the kernel decides everything else. diff --git a/internal/anthropic/messages/classify.go b/internal/anthropic/messages/classify.go deleted file mode 100644 index 4a92414..0000000 --- a/internal/anthropic/messages/classify.go +++ /dev/null @@ -1,183 +0,0 @@ -package messages - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// maxRawDetailBytes caps how much of a raw error body classify copies into -// model.Error.RawDetail — a misbehaving proxy can return a multi-megabyte -// HTML error page, and RawDetail exists for debugging, not for holding the -// whole thing. -const maxRawDetailBytes = 2048 - -// contextLengthPhrases are the message substrings classifyHTTP and -// classifyStreamError use to detect an over-long-prompt failure. See the -// comment where this is applied for why the technique itself is fragile. -var contextLengthPhrases = []string{ - "prompt is too long", - "too many tokens", - "exceeds the maximum", -} - -// errorClassification is one row of the vendor-error-type → model error -// mapping tables below. -type errorClassification struct { - category modelv1.ModelErrorCategory - retryable bool -} - -// errorTypeTable maps Anthropic's error.type values to a category and -// retryability, per docs/specifications/model/conformance.md's taxonomy. -// This is the primary classification path — keyed off the parsed error -// body, which is present on both an HTTP error response and a mid-stream -// error event. -var errorTypeTable = map[string]errorClassification{ - errInvalidRequest: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, false}, - errAuthentication: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, false}, - errBilling: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, false}, - errPermission: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, false}, - errNotFound: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, false}, - errConflict: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, false}, - errRequestTooLarge: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED, false}, - errRateLimit: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED, true}, - errAPI: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, - errTimeout: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, - errOverloaded: {modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, -} - -// classify resolves a category and retryability from errType first, -// falling back to the HTTP status when errType is empty or unrecognized -// (an unparseable body — an HTML proxy error page carries no error.type at -// all). -// -// The status fallback is model.ClassifyHTTPStatus, shared with every other -// provider: those are HTTP semantics rather than Anthropic's, and its 5xx -// rule is what makes Anthropic's own 529 overload code classify correctly -// without an entry anywhere. Only errorTypeTable above is vendor-specific. -func classify(errType string, status int) (modelv1.ModelErrorCategory, bool) { - if c, ok := errorTypeTable[errType]; ok { - return c.category, c.retryable - } - return model.ClassifyHTTPStatus(status) -} - -// looksLikeContextLength reports whether message reads as Anthropic's -// over-long-prompt wording. Anthropic has no distinct error.type for this -// case — it's a plain 400 invalid_request_error whose message happens to -// say the prompt is too long — so detection is a small, case-insensitive -// substring check. -func looksLikeContextLength(message string) bool { - lower := strings.ToLower(message) - for _, phrase := range contextLengthPhrases { - if strings.Contains(lower, phrase) { - return true - } - } - return false -} - -// upgradeContextLength promotes category to CONTEXT_LENGTH_EXCEEDED when -// it classified as INVALID_REQUEST and message looks like an over-long -// prompt. -// -// This is message-sniffing and therefore fragile: it depends entirely on -// Anthropic's current wording. If the vendor rewords the message, this -// silently stops matching and classification degrades back to -// invalid_request — a safe failure direction (the kernel still won't -// retry it as-is), but one a future reader should know about rather than -// discover by surprise. That safety property — degrading to a category -// the kernel already treats correctly, never to something worse — is the -// justification for doing message-sniffing here at all. -func upgradeContextLength(category modelv1.ModelErrorCategory, message string) modelv1.ModelErrorCategory { - if category == modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST && looksLikeContextLength(message) { - return modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED - } - return category -} - -// classifyHTTP maps an Anthropic HTTP error response to a *model.Error. -// retryAfter is the raw retry-after header value, if any; body is the -// (possibly capped) response body. -func classifyHTTP(status int, body []byte, retryAfter string) *model.Error { - var apiErr APIError - _ = json.Unmarshal(body, &apiErr) // unparseable body (e.g. an HTML proxy error page) leaves apiErr zero-valued, handled by classify's status fallback. - - category, retryable := classify(apiErr.Error.Type, status) - category = upgradeContextLength(category, apiErr.Error.Message) - - message := apiErr.Error.Message - if message == "" { - message = fmt.Sprintf("http status %d", status) - } - - modelErr := &model.Error{ - Category: category, - Message: "anthropic: " + message, - Retryable: retryable, - RawDetail: rawDetail(apiErr.Error.Type, status, apiErr.RequestID, body), - } - if retryable { - if d, ok := parseRetryAfterSeconds(retryAfter); ok { - modelErr.RetryAfter = d - } - } - return modelErr -} - -// classifyStreamError maps a mid-stream SSE error event to a *model.Error. -// There is no HTTP status or retry-after header available mid-stream, so -// classification rests entirely on the vendor's error.type. -func classifyStreamError(body APIErrorBody) *model.Error { - category, retryable := classify(body.Type, 0) - category = upgradeContextLength(category, body.Message) - - message := body.Message - if message == "" { - message = "no error message provided" - } - - return &model.Error{ - Category: category, - Message: "anthropic: " + message, - Retryable: retryable, - RawDetail: fmt.Sprintf("type=%s", body.Type), - } -} - -// rawDetail assembles model.Error.RawDetail from the pieces available on -// an HTTP error response, capping the body so a huge proxy error page -// cannot balloon a log line. -func rawDetail(errType string, status int, requestID string, body []byte) string { - capped := body - if len(capped) > maxRawDetailBytes { - capped = capped[:maxRawDetailBytes] - } - detail := fmt.Sprintf("type=%s status=%d", errType, status) - if requestID != "" { - detail += " request_id=" + requestID - } - detail += " body=" + string(capped) - return detail -} - -// parseRetryAfterSeconds parses Anthropic's retry-after header value, -// which is always an integer count of seconds — never an HTTP-date, unlike -// some other vendors' retry-after headers. A malformed value is ignored -// rather than failing classification outright. -func parseRetryAfterSeconds(raw string) (time.Duration, bool) { - if raw == "" { - return 0, false - } - seconds, err := strconv.Atoi(raw) - if err != nil || seconds < 0 { - return 0, false - } - return time.Duration(seconds) * time.Second, true -} diff --git a/internal/anthropic/messages/classify_test.go b/internal/anthropic/messages/classify_test.go deleted file mode 100644 index 1fb27ad..0000000 --- a/internal/anthropic/messages/classify_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package messages - -import ( - "encoding/json" - "strconv" - "strings" - "testing" - "time" - - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -func apiErrorBody(t *testing.T, errType, message string) []byte { - t.Helper() - body, err := json.Marshal(APIError{ - Type: "error", - Error: APIErrorBody{Type: errType, Message: message}, - RequestID: "req_123", - }) - if err != nil { - t.Fatalf("marshal fixture APIError: %v", err) - } - return body -} - -func TestClassifyHTTP_table(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - status int - errType string - wantCat modelv1.ModelErrorCategory - wantRetry bool - }{ - {"invalid_request_error/400", 400, errInvalidRequest, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, false}, - {"authentication_error/401", 401, errAuthentication, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, false}, - {"billing_error/402", 402, errBilling, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, false}, - {"permission_error/403", 403, errPermission, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR, false}, - {"not_found_error/404", 404, errNotFound, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, false}, - {"conflict_error/409", 409, errConflict, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, false}, - {"request_too_large/413", 413, errRequestTooLarge, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED, false}, - {"rate_limit_error/429", 429, errRateLimit, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED, true}, - {"api_error/500", 500, errAPI, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, - {"timeout_error/504", 504, errTimeout, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, - {"overloaded_error/529", 529, errOverloaded, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, - {"unrecognized type/418", 418, "teapot_error", modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - body := apiErrorBody(t, tt.errType, "something went wrong") - got := classifyHTTP(tt.status, body, "") - if got.Category != tt.wantCat { - t.Errorf("Category = %v, want %v", got.Category, tt.wantCat) - } - if got.Retryable != tt.wantRetry { - t.Errorf("Retryable = %v, want %v", got.Retryable, tt.wantRetry) - } - if !strings.HasPrefix(got.Message, "anthropic: ") { - t.Errorf("Message = %q, missing anthropic: prefix", got.Message) - } - if !strings.Contains(got.RawDetail, tt.errType) || !strings.Contains(got.RawDetail, "req_123") { - t.Errorf("RawDetail = %q, missing type/request_id", got.RawDetail) - } - }) - } -} - -func TestClassifyHTTP_contextLengthSniff(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - message string - want bool - }{ - {"prompt is too long", "prompt is too long: 250000 tokens > 200000 maximum", true}, - {"too many tokens", "too many tokens in the request", true}, - {"exceeds the maximum", "input exceeds the maximum context length", true}, - {"unrelated message", "field 'model' is required", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - body := apiErrorBody(t, errInvalidRequest, tt.message) - got := classifyHTTP(400, body, "") - wantCat := modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST - if tt.want { - wantCat = modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED - } - if got.Category != wantCat { - t.Errorf("Category = %v, want %v", got.Category, wantCat) - } - if got.Retryable { - t.Errorf("Retryable = true, want false") - } - }) - } -} - -func TestClassifyHTTP_retryAfterParsing(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - retryAfter string - wantSet bool - wantDur time.Duration - }{ - {"valid seconds", "5", true, 5 * time.Second}, - {"zero", "0", true, 0}, - {"empty", "", false, 0}, - {"malformed non-numeric", "not-a-number", false, 0}, - {"http-date is not seconds and is ignored", "Wed, 21 Oct 2026 07:28:00 GMT", false, 0}, - {"negative is ignored", "-1", false, 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - body := apiErrorBody(t, errRateLimit, "slow down") - got := classifyHTTP(429, body, tt.retryAfter) - if tt.wantSet && got.RetryAfter != tt.wantDur { - t.Errorf("RetryAfter = %v, want %v", got.RetryAfter, tt.wantDur) - } - if !tt.wantSet && got.RetryAfter != 0 { - t.Errorf("RetryAfter = %v, want unset (0)", got.RetryAfter) - } - }) - } -} - -func TestClassifyHTTP_retryAfterOnlySetWhenRetryable(t *testing.T) { - t.Parallel() - - // invalid_request_error is not retryable; a stray retry-after header - // (e.g. from an intermediary proxy) must not be honored. - body := apiErrorBody(t, errInvalidRequest, "bad request") - got := classifyHTTP(400, body, "5") - if got.RetryAfter != 0 { - t.Errorf("RetryAfter = %v, want 0 for a non-retryable category", got.RetryAfter) - } -} - -func TestClassifyHTTP_unparseableBodyFallsBackToStatus(t *testing.T) { - t.Parallel() - - htmlBody := []byte("502 Bad Gateway") - - tests := []struct { - name string - status int - wantCat modelv1.ModelErrorCategory - wantRetry bool - }{ - {"5xx with html body falls back to overloaded/retryable", 502, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, - {"exact 500 table entry still applies", 500, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, - {"non-5xx unparseable body is unknown", 404, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, false}, - {"totally unmapped status", 418, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := classifyHTTP(tt.status, htmlBody, "") - if got.Category != tt.wantCat { - t.Errorf("Category = %v, want %v", got.Category, tt.wantCat) - } - if got.Retryable != tt.wantRetry { - t.Errorf("Retryable = %v, want %v", got.Retryable, tt.wantRetry) - } - }) - } -} - -func TestClassifyHTTP_rawDetailCapped(t *testing.T) { - t.Parallel() - - hugeBody := []byte(strings.Repeat("x", maxRawDetailBytes*4)) - got := classifyHTTP(500, hugeBody, "") - if len(got.RawDetail) > maxRawDetailBytes+128 { - // +128 for the "type=... status=... body=" prefix this function - // prepends before the capped body bytes. - t.Errorf("RawDetail length = %d, want roughly capped at %d", len(got.RawDetail), maxRawDetailBytes) - } -} - -func TestClassifyHTTP_emptyMessageUsesStatus(t *testing.T) { - t.Parallel() - - got := classifyHTTP(503, []byte(""), "") - if !strings.Contains(got.Message, strconv.Itoa(503)) { - t.Errorf("Message = %q, want it to mention the status", got.Message) - } -} - -func TestClassifyStreamError_table(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - errType string - wantCat modelv1.ModelErrorCategory - wantRetry bool - }{ - {"rate_limit_error", errRateLimit, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED, true}, - {"overloaded_error", errOverloaded, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED, true}, - {"invalid_request_error", errInvalidRequest, modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, false}, - {"unrecognized", "mystery_error", modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := classifyStreamError(APIErrorBody{Type: tt.errType, Message: "vendor message"}) - if got.Category != tt.wantCat { - t.Errorf("Category = %v, want %v", got.Category, tt.wantCat) - } - if got.Retryable != tt.wantRetry { - t.Errorf("Retryable = %v, want %v", got.Retryable, tt.wantRetry) - } - if !strings.HasPrefix(got.Message, "anthropic: ") { - t.Errorf("Message = %q, missing anthropic: prefix", got.Message) - } - }) - } -} - -func TestClassifyStreamError_contextLengthSniff(t *testing.T) { - t.Parallel() - - got := classifyStreamError(APIErrorBody{ - Type: errInvalidRequest, - Message: "prompt is too long: 300000 tokens > 200000 maximum", - }) - if got.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_CONTEXT_LENGTH_EXCEEDED { - t.Errorf("Category = %v, want CONTEXT_LENGTH_EXCEEDED", got.Category) - } - if got.Retryable { - t.Errorf("Retryable = true, want false") - } -} - -func TestClassifyStreamError_emptyMessage(t *testing.T) { - t.Parallel() - - got := classifyStreamError(APIErrorBody{}) - if got.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN { - t.Errorf("Category = %v, want UNKNOWN", got.Category) - } - if !strings.Contains(got.Message, "no error message provided") { - t.Errorf("Message = %q, want a fallback message", got.Message) - } -} diff --git a/internal/anthropic/messages/client.go b/internal/anthropic/messages/client.go deleted file mode 100644 index 77cb682..0000000 --- a/internal/anthropic/messages/client.go +++ /dev/null @@ -1,286 +0,0 @@ -package messages - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "time" - - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -const ( - // messagesPath is the streaming completion endpoint. - messagesPath = "/v1/messages" - // countTokensPath is the exact-token-count endpoint. - // - // #nosec G101 -- a URL path, not a credential. gosec's heuristic - // flags the substring "token" in a string constant; the actual - // credential in this package is ClientConfig.APIKey, which is never a - // literal and never logged. - countTokensPath = "/v1/messages/count_tokens" - // anthropicVersion is the vendor API version this adapter speaks. No - // beta header accompanies it — this package targets only generally - // available surface. - anthropicVersion = "2023-06-01" - // maxErrorResponseBytes caps how much of a non-2xx response body this - // client reads before classifying it, for the same reason - // classify.go's maxRawDetailBytes exists. - maxErrorResponseBytes = 2048 -) - -// ClientConfig configures a Client. -type ClientConfig struct { - // BaseURL is the vendor API origin, with no trailing slash. - BaseURL string - // APIKey authenticates every request via the x-api-key header. - APIKey string - // Timeout bounds each HTTP request's total round-trip time. - Timeout time.Duration - // Transport is the RoundTripper to use. Nil selects - // http.DefaultTransport; tests inject a fake here. - Transport http.RoundTripper - // Logger receives this Client's structured logs. Nil selects - // slog.Default(). - Logger *slog.Logger -} - -// Client is a minimal HTTP client for Anthropic's Messages API. -// -// It never retries: classify a failure and return it, always — the -// kernel's internal/modelcall owns retry and backoff -// (.claude/rules/grpc.md's "a provider does not invent its own retry -// policy"; internal/anthropic/CLAUDE.md restates this for the package as -// a whole). A Client method returning a *model.Error with Retryable set is -// the entire extent of this package's opinion on retrying. -type Client struct { - baseURL string - apiKey string - http *http.Client - logger *slog.Logger -} - -// NewClient returns a Client configured per cfg. -func NewClient(cfg ClientConfig) *Client { - transport := cfg.Transport - if transport == nil { - transport = http.DefaultTransport - } - logger := cfg.Logger - if logger == nil { - logger = slog.Default() - } - return &Client{ - baseURL: cfg.BaseURL, - apiKey: cfg.APIKey, - http: &http.Client{ - Transport: transport, - Timeout: cfg.Timeout, - }, - logger: logger, - } -} - -// setHeaders attaches the headers every Anthropic request carries. The API -// key is never logged or wrapped into an error anywhere in this package — -// see internal/anthropic/CLAUDE.md's secrets section — and this is the -// only place it goes on the wire. -func (c *Client) setHeaders(req *http.Request) { - req.Header.Set("x-api-key", c.apiKey) - req.Header.Set("anthropic-version", anthropicVersion) - req.Header.Set("content-type", "application/json") -} - -// cancelOrErr rewrites err to ctx.Err() when ctx has genuinely been -// canceled or exceeded its deadline — cancellation is normal control flow -// (.claude/rules/grpc.md), never wrapped or logged as an application -// error, and returning ctx.Err() itself (rather than a wrap of err) keeps -// errors.Is(err, context.Canceled) working for the caller. When ctx is not -// done, err is returned unchanged. -func cancelOrErr(ctx context.Context, err error) error { - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - return err -} - -// logRetryable logs a WARN when modelErr classified as retryable — the one -// place this client comments on retryability at all; it never acts on it. -func (c *Client) logRetryable(ctx context.Context, op string, modelErr *model.Error) { - if modelErr.Retryable { - c.logger.WarnContext(ctx, "anthropic: "+op+": retryable failure", "category", modelErr.Category) - } -} - -// Stream POSTs req to /v1/messages and drives sink from the resulting SSE -// stream until a terminal event or the stream ends. -func (c *Client) Stream(ctx context.Context, req *Request, sink EventSink) error { - // Check cancellation before doing any work. net/http does not - // guarantee it inspects the context before handing the request to the - // transport, so without this an already-canceled turn could still - // reach the vendor — a billed request for a turn the kernel has - // already abandoned. Returned unwrapped so errors.Is(err, - // context.Canceled) works upstream and pkg/model maps it to a bare - // codes.Canceled rather than an application error. - if err := ctx.Err(); err != nil { - return err - } - - body, err := json.Marshal(req) - if err != nil { - return fmt.Errorf("anthropic: stream: encode request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+messagesPath, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("anthropic: stream: build request: %w", err) - } - c.setHeaders(httpReq) - - c.logger.DebugContext(ctx, "anthropic: stream: request", "method", httpReq.Method, "path", messagesPath, "model", req.Model) - - resp, err := c.http.Do(httpReq) - if err != nil { - return cancelOrErr(ctx, fmt.Errorf("anthropic: stream: %w", err)) - } - defer func() { _ = resp.Body.Close() }() - - requestID := providerRequestID(resp.Header) - c.logger.DebugContext(ctx, "anthropic: stream: response", "status", resp.StatusCode, "provider_request_id", requestID) - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorResponseBytes)) - modelErr := classifyHTTP(resp.StatusCode, respBody, resp.Header.Get("retry-after")) - c.logRetryable(ctx, "stream", modelErr) - return modelErr - } - - // Emitted before any content so a stream that fails midway is still - // correlatable to the vendor's own logs — the whole reason stream_start - // is an early event rather than a field on stop - // (model/data-types.md#stream_start-and-vendor-request-correlation). - if requestID != "" { - if err := sink.StreamStart(requestID); err != nil { - return err - } - } - - return c.drive(ctx, resp.Body, sink) -} - -// drive reads body as an SSE stream, translating each decoded event into -// calls on sink until a terminal event is emitted or the stream ends. -func (c *Client) drive(ctx context.Context, body io.Reader, sink EventSink) error { - translator := NewTranslator(sink) - scanner := NewScanner(body) - - done := false - for scanner.Next() { - var err error - done, err = translator.Handle(scanner.Event()) - if err != nil { - return cancelOrErr(ctx, err) - } - if done { - break - } - } - if err := scanner.Err(); err != nil { - return cancelOrErr(ctx, err) - } - if done { - return nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - - // The stream ended cleanly (EOF, no read error) but never produced a - // terminal event — a silently truncated stream must not look like a - // clean turn to the kernel. - truncated := &model.Error{ - Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN, - Message: "anthropic: stream ended without a terminal event", - } - return sink.Error(truncated) -} - -// providerRequestID extracts the vendor's request identifier from a -// response's headers, or "" when the vendor published none. -// -// Two header names are tried because Anthropic has used both spellings -// across its API surface, and this is best-effort by design: the spec -// permits omitting stream_start entirely, so a header name that stops -// matching costs a correlation id, never a failed request. That is why -// this reads headers rather than parsing the response body — a missing -// header must be a non-event. -func providerRequestID(h http.Header) string { - for _, name := range []string{"request-id", "x-request-id"} { - if v := h.Get(name); v != "" { - return v - } - } - return "" -} - -// CountTokens returns an exact input-token count for req against -// POST /v1/messages/count_tokens. -// -// The endpoint takes the same messages/system/tools triple the completion -// endpoint does, so this translates req through the same builders -// BuildRequest uses rather than flattening it to a string — tool schemas -// in particular are frequently the largest single contributor to a -// request's input tokens, and dropping them was the main way the earlier -// text-only shape produced a badly wrong number. -func (c *Client) CountTokens(ctx context.Context, req *modelv1.CountTokensRequest, spec model.Spec) (int64, error) { - // Same pre-flight cancellation check as Stream, for the same reason. - if err := ctx.Err(); err != nil { - return 0, err - } - - countReq, err := BuildCountTokensRequest(req, spec) - if err != nil { - return 0, err - } - body, err := json.Marshal(countReq) - if err != nil { - return 0, fmt.Errorf("anthropic: count tokens: encode request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+countTokensPath, bytes.NewReader(body)) - if err != nil { - return 0, fmt.Errorf("anthropic: count tokens: build request: %w", err) - } - c.setHeaders(httpReq) - - c.logger.DebugContext(ctx, "anthropic: count tokens: request", "method", httpReq.Method, "path", countTokensPath, "model", countReq.Model, - "messages", len(countReq.Messages), "tools", len(countReq.Tools)) - - resp, err := c.http.Do(httpReq) - if err != nil { - return 0, cancelOrErr(ctx, fmt.Errorf("anthropic: count tokens: %w", err)) - } - defer func() { _ = resp.Body.Close() }() - - c.logger.DebugContext(ctx, "anthropic: count tokens: response", "status", resp.StatusCode) - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorResponseBytes)) - modelErr := classifyHTTP(resp.StatusCode, respBody, resp.Header.Get("retry-after")) - c.logRetryable(ctx, "count tokens", modelErr) - return 0, modelErr - } - - var result struct { - InputTokens int64 `json:"input_tokens"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return 0, cancelOrErr(ctx, fmt.Errorf("anthropic: count tokens: decode response: %w", err)) - } - return result.InputTokens, nil -} diff --git a/internal/anthropic/messages/client_test.go b/internal/anthropic/messages/client_test.go deleted file mode 100644 index 47ee56c..0000000 --- a/internal/anthropic/messages/client_test.go +++ /dev/null @@ -1,565 +0,0 @@ -package messages - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "log/slog" - "net/http" - "strings" - "testing" - "time" - - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// countReq builds the request-shaped CountTokensRequest the RPC now takes, -// carrying loose text as one user message. -func countReq(text, modelID string) *modelv1.CountTokensRequest { - return &modelv1.CountTokensRequest{ - ModelId: modelID, - Messages: []*contentv1.Message{{ - Role: contentv1.Role_ROLE_USER, - Content: []*contentv1.ContentBlock{{ - Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: text}}, - }}, - }}, - } -} - -// specFor is the minimal model.Spec CountTokens needs to translate a -// request: enough to accept text blocks, nothing more. -func specFor(id string) model.Spec { - return model.Spec{ID: id, MaxOutputTokens: 4096} -} - -// roundTripFunc adapts a function to http.RoundTripper, the standard -// fake-transport seam for testing an *http.Client without a real network -// call. -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } - -func newResponse(status int, body string, header http.Header) *http.Response { - if header == nil { - header = http.Header{} - } - return &http.Response{ - StatusCode: status, - Body: io.NopCloser(strings.NewReader(body)), - Header: header, - } -} - -// sseFromEvents marshals each event to JSON and frames it as one SSE -// message per docs/specifications/model — a real event body round-trips -// through the same StreamEvent type Scanner decodes into. -func sseFromEvents(t *testing.T, events ...StreamEvent) string { - t.Helper() - var b strings.Builder - for _, ev := range events { - raw, err := json.Marshal(ev) - if err != nil { - t.Fatalf("marshal fixture event: %v", err) - } - b.WriteString("data: ") - b.Write(raw) - b.WriteString("\n\n") - } - return b.String() -} - -func testRequest() *Request { - return &Request{ - Model: "claude-opus-5", - MaxTokens: 1024, - Messages: []Message{{Role: roleUser, Content: []Block{{Type: blockText, Text: "hi"}}}}, - Stream: true, - } -} - -func newTestClient(transport http.RoundTripper) *Client { - var buf bytes.Buffer - return NewClient(ClientConfig{ - BaseURL: "http://anthropic.test", - APIKey: "sk-ant-test-key", - Timeout: 5 * time.Second, - Transport: transport, - Logger: slog.New(slog.NewTextHandler(&buf, nil)), - }) -} - -func TestClient_Stream_success(t *testing.T) { - t.Parallel() - - var captured *http.Request - var capturedBody []byte - body := sseFromEvents(t, - StreamEvent{Type: eventMessageStart, Message: &StreamMessage{Usage: &Usage{InputTokens: i64p(10)}}}, - StreamEvent{Type: eventContentBlockDelta, Delta: &StreamDelta{Type: deltaText, Text: "hi"}}, - StreamEvent{Type: eventMessageDelta, Usage: &Usage{OutputTokens: i64p(3)}, Delta: &StreamDelta{StopReason: stopEndTurn}}, - StreamEvent{Type: eventMessageStop}, - ) - - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - captured = r - capturedBody, _ = io.ReadAll(r.Body) - return newResponse(http.StatusOK, body, nil), nil - }) - - client := newTestClient(transport) - sink := newFakeSink() - - if err := client.Stream(context.Background(), testRequest(), sink); err != nil { - t.Fatalf("Stream: %v", err) - } - - if captured.Method != http.MethodPost { - t.Errorf("method = %s, want POST", captured.Method) - } - if captured.URL.Path != messagesPath { - t.Errorf("path = %s, want %s", captured.URL.Path, messagesPath) - } - if got := captured.Header.Get("x-api-key"); got != "sk-ant-test-key" { - t.Errorf("x-api-key = %q", got) - } - if got := captured.Header.Get("anthropic-version"); got != anthropicVersion { - t.Errorf("anthropic-version = %q, want %q", got, anthropicVersion) - } - if got := captured.Header.Get("content-type"); got != "application/json" { - t.Errorf("content-type = %q, want application/json", got) - } - if got := captured.Header.Get("anthropic-beta"); got != "" { - t.Errorf("anthropic-beta header set to %q, want no beta header", got) - } - if !bytes.Contains(capturedBody, []byte(`"model":"claude-opus-5"`)) { - t.Errorf("request body missing model field: %s", capturedBody) - } - - want := []sinkCall{ - {method: "TextDelta", args: []any{"hi"}}, - {method: "Usage", args: []any{model.Usage{InputTokens: 10, OutputTokens: 3}}}, - {method: "Stop", args: []any{modelv1.StopReason_STOP_REASON_END_TURN, ""}}, - } - assertCalls(t, sink.calls, want) -} - -func TestClient_Stream_nonRetryableClassification(t *testing.T) { - t.Parallel() - - errBody, err := json.Marshal(APIError{Error: APIErrorBody{Type: errAuthentication, Message: "invalid api key"}}) - if err != nil { - t.Fatalf("marshal fixture: %v", err) - } - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return newResponse(http.StatusUnauthorized, string(errBody), nil), nil - }) - - client := newTestClient(transport) - err = client.Stream(context.Background(), testRequest(), newFakeSink()) - - var modelErr *model.Error - if !errors.As(err, &modelErr) { - t.Fatalf("err = %v, want a *model.Error", err) - } - if modelErr.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_AUTH_ERROR { - t.Errorf("Category = %v, want AUTH_ERROR", modelErr.Category) - } - if modelErr.Retryable { - t.Errorf("Retryable = true, want false") - } -} - -func TestClient_Stream_retryableClassificationWithRetryAfter(t *testing.T) { - t.Parallel() - - errBody, err := json.Marshal(APIError{Error: APIErrorBody{Type: errRateLimit, Message: "slow down"}}) - if err != nil { - t.Fatalf("marshal fixture: %v", err) - } - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - header := http.Header{} - header.Set("retry-after", "7") - return newResponse(http.StatusTooManyRequests, string(errBody), header), nil - }) - - client := newTestClient(transport) - err = client.Stream(context.Background(), testRequest(), newFakeSink()) - - var modelErr *model.Error - if !errors.As(err, &modelErr) { - t.Fatalf("err = %v, want a *model.Error", err) - } - if modelErr.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED { - t.Errorf("Category = %v, want RATE_LIMITED", modelErr.Category) - } - if !modelErr.Retryable { - t.Errorf("Retryable = false, want true") - } - if modelErr.RetryAfter != 7*time.Second { - t.Errorf("RetryAfter = %v, want 7s", modelErr.RetryAfter) - } -} - -func TestClient_Stream_truncatedStream(t *testing.T) { - t.Parallel() - - // The stream ends after a text delta with no message_stop. - body := sseFromEvents(t, - StreamEvent{Type: eventMessageStart}, - StreamEvent{Type: eventContentBlockDelta, Delta: &StreamDelta{Type: deltaText, Text: "partial"}}, - ) - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return newResponse(http.StatusOK, body, nil), nil - }) - - client := newTestClient(transport) - sink := newFakeSink() - - if err := client.Stream(context.Background(), testRequest(), sink); err != nil { - t.Fatalf("Stream: %v", err) - } - - if len(sink.calls) == 0 { - t.Fatalf("no sink calls recorded") - } - last := sink.calls[len(sink.calls)-1] - if last.method != "Error" { - t.Fatalf("last call = %+v, want an Error call for the truncated stream", last) - } - modelErr, ok := last.args[0].(*model.Error) - if !ok { - t.Fatalf("Error arg = %v, want *model.Error", last.args[0]) - } - if modelErr.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN { - t.Errorf("Category = %v, want UNKNOWN", modelErr.Category) - } - if modelErr.Retryable { - t.Errorf("Retryable = true, want false") - } -} - -func TestClient_Stream_malformedSSEIsClassifiedAsError(t *testing.T) { - t.Parallel() - - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return newResponse(http.StatusOK, "data: {not valid json\n\n", nil), nil - }) - client := newTestClient(transport) - - err := client.Stream(context.Background(), testRequest(), newFakeSink()) - if err == nil { - t.Fatalf("expected an error") - } - if errors.Is(err, context.Canceled) { - t.Fatalf("err = %v, want a decode error, not cancellation", err) - } -} - -// eofCancelReader cancels ctx the instant its wrapped reader reports EOF, -// letting a test land a real cancellation exactly at the point drive() -// checks ctx.Err() after a clean-but-empty scan loop — narrower than -// canceling before Stream is even called, which the pre-flight check -// would catch first. -type eofCancelReader struct { - r io.Reader - cancel context.CancelFunc -} - -func (e *eofCancelReader) Read(p []byte) (int, error) { - n, err := e.r.Read(p) - if err == io.EOF { - e.cancel() - } - return n, err -} - -func TestClient_Stream_cancellationRacesTruncatedStream(t *testing.T) { - t.Parallel() - - body := sseFromEvents(t, StreamEvent{Type: eventContentBlockDelta, Delta: &StreamDelta{Type: deltaText, Text: "x"}}) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(&eofCancelReader{r: strings.NewReader(body), cancel: cancel}), - Header: http.Header{}, - }, nil - }) - client := newTestClient(transport) - sink := newFakeSink() - - err := client.Stream(ctx, testRequest(), sink) - if !errors.Is(err, context.Canceled) { - t.Fatalf("err = %v, want context.Canceled", err) - } - for _, c := range sink.calls { - if c.method == "Error" { - t.Fatalf("unexpected Error call %+v — cancellation must win over the truncated-stream classification", c) - } - } -} - -func TestClient_Stream_cancellationBeforeRequest(t *testing.T) { - t.Parallel() - - // A custom RoundTripper is not skipped by net/http for an - // already-canceled context — only http.Transport's own connection - // logic short-circuits on context cancellation — so this fake checks - // the request's context itself and returns ctx.Err(), exactly as - // http.Transport would. - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - if err := r.Context().Err(); err != nil { - return nil, err - } - t.Fatalf("request context was not canceled") - return nil, nil - }) - client := newTestClient(transport) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - err := client.Stream(ctx, testRequest(), newFakeSink()) - if !errors.Is(err, context.Canceled) { - t.Fatalf("err = %v, want context.Canceled", err) - } - var modelErr *model.Error - if errors.As(err, &modelErr) { - t.Fatalf("cancellation was converted into a *model.Error: %+v", modelErr) - } -} - -func TestClient_Stream_cancellationMidStream(t *testing.T) { - t.Parallel() - - body := sseFromEvents(t, - StreamEvent{Type: eventContentBlockDelta, Delta: &StreamDelta{Type: deltaText, Text: "x"}}, - StreamEvent{Type: eventMessageStop}, - ) - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return newResponse(http.StatusOK, body, nil), nil - }) - client := newTestClient(transport) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - sink := newFakeSink() - // Simulates the kernel closing the gRPC stream mid-turn: the real - // *model.Sink detects this via its own stream context and returns - // ctx.Err() from the first send after cancellation - // (pkg/model/stream.go's send). Canceling ctx itself from inside the - // sink call — not just returning context.Canceled as a value — is - // what exercises drive()'s real ctx.Err() check rather than merely - // passing through an error that happens to equal context.Canceled. - sink.before["TextDelta"] = cancel - sink.failAt["TextDelta"] = context.Canceled - - err := client.Stream(ctx, testRequest(), sink) - - if !errors.Is(err, context.Canceled) { - t.Fatalf("err = %v, want context.Canceled", err) - } - var modelErr *model.Error - if errors.As(err, &modelErr) { - t.Fatalf("cancellation was converted into a *model.Error: %+v", modelErr) - } -} - -func TestClient_Stream_transportErrorIsClassifiedNotCancellation(t *testing.T) { - t.Parallel() - - wantErr := errors.New("connection refused") - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return nil, wantErr - }) - client := newTestClient(transport) - - err := client.Stream(context.Background(), testRequest(), newFakeSink()) - if err == nil || errors.Is(err, context.Canceled) { - t.Fatalf("err = %v, want a wrapped transport error, not cancellation", err) - } - if !strings.Contains(err.Error(), "connection refused") { - t.Fatalf("err = %v, want it to mention the underlying transport failure", err) - } -} - -func TestClient_CountTokens_success(t *testing.T) { - t.Parallel() - - var capturedBody []byte - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - capturedBody, _ = io.ReadAll(r.Body) - if r.URL.Path != countTokensPath { - t.Errorf("path = %s, want %s", r.URL.Path, countTokensPath) - } - return newResponse(http.StatusOK, `{"input_tokens": 42}`, nil), nil - }) - - client := newTestClient(transport) - got, err := client.CountTokens(context.Background(), countReq("hello world", "claude-opus-5"), specFor("claude-opus-5")) - if err != nil { - t.Fatalf("CountTokens: %v", err) - } - if got != 42 { - t.Errorf("got %d, want 42", got) - } - - want := `{"model":"claude-opus-5","messages":[{"role":"user","content":[{"type":"text","text":"hello world"}]}]}` - if string(capturedBody) != want { - t.Errorf("request body = %s, want %s", capturedBody, want) - } -} - -func TestClient_CountTokens_malformedResponseBody(t *testing.T) { - t.Parallel() - - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return newResponse(http.StatusOK, "{not valid json", nil), nil - }) - client := newTestClient(transport) - - got, err := client.CountTokens(context.Background(), countReq("hi", "claude-opus-5"), specFor("claude-opus-5")) - if err == nil { - t.Fatalf("expected a decode error") - } - if got != 0 { - t.Errorf("got %d, want 0 on error", got) - } -} - -func TestClient_CountTokens_nonRetryableClassification(t *testing.T) { - t.Parallel() - - errBody, err := json.Marshal(APIError{Error: APIErrorBody{Type: errNotFound, Message: "no such model"}}) - if err != nil { - t.Fatalf("marshal fixture: %v", err) - } - transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return newResponse(http.StatusNotFound, string(errBody), nil), nil - }) - - client := newTestClient(transport) - got, err := client.CountTokens(context.Background(), countReq("hi", "unknown-model"), specFor("unknown-model")) - if got != 0 { - t.Errorf("got %d, want 0 on error", got) - } - var modelErr *model.Error - if !errors.As(err, &modelErr) { - t.Fatalf("err = %v, want a *model.Error", err) - } - if modelErr.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST { - t.Errorf("Category = %v, want INVALID_REQUEST", modelErr.Category) - } -} - -func TestClient_CountTokens_cancellation(t *testing.T) { - t.Parallel() - - // See TestClient_Stream_cancellationBeforeRequest's comment: a fake - // RoundTripper must check context cancellation itself. - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - if err := r.Context().Err(); err != nil { - return nil, err - } - t.Fatalf("request context was not canceled") - return nil, nil - }) - client := newTestClient(transport) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - _, err := client.CountTokens(ctx, countReq("hi", "claude-opus-5"), specFor("claude-opus-5")) - if !errors.Is(err, context.Canceled) { - t.Fatalf("err = %v, want context.Canceled", err) - } -} - -// TestClient_apiKeyNeverLeaks exercises several distinct failure paths -// with a distinctive API key and asserts it never appears in a returned -// error's message or in anything logged — internal/anthropic/CLAUDE.md's -// secrets rule, and the one property this package cannot regress on -// silently. -func TestClient_apiKeyNeverLeaks(t *testing.T) { - t.Parallel() - - const secretKey = "sk-ant-api03-do-not-leak-this-canary-value" - - var logBuf bytes.Buffer - cfg := ClientConfig{ - BaseURL: "http://anthropic.test", - APIKey: secretKey, - Logger: slog.New(slog.NewTextHandler(&logBuf, nil)), - } - - t.Run("transport failure", func(t *testing.T) { - // The transport's own error is deliberately key-free. The - // property under test is that *this client* never adds the - // credential to an error it builds or wraps — it cannot scrub a - // secret that an arbitrary RoundTripper chose to put in its own - // message, and pretending otherwise would be testing the fake - // rather than the code. - // - // The request-header path is asserted separately below: the key - // travels in x-api-key and must never reach a URL, a body, or a - // log line. - cfg := cfg - var sawKeyHeader bool - cfg.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { - sawKeyHeader = r.Header.Get("x-api-key") == secretKey - if strings.Contains(r.URL.String(), secretKey) { - t.Error("the API key reached the request URL") - } - return nil, errors.New("dial tcp: connection refused") - }) - client := NewClient(cfg) - err := client.Stream(context.Background(), testRequest(), newFakeSink()) - if err == nil { - t.Fatalf("expected an error") - } - if !sawKeyHeader { - t.Error("the API key never reached the x-api-key header, so this test proves nothing") - } - if strings.Contains(err.Error(), secretKey) { - t.Fatalf("error contains the API key: %v", err) - } - }) - - t.Run("classified HTTP failure", func(t *testing.T) { - cfg := cfg - cfg.Transport = roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return newResponse(http.StatusUnauthorized, `{"error":{"type":"authentication_error","message":"invalid x-api-key"}}`, nil), nil - }) - client := NewClient(cfg) - err := client.Stream(context.Background(), testRequest(), newFakeSink()) - if err == nil { - t.Fatalf("expected an error") - } - if strings.Contains(err.Error(), secretKey) { - t.Fatalf("error contains the API key: %v", err) - } - }) - - if strings.Contains(logBuf.String(), secretKey) { - t.Fatalf("log output contains the API key: %s", logBuf.String()) - } -} - -func TestNewClient_defaults(t *testing.T) { - t.Parallel() - - client := NewClient(ClientConfig{BaseURL: "http://anthropic.test", APIKey: "k"}) - if client.http.Transport != http.DefaultTransport { - t.Errorf("Transport = %v, want http.DefaultTransport", client.http.Transport) - } - if client.logger == nil { - t.Errorf("logger = nil, want slog.Default()") - } -} diff --git a/internal/anthropic/messages/doc.go b/internal/anthropic/messages/doc.go deleted file mode 100644 index 14e93ab..0000000 --- a/internal/anthropic/messages/doc.go +++ /dev/null @@ -1,36 +0,0 @@ -// Package messages is the Anthropic wire adapter: the only place in this -// repository that knows Anthropic's own JSON format. -// -// It translates in both directions. Outbound, a canonical -// modelv1.StreamCompletionRequest becomes an Anthropic request body — -// assembled-context sections become the top-level system array, canonical -// content blocks become Anthropic content blocks, the restricted -// schema.v1 subset becomes a tool's input_schema, and the kernel's -// cache breakpoints become vendor cache_control markers. Inbound, -// Anthropic's server-sent events become model.Sink calls. -// -// The vendor JSON types in types.go describe Anthropic's schema, not a -// second Go representation of a PluggableHarness wire message — -// docs/specifications/architecture.md#canonical-message--tool-schema-format -// assigns each adapter exactly this translation job, and doing it needs -// both shapes present in Go. See CLAUDE.md before concluding otherwise. -// -// Two invariants in this package are load-bearing and non-obvious, and -// CLAUDE.md explains both at length: -// -// - Anything derived from protobuf that reaches the wire is serialized -// with encoding/json over native Go values, never protojson. -// Anthropic's prompt cache is a byte-exact prefix match, and -// protojson deliberately emits non-deterministic whitespace, so a -// single use of it silently disables caching for the rest of any -// conversation containing a tool call. -// - Thinking signatures and redacted-thinking payloads are carried as -// the literal bytes of the vendor's base64 text and are never decoded -// or re-encoded. A re-encoding that differs in padding or alphabet -// fails the vendor's integrity check, which rejects the whole -// conversation on the next turn. -// -// This package computes no cost, places no cache breakpoints, and -// performs no retries — all three are the kernel's, per -// docs/specifications/model/protocol.md and .claude/rules/grpc.md. -package messages diff --git a/internal/anthropic/messages/events.go b/internal/anthropic/messages/events.go deleted file mode 100644 index 20b2ab6..0000000 --- a/internal/anthropic/messages/events.go +++ /dev/null @@ -1,277 +0,0 @@ -package messages - -import ( - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// EventSink is the subset of *model.Sink this package writes to. It exists -// so Translator can be tested against a recording fake without a live gRPC -// stream; *model.Sink satisfies it structurally. -type EventSink interface { - // StreamStart sends the vendor's own identifier for this request. - StreamStart(providerRequestID string) error - // TextDelta sends an incremental fragment of assistant text output. - TextDelta(text string) error - // ThinkingDelta sends an incremental fragment of the model's reasoning - // output. - ThinkingDelta(text string) error - // ThinkingSignature sends the vendor's opaque integrity token for the - // reasoning block just completed. - ThinkingSignature(signature []byte) error - // RedactedThinking sends one complete, vendor-encrypted reasoning - // block. - RedactedThinking(data []byte) error - // ToolCallStart announces the model has begun requesting a tool - // invocation. - ToolCallStart(id, name string) error - // ToolCallDelta sends one incremental fragment of a tool call's - // arguments. - ToolCallDelta(id, argumentsFragment string) error - // ToolCallDone signals a tool call's arguments are complete. - ToolCallDone(id string) error - // Usage sends token accounting for this completion. - Usage(u model.Usage) error - // Stop sends the stream's terminal Stop event. - Stop(reason modelv1.StopReason, matchedStopSequence string) error - // Error sends the stream's terminal Error event. - Error(modelErr *model.Error) error -} - -// Compile-time proof the real Sink satisfies the seam. -var _ EventSink = (*model.Sink)(nil) - -// Translator converts Anthropic's stream events into Sink calls, per -// docs/specifications/model/examples.md's worked StreamCompletion event -// sequence. -// -// A Translator is single-use: construct one per StreamCompletion call with -// NewTranslator and feed it every decoded StreamEvent, in order, via -// Handle. -type Translator struct { - sink EventSink - - // toolIndex maps a content_block index to the tool_use id declared at - // its content_block_start, forgotten again at the matching - // content_block_stop. input_json_delta and content_block_stop only - // carry the index, not the id, so this is how they're reunited with - // the ToolCallStart id ToolCallDelta/ToolCallDone require. - toolIndex map[int64]string - - // usage accumulates the cumulative usage Anthropic reports across - // message_start and message_delta. Anthropic's message_delta.usage is - // cumulative, not incremental, so emitting once at message_stop with - // the merged counts is correct — emitting at every event that carries - // a usage object would double-count. - usage model.Usage - usageSeen bool - - stopReason modelv1.StopReason - stopSequence string -} - -// NewTranslator returns a Translator that writes to sink. -func NewTranslator(sink EventSink) *Translator { - return &Translator{ - sink: sink, - // STOP_REASON_END_TURN is the documented default for an - // unknown/empty stop reason (see mapStopReason) — set here too so - // a message_stop arriving without a preceding message_delta (not - // expected by the protocol, but not fatal either) still reports a - // defined reason rather than STOP_REASON_UNSPECIFIED. - stopReason: modelv1.StopReason_STOP_REASON_END_TURN, - } -} - -// Handle processes one vendor event. It returns true once a terminal event -// (Stop or Error) has been emitted to the sink — the caller MUST stop -// feeding further events once done is true. -func (t *Translator) Handle(ev StreamEvent) (done bool, err error) { - switch ev.Type { - case eventMessageStart: - if ev.Message != nil { - t.mergeUsage(ev.Message.Usage) - } - return false, nil - - case eventContentBlockStart: - return false, t.handleContentBlockStart(ev) - - case eventContentBlockDelta: - return false, t.handleContentBlockDelta(ev) - - case eventContentBlockStop: - return false, t.handleContentBlockStop(ev) - - case eventMessageDelta: - t.mergeUsage(ev.Usage) - if ev.Delta != nil { - t.stopReason = mapStopReason(ev.Delta.StopReason) - t.stopSequence = ev.Delta.StopSequence - } - return false, nil - - case eventMessageStop: - if t.usageSeen { - if err := t.sink.Usage(t.usage); err != nil { - return true, err - } - } - if err := t.sink.Stop(t.stopReason, t.stopSequence); err != nil { - return true, err - } - return true, nil - - case eventError: - var body APIErrorBody - if ev.Error != nil { - body = *ev.Error - } - if err := t.sink.Error(classifyStreamError(body)); err != nil { - return true, err - } - return true, nil - - case eventPing: - // Surfaced by Scanner, ignored here — see sse.go's package - // comment for why that split exists. - return false, nil - - default: - // An event type this adapter doesn't recognize MUST NOT break the - // stream (versioning policy: forward compatibility with vendor - // additions). - return false, nil - } -} - -// handleContentBlockStart processes a content_block_start event. -func (t *Translator) handleContentBlockStart(ev StreamEvent) error { - block := ev.ContentBlock - if block == nil { - return nil - } - switch block.Type { - case blockToolUse: - if t.toolIndex == nil { - t.toolIndex = make(map[int64]string) - } - t.toolIndex[ev.Index] = block.ID - return t.sink.ToolCallStart(block.ID, block.Name) - - case blockRedactedThinking: - // The vendor emits this block whole, never fragmented, and its - // base64 payload is passed through byte-for-byte: decoding and - // re-encoding it here would produce a payload that differs in - // padding or alphabet from the vendor's own, which fails the - // vendor's integrity check on a later turn. - return t.sink.RedactedThinking([]byte(block.Data)) - - default: - // text and thinking need no action here — their content arrives - // via content_block_delta. Any other block type (server_tool_use, - // web_search_tool_result, ...) is a server-tool artifact this - // adapter never declares and therefore never needs to act on; - // ignoring it keeps a future vendor addition from breaking the - // stream. - return nil - } -} - -// handleContentBlockDelta processes a content_block_delta event. -func (t *Translator) handleContentBlockDelta(ev StreamEvent) error { - delta := ev.Delta - if delta == nil { - return nil - } - switch delta.Type { - case deltaText: - return t.sink.TextDelta(delta.Text) - - case deltaThinking: - return t.sink.ThinkingDelta(delta.Thinking) - - case deltaSignature: - // Literal bytes of the vendor's base64 signature string, never - // decoded/re-encoded — same integrity-preservation reason as - // RedactedThinking above. - return t.sink.ThinkingSignature([]byte(delta.Signature)) - - case deltaInputJSON: - id, ok := t.toolIndex[ev.Index] - if !ok { - return nil - } - return t.sink.ToolCallDelta(id, delta.PartialJSON) - - default: - return nil - } -} - -// handleContentBlockStop processes a content_block_stop event. -func (t *Translator) handleContentBlockStop(ev StreamEvent) error { - id, ok := t.toolIndex[ev.Index] - if !ok { - return nil - } - delete(t.toolIndex, ev.Index) - return t.sink.ToolCallDone(id) -} - -// mergeUsage folds u into t.usage. Anthropic's usage counts arrive -// piecemeal across message_start (input/cache) and message_delta (output, -// and sometimes input/cache again) — this merges by taking, per field, -// whichever event most recently supplied a non-nil value, which -// simultaneously satisfies "input/cache from whichever event supplied -// them" and "output from the last one seen" for every field. u == nil -// (an event with no usage object at all) is a no-op. -func (t *Translator) mergeUsage(u *Usage) { - if u == nil { - return - } - t.usageSeen = true - if u.InputTokens != nil { - t.usage.InputTokens = *u.InputTokens - } - if u.OutputTokens != nil { - t.usage.OutputTokens = *u.OutputTokens - } - if u.CacheReadInputTokens != nil { - v := *u.CacheReadInputTokens - t.usage.CacheReadTokens = &v - } - if u.CacheCreationInputTokens != nil { - v := *u.CacheCreationInputTokens - t.usage.CacheWriteTokens = &v - } - // ReasoningTokens is deliberately left nil: Anthropic folds thinking - // tokens into output_tokens and reports no separate figure, and a - // vendor with no distinct count leaves the field unset rather than - // deriving one (model.Usage's own doc comment). -} - -// mapStopReason converts Anthropic's stop_reason wire string to the -// protocol's modelv1.StopReason enum. Unknown or empty input maps to -// STOP_REASON_END_TURN, the documented safe default. -func mapStopReason(reason string) modelv1.StopReason { - switch reason { - case stopEndTurn: - return modelv1.StopReason_STOP_REASON_END_TURN - case stopToolUse: - return modelv1.StopReason_STOP_REASON_TOOL_USE - case stopMaxTokens: - return modelv1.StopReason_STOP_REASON_MAX_TOKENS - case stopStopSequence: - return modelv1.StopReason_STOP_REASON_STOP_SEQUENCE - case stopRefusal: - return modelv1.StopReason_STOP_REASON_REFUSAL - case stopPauseTurn: - // pause_turn means the vendor paused a server-tool loop; this - // adapter declares no server tools, so it should not occur in - // practice, and END_TURN is the safe reading if it ever does. - return modelv1.StopReason_STOP_REASON_END_TURN - default: - return modelv1.StopReason_STOP_REASON_END_TURN - } -} diff --git a/internal/anthropic/messages/events_test.go b/internal/anthropic/messages/events_test.go deleted file mode 100644 index 888de0a..0000000 --- a/internal/anthropic/messages/events_test.go +++ /dev/null @@ -1,580 +0,0 @@ -package messages - -import ( - "errors" - "reflect" - "testing" - - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// sinkCall is one recorded EventSink method invocation, used to assert -// exact call sequences against a fakeSink. -type sinkCall struct { - method string - args []any -} - -// fakeSink is a hand-written recording EventSink, per .claude/rules/go-testing.md -// — no mocking framework, an in-memory fake implementing the interface -// directly. failAt lets a test inject an error return from one named -// method, exactly once semantics not required since every test either -// fails fast or doesn't touch that method again. -type fakeSink struct { - calls []sinkCall - failAt map[string]error - // before, when set for a method name, runs immediately before that - // call is recorded — client_test.go's cancellation test uses this to - // cancel the real context from inside a sink call, reproducing how - // pkg/model's Sink detects a kernel-side stream close mid-send. - before map[string]func() -} - -func newFakeSink() *fakeSink { - return &fakeSink{failAt: map[string]error{}, before: map[string]func(){}} -} - -func (f *fakeSink) record(method string, args ...any) error { - if hook := f.before[method]; hook != nil { - hook() - } - f.calls = append(f.calls, sinkCall{method: method, args: args}) - return f.failAt[method] -} - -func (f *fakeSink) StreamStart(providerRequestID string) error { - return f.record("StreamStart", providerRequestID) -} - -func (f *fakeSink) TextDelta(text string) error { return f.record("TextDelta", text) } - -func (f *fakeSink) ThinkingDelta(text string) error { return f.record("ThinkingDelta", text) } - -func (f *fakeSink) ThinkingSignature(signature []byte) error { - return f.record("ThinkingSignature", string(signature)) -} - -func (f *fakeSink) RedactedThinking(data []byte) error { - return f.record("RedactedThinking", string(data)) -} - -func (f *fakeSink) ToolCallStart(id, name string) error { - return f.record("ToolCallStart", id, name) -} - -func (f *fakeSink) ToolCallDelta(id, argumentsFragment string) error { - return f.record("ToolCallDelta", id, argumentsFragment) -} - -func (f *fakeSink) ToolCallDone(id string) error { return f.record("ToolCallDone", id) } - -func (f *fakeSink) Usage(u model.Usage) error { return f.record("Usage", u) } - -func (f *fakeSink) Stop(reason modelv1.StopReason, matchedStopSequence string) error { - return f.record("Stop", reason, matchedStopSequence) -} - -func (f *fakeSink) Error(modelErr *model.Error) error { return f.record("Error", modelErr) } - -var _ EventSink = (*fakeSink)(nil) - -func i64p(v int64) *int64 { return &v } - -func assertCalls(t *testing.T, got []sinkCall, want []sinkCall) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("got %d calls, want %d\ngot: %+v\nwant: %+v", len(got), len(want), got, want) - } - for i := range want { - if got[i].method != want[i].method || !reflect.DeepEqual(got[i].args, want[i].args) { - t.Errorf("call %d = %+v, want %+v", i, got[i], want[i]) - } - } -} - -// handleAll feeds every event in order into tr, failing the test on the -// first error and returning whether a terminal event was emitted. -func handleAll(t *testing.T, tr *Translator, events []StreamEvent) bool { - t.Helper() - done := false - for _, ev := range events { - var err error - done, err = tr.Handle(ev) - if err != nil { - t.Fatalf("Handle(%q): %v", ev.Type, err) - } - } - return done -} - -// TestTranslator_workedSequence reproduces -// docs/specifications/model/examples.md's full StreamCompletion event -// sequence: text, then one tool call, then usage and a tool_use stop. -func TestTranslator_workedSequence(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - - events := []StreamEvent{ - {Type: eventMessageStart, Message: &StreamMessage{Usage: &Usage{InputTokens: i64p(412)}}}, - {Type: eventContentBlockStart, Index: 0, ContentBlock: &Block{Type: blockText}}, - {Type: eventContentBlockDelta, Index: 0, Delta: &StreamDelta{Type: deltaText, Text: "Let me check "}}, - {Type: eventContentBlockDelta, Index: 0, Delta: &StreamDelta{Type: deltaText, Text: "that file."}}, - {Type: eventContentBlockStop, Index: 0}, - {Type: eventContentBlockStart, Index: 1, ContentBlock: &Block{Type: blockToolUse, ID: "tc_1", Name: "read_file"}}, - {Type: eventContentBlockDelta, Index: 1, Delta: &StreamDelta{Type: deltaInputJSON, PartialJSON: `{"path":`}}, - {Type: eventContentBlockDelta, Index: 1, Delta: &StreamDelta{Type: deltaInputJSON, PartialJSON: `"main.go"}`}}, - {Type: eventContentBlockStop, Index: 1}, - {Type: eventMessageDelta, Usage: &Usage{OutputTokens: i64p(28)}, Delta: &StreamDelta{StopReason: stopToolUse}}, - {Type: eventMessageStop}, - } - - done := handleAll(t, tr, events) - if !done { - t.Fatalf("done = false after message_stop, want true") - } - - want := []sinkCall{ - {method: "TextDelta", args: []any{"Let me check "}}, - {method: "TextDelta", args: []any{"that file."}}, - {method: "ToolCallStart", args: []any{"tc_1", "read_file"}}, - {method: "ToolCallDelta", args: []any{"tc_1", `{"path":`}}, - {method: "ToolCallDelta", args: []any{"tc_1", `"main.go"}`}}, - {method: "ToolCallDone", args: []any{"tc_1"}}, - {method: "Usage", args: []any{model.Usage{InputTokens: 412, OutputTokens: 28}}}, - {method: "Stop", args: []any{modelv1.StopReason_STOP_REASON_TOOL_USE, ""}}, - } - assertCalls(t, sink.calls, want) -} - -func TestTranslator_contentBlockStart(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - block *Block - want []sinkCall - }{ - { - name: "text needs no action", - block: &Block{Type: blockText}, - want: nil, - }, - { - name: "thinking needs no action", - block: &Block{Type: blockThinking}, - want: nil, - }, - { - name: "tool_use starts a tool call", - block: &Block{Type: blockToolUse, ID: "tc_1", Name: "read_file"}, - want: []sinkCall{{method: "ToolCallStart", args: []any{"tc_1", "read_file"}}}, - }, - { - name: "redacted_thinking passes through untouched", - block: &Block{Type: blockRedactedThinking, Data: "QUJDREVGRw=="}, - want: []sinkCall{{method: "RedactedThinking", args: []any{"QUJDREVGRw=="}}}, - }, - { - name: "unrecognized block type is ignored", - block: &Block{Type: "server_tool_use"}, - want: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - sink := newFakeSink() - tr := NewTranslator(sink) - done, err := tr.Handle(StreamEvent{Type: eventContentBlockStart, Index: 0, ContentBlock: tt.block}) - if err != nil { - t.Fatalf("Handle: %v", err) - } - if done { - t.Fatalf("done = true, want false") - } - assertCalls(t, sink.calls, tt.want) - }) - } -} - -func TestTranslator_contentBlockStart_nilContentBlock(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - done, err := tr.Handle(StreamEvent{Type: eventContentBlockStart, Index: 0}) - if err != nil || done { - t.Fatalf("Handle = (%v, %v), want (false, nil)", done, err) - } - assertCalls(t, sink.calls, nil) -} - -func TestTranslator_redactedThinkingBytesPassThroughUndecoded(t *testing.T) { - t.Parallel() - - // The literal ASCII bytes of the vendor's base64 text must arrive - // exactly as sent — this must NOT be base64-decoded on the way - // through (see this package's CLAUDE.md). - const rawBase64 = "SGVsbG8sIHdvcmxkIQ==" - sink := newFakeSink() - tr := NewTranslator(sink) - - _, err := tr.Handle(StreamEvent{ - Type: eventContentBlockStart, - Index: 0, - ContentBlock: &Block{ - Type: blockRedactedThinking, - Data: rawBase64, - }, - }) - if err != nil { - t.Fatalf("Handle: %v", err) - } - assertCalls(t, sink.calls, []sinkCall{{method: "RedactedThinking", args: []any{rawBase64}}}) -} - -func TestTranslator_signatureBytesPassThroughUndecoded(t *testing.T) { - t.Parallel() - - const rawBase64 = "c2lnbmF0dXJlLWJ5dGVz" - sink := newFakeSink() - tr := NewTranslator(sink) - - _, err := tr.Handle(StreamEvent{ - Type: eventContentBlockDelta, - Index: 0, - Delta: &StreamDelta{Type: deltaSignature, Signature: rawBase64}, - }) - if err != nil { - t.Fatalf("Handle: %v", err) - } - assertCalls(t, sink.calls, []sinkCall{{method: "ThinkingSignature", args: []any{rawBase64}}}) -} - -func TestTranslator_contentBlockDelta(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - delta *StreamDelta - want []sinkCall - }{ - { - name: "text_delta", - delta: &StreamDelta{Type: deltaText, Text: "hi"}, - want: []sinkCall{{method: "TextDelta", args: []any{"hi"}}}, - }, - { - name: "thinking_delta", - delta: &StreamDelta{Type: deltaThinking, Thinking: "pondering"}, - want: []sinkCall{{method: "ThinkingDelta", args: []any{"pondering"}}}, - }, - { - name: "unrecognized delta type is ignored", - delta: &StreamDelta{Type: "some_future_delta"}, - want: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - sink := newFakeSink() - tr := NewTranslator(sink) - _, err := tr.Handle(StreamEvent{Type: eventContentBlockDelta, Index: 0, Delta: tt.delta}) - if err != nil { - t.Fatalf("Handle: %v", err) - } - assertCalls(t, sink.calls, tt.want) - }) - } -} - -func TestTranslator_contentBlockDelta_nilDelta(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - _, err := tr.Handle(StreamEvent{Type: eventContentBlockDelta, Index: 0}) - if err != nil { - t.Fatalf("Handle: %v", err) - } - assertCalls(t, sink.calls, nil) -} - -func TestTranslator_inputJSONDeltaWithoutMatchingToolUseIsIgnored(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - _, err := tr.Handle(StreamEvent{ - Type: eventContentBlockDelta, - Index: 7, - Delta: &StreamDelta{Type: deltaInputJSON, PartialJSON: "{}"}, - }) - if err != nil { - t.Fatalf("Handle: %v", err) - } - assertCalls(t, sink.calls, nil) -} - -func TestTranslator_contentBlockStopWithoutMatchingToolUseIsIgnored(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - _, err := tr.Handle(StreamEvent{Type: eventContentBlockStop, Index: 3}) - if err != nil { - t.Fatalf("Handle: %v", err) - } - assertCalls(t, sink.calls, nil) -} - -func TestTranslator_stopReasonMapping(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - vendorReason string - want modelv1.StopReason - }{ - {"end_turn", stopEndTurn, modelv1.StopReason_STOP_REASON_END_TURN}, - {"tool_use", stopToolUse, modelv1.StopReason_STOP_REASON_TOOL_USE}, - {"max_tokens", stopMaxTokens, modelv1.StopReason_STOP_REASON_MAX_TOKENS}, - {"stop_sequence", stopStopSequence, modelv1.StopReason_STOP_REASON_STOP_SEQUENCE}, - {"refusal", stopRefusal, modelv1.StopReason_STOP_REASON_REFUSAL}, - {"pause_turn maps to end_turn", stopPauseTurn, modelv1.StopReason_STOP_REASON_END_TURN}, - {"unknown maps to end_turn", "some_future_reason", modelv1.StopReason_STOP_REASON_END_TURN}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - sink := newFakeSink() - tr := NewTranslator(sink) - handleAll(t, tr, []StreamEvent{ - {Type: eventMessageDelta, Delta: &StreamDelta{StopReason: tt.vendorReason}}, - {Type: eventMessageStop}, - }) - assertCalls(t, sink.calls, []sinkCall{{method: "Stop", args: []any{tt.want, ""}}}) - }) - } -} - -func TestTranslator_stopSequencePassedThrough(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - handleAll(t, tr, []StreamEvent{ - {Type: eventMessageDelta, Delta: &StreamDelta{StopReason: stopStopSequence, StopSequence: ""}}, - {Type: eventMessageStop}, - }) - assertCalls(t, sink.calls, []sinkCall{ - {method: "Stop", args: []any{modelv1.StopReason_STOP_REASON_STOP_SEQUENCE, ""}}, - }) -} - -func TestTranslator_defaultStopReasonWithoutMessageDelta(t *testing.T) { - t.Parallel() - - // message_stop with no preceding message_delta is not expected by the - // protocol, but must still report a defined reason. - sink := newFakeSink() - tr := NewTranslator(sink) - handleAll(t, tr, []StreamEvent{{Type: eventMessageStop}}) - assertCalls(t, sink.calls, []sinkCall{ - {method: "Stop", args: []any{modelv1.StopReason_STOP_REASON_END_TURN, ""}}, - }) -} - -func TestTranslator_usageMergeAcrossMessageStartAndDelta(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - handleAll(t, tr, []StreamEvent{ - {Type: eventMessageStart, Message: &StreamMessage{Usage: &Usage{ - InputTokens: i64p(100), - CacheReadInputTokens: i64p(10), - CacheCreationInputTokens: i64p(20), - }}}, - {Type: eventMessageDelta, Usage: &Usage{OutputTokens: i64p(50)}, Delta: &StreamDelta{StopReason: stopEndTurn}}, - {Type: eventMessageStop}, - }) - - wantUsage := model.Usage{ - InputTokens: 100, - OutputTokens: 50, - CacheReadTokens: i64p(10), - CacheWriteTokens: i64p(20), - } - assertCalls(t, sink.calls, []sinkCall{ - {method: "Usage", args: []any{wantUsage}}, - {method: "Stop", args: []any{modelv1.StopReason_STOP_REASON_END_TURN, ""}}, - }) -} - -func TestTranslator_noUsageEventsMeansNoUsageCall(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - handleAll(t, tr, []StreamEvent{{Type: eventMessageStop}}) - assertCalls(t, sink.calls, []sinkCall{ - {method: "Stop", args: []any{modelv1.StopReason_STOP_REASON_END_TURN, ""}}, - }) -} - -func TestTranslator_reasoningTokensNeverSet(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - handleAll(t, tr, []StreamEvent{ - {Type: eventMessageStart, Message: &StreamMessage{Usage: &Usage{InputTokens: i64p(1)}}}, - {Type: eventMessageStop}, - }) - if len(sink.calls) == 0 || sink.calls[0].method != "Usage" { - t.Fatalf("calls = %+v, want a Usage call first", sink.calls) - } - got := sink.calls[0].args[0].(model.Usage) - if got.ReasoningTokens != nil { - t.Fatalf("ReasoningTokens = %v, want nil", got.ReasoningTokens) - } -} - -func TestTranslator_midStreamError(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - done, err := tr.Handle(StreamEvent{ - Type: eventError, - Error: &APIErrorBody{Type: errOverloaded, Message: "vendor overloaded"}, - }) - if err != nil { - t.Fatalf("Handle: %v", err) - } - if !done { - t.Fatalf("done = false, want true") - } - if len(sink.calls) != 1 || sink.calls[0].method != "Error" { - t.Fatalf("calls = %+v", sink.calls) - } - modelErr, ok := sink.calls[0].args[0].(*model.Error) - if !ok || modelErr.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_OVERLOADED || !modelErr.Retryable { - t.Fatalf("classified error = %+v", modelErr) - } -} - -func TestTranslator_midStreamErrorWithNilBody(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - done, err := tr.Handle(StreamEvent{Type: eventError}) - if err != nil { - t.Fatalf("Handle: %v", err) - } - if !done { - t.Fatalf("done = false, want true") - } - modelErr, ok := sink.calls[0].args[0].(*model.Error) - if !ok || modelErr.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_UNKNOWN { - t.Fatalf("classified error = %+v", modelErr) - } -} - -func TestTranslator_pingIsIgnored(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - done, err := tr.Handle(StreamEvent{Type: eventPing}) - if err != nil || done { - t.Fatalf("Handle = (%v, %v), want (false, nil)", done, err) - } - assertCalls(t, sink.calls, nil) -} - -func TestTranslator_unknownTopLevelEventIsIgnored(t *testing.T) { - t.Parallel() - - sink := newFakeSink() - tr := NewTranslator(sink) - done, err := tr.Handle(StreamEvent{Type: "some_future_event"}) - if err != nil || done { - t.Fatalf("Handle = (%v, %v), want (false, nil)", done, err) - } - assertCalls(t, sink.calls, nil) -} - -func TestTranslator_sinkErrorsPropagate(t *testing.T) { - t.Parallel() - - wantErr := errors.New("sink failure") - - tests := []struct { - name string - setup func(*fakeSink) - event StreamEvent - done bool - }{ - { - name: "TextDelta failure", - setup: func(f *fakeSink) { f.failAt["TextDelta"] = wantErr }, - event: StreamEvent{Type: eventContentBlockDelta, Delta: &StreamDelta{Type: deltaText, Text: "x"}}, - done: false, - }, - { - name: "ToolCallStart failure", - setup: func(f *fakeSink) { f.failAt["ToolCallStart"] = wantErr }, - event: StreamEvent{Type: eventContentBlockStart, ContentBlock: &Block{Type: blockToolUse, ID: "t", Name: "n"}}, - done: false, - }, - { - name: "Usage failure at message_stop", - setup: func(f *fakeSink) { f.failAt["Usage"] = wantErr }, - event: StreamEvent{Type: eventMessageStop}, - done: true, - }, - { - name: "Stop failure at message_stop", - setup: func(f *fakeSink) { f.failAt["Stop"] = wantErr }, - event: StreamEvent{Type: eventMessageStop}, - done: true, - }, - { - name: "Error failure on mid-stream error event", - setup: func(f *fakeSink) { f.failAt["Error"] = wantErr }, - event: StreamEvent{Type: eventError, Error: &APIErrorBody{Type: errAPI}}, - done: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - sink := newFakeSink() - tt.setup(sink) - tr := NewTranslator(sink) - - if tt.name == "Usage failure at message_stop" { - tr.usageSeen = true - } - - done, err := tr.Handle(tt.event) - if !errors.Is(err, wantErr) { - t.Fatalf("err = %v, want %v", err, wantErr) - } - if done != tt.done { - t.Fatalf("done = %v, want %v", done, tt.done) - } - }) - } -} diff --git a/internal/anthropic/messages/request.go b/internal/anthropic/messages/request.go deleted file mode 100644 index c6dfeac..0000000 --- a/internal/anthropic/messages/request.go +++ /dev/null @@ -1,524 +0,0 @@ -package messages - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "slices" - "strings" - - "google.golang.org/protobuf/types/known/structpb" - - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// Anthropic's `thinking.type` literals. Not shared with types.go's wire -// vocabulary block because these three are specific to how request.go -// drives the field, not a value ever read back off the wire. -const ( - thinkingTypeAdaptive = "adaptive" - thinkingTypeEnabled = "enabled" - thinkingTypeDisabled = "disabled" -) - -// maxCacheBreakpoints is Anthropic's hard cap on cache_control markers per -// request. Exceeding it is a 400 from the vendor, so BuildRequest rejects -// it up front with invalid_request rather than letting the vendor's error -// surface three layers up the stack. -const maxCacheBreakpoints = 4 - -// newInvalidRequestError builds the *model.Error every BuildRequest failure -// returns. Every failure here is a kernel/adapter bug — malformed input the -// kernel should never have sent — which is exactly what invalid_request -// means per docs/specifications/model/conformance.md, so Retryable is -// always false and the message carries only request-shaped data, never a -// config value or secret. -func newInvalidRequestError(format string, args ...any) *model.Error { - return &model.Error{ - Category: modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST, - Message: "anthropic: build request: " + fmt.Sprintf(format, args...), - Retryable: false, - } -} - -// structToJSON converts s into deterministic JSON bytes via its native Go -// map representation. -// -// NEVER use protojson here. protojson deliberately injects -// non-deterministic whitespace into its output to discourage byte -// comparison, and Anthropic's prompt cache is a byte-exact prefix match: -// non-deterministic tool-argument serialization would silently and -// permanently disable caching for every turn after the first tool call, -// with no error anywhere to reveal why. -func structToJSON(s *structpb.Struct) (json.RawMessage, error) { - if s == nil { - return json.Marshal(map[string]any{}) - } - return json.Marshal(s.AsMap()) -} - -// BuildRequest translates in into the Anthropic request body for the model -// described by spec. -func BuildRequest(in *modelv1.StreamCompletionRequest, spec model.Spec) (*Request, error) { - params := in.GetParams() - - maxTokens := spec.MaxOutputTokens - if params.GetMaxOutputTokens() > 0 { - maxTokens = params.GetMaxOutputTokens() - } - - system, err := buildSystem(in.GetAssembledContext()) - if err != nil { - return nil, err - } - - tools, err := buildTools(in.GetTools()) - if err != nil { - return nil, err - } - - // Translated before coalescing so cache-breakpoint message indices, - // which are defined against the kernel's original message list, can - // still be resolved correctly — see applyCacheBreakpoints. - origMessages := make([]Message, len(in.GetMessages())) - for i, m := range in.GetMessages() { - msg, err := translateMessage(m, spec) - if err != nil { - return nil, err - } - origMessages[i] = msg - } - - if err := applyCacheBreakpoints(in.GetCacheBreakpoints(), spec, system, tools, origMessages); err != nil { - return nil, err - } - - toolChoice, err := buildToolChoice(params) - if err != nil { - return nil, err - } - - thinking, outputConfig, err := buildThinking(params, spec) - if err != nil { - return nil, err - } - - // Models on the effort ladder reject temperature outright (a 400 from - // the vendor); models on a token budget, or with no thinking capability - // at all, still accept it. - // - // Presence of an EffortControl is a proxy for "rejects sampling - // params", not a statement about thinking as such — the protocol has no - // field for the latter, which - // docs/specifications/model/conformance.md's open questions records. - // The proxy holds for every Anthropic model in this roster and will - // need revisiting for the first model that has an effort ladder and - // still accepts temperature. - var temperature *float64 - if params != nil && params.Temperature != nil && spec.Thinking.Effort == nil { - t := *params.Temperature - temperature = &t - } - - return &Request{ - Model: in.GetModelId(), - MaxTokens: maxTokens, - Messages: coalesceMessages(origMessages), - Stream: true, - System: system, - Tools: tools, - ToolChoice: toolChoice, - StopSequences: params.GetStopSequences(), - Temperature: temperature, - Thinking: thinking, - OutputConfig: outputConfig, - }, nil -} - -// CountTokensRequest is the POST /v1/messages/count_tokens body. -// -// It is deliberately a narrower struct than Request rather than a reuse of -// it: the endpoint accepts only the fields that affect the input-token -// total, and sending generation params it does not expect risks a 400 for -// no benefit. -type CountTokensRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - System []TextBlock `json:"system,omitempty"` - Tools []Tool `json:"tools,omitempty"` -} - -// BuildCountTokensRequest translates in into the Anthropic count-tokens -// body for the model described by spec. -// -// It runs the same buildSystem/buildTools/translateMessage path -// BuildRequest does, so a count is computed over exactly the content a -// completion would have carried. Any divergence between the two would -// make the count silently unrepresentative of the request it is meant to -// size. -func BuildCountTokensRequest(in *modelv1.CountTokensRequest, spec model.Spec) (*CountTokensRequest, error) { - system, err := buildSystem(in.GetAssembledContext()) - if err != nil { - return nil, err - } - - tools, err := buildTools(in.GetTools()) - if err != nil { - return nil, err - } - - messages := make([]Message, len(in.GetMessages())) - for i, m := range in.GetMessages() { - msg, err := translateMessage(m, spec) - if err != nil { - return nil, err - } - messages[i] = msg - } - - return &CountTokensRequest{ - Model: in.GetModelId(), - Messages: coalesceMessages(messages), - System: system, - Tools: tools, - }, nil -} - -// buildSystem translates the kernel-assembled context chain into -// Anthropic's top-level `system` array, one TextBlock per section. Each -// section's concatenated text is wrapped in a delimiter line built from its -// Label, so the model sees a clear boundary between one context provider's -// contribution and the next. -func buildSystem(sections []*contentv1.ContextSection) ([]TextBlock, error) { - if len(sections) == 0 { - return nil, nil - } - out := make([]TextBlock, 0, len(sections)) - for _, sec := range sections { - var text strings.Builder - for _, block := range sec.GetContent() { - tb, ok := block.GetBlock().(*contentv1.ContentBlock_Text) - if !ok { - return nil, newInvalidRequestError("assembled context section %q contains a non-text block", sec.GetLabel()) - } - text.WriteString(tb.Text.GetText()) - } - label := sec.GetLabel() - out = append(out, TextBlock{ - Type: blockText, - Text: fmt.Sprintf("<%s>\n%s\n", label, text.String(), label), - }) - } - return out, nil -} - -// buildTools translates every kernel ToolDeclaration into an Anthropic Tool. -func buildTools(decls []*modelv1.ToolDeclaration) ([]Tool, error) { - if len(decls) == 0 { - return nil, nil - } - tools := make([]Tool, 0, len(decls)) - for _, d := range decls { - schema, err := schemaToJSON(d.GetInputSchema()) - if err != nil { - return nil, newInvalidRequestError("tool %q: %s", d.GetName(), err) - } - tools = append(tools, Tool{ - Name: d.GetName(), - Description: d.GetDescription(), - InputSchema: schema, - }) - } - return tools, nil -} - -// buildToolChoice translates params' tool_choice, when set, into -// Anthropic's ToolChoice shape. -func buildToolChoice(params *modelv1.GenerationParams) (*ToolChoice, error) { - tc := params.GetToolChoice() - if tc == nil { - return nil, nil - } - switch tc.GetMode() { - case modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO: - return &ToolChoice{Type: toolChoiceAuto}, nil - case modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_ANY: - return &ToolChoice{Type: toolChoiceAny}, nil - case modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE: - return &ToolChoice{Type: toolChoiceNone}, nil - case modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_SPECIFIC: - name := tc.GetToolName() - if name == "" { - return nil, newInvalidRequestError("tool_choice mode SPECIFIC requires tool_name") - } - return &ToolChoice{Type: toolChoiceTool, Name: name}, nil - default: - return nil, nil - } -} - -// buildThinking translates params' thinking-control fields into Anthropic's -// Thinking/OutputConfig pair, against whichever controls spec declares. -// -// The effort and budget controls are independent axes -// (docs/specifications/model/data-types.md#thinkingspec), so this checks -// each requested param against the control that governs it rather than -// switching on one mode. Effort is checked first: no Anthropic model -// currently declares both, but if one ever does, sending the effort ladder -// alongside adaptive thinking is the shape Anthropic documents, and a -// budget would then be the deprecated path. -// -// Note that an effort request yields BOTH thinking:{type:"adaptive"} and -// output_config.effort — Anthropic's effort ladder rides on top of -// adaptive reasoning rather than replacing it, which is exactly the fact -// the old single-mode ThinkingSpec could not declare. -func buildThinking(params *modelv1.GenerationParams, spec model.Spec) (*Thinking, *OutputConfig, error) { - if params == nil { - return nil, nil, nil - } - - if effort := spec.Thinking.Effort; effort != nil && params.ThinkingEffort != nil { - level := *params.ThinkingEffort - if !slices.Contains(effort.Levels, level) { - return nil, nil, newInvalidRequestError("thinking effort %q is not one of model %q's effort levels %v", level, spec.ID, effort.Levels) - } - return &Thinking{Type: thinkingTypeAdaptive}, &OutputConfig{Effort: level}, nil - } - - if budget := spec.Thinking.Budget; budget != nil && params.ThinkingBudgetTokens != nil { - want := *params.ThinkingBudgetTokens - // A budget of zero is how a caller asks for no reasoning at all, - // which Anthropic expresses as an explicit disable rather than as a - // zero budget — and which is only legal where the model actually - // permits disabling. - if want == 0 && spec.Thinking.Disable != modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_NEVER { - return &Thinking{Type: thinkingTypeDisabled}, nil, nil - } - if want < budget.Range.Min || want > budget.Range.Max { - return nil, nil, newInvalidRequestError("thinking budget %d is outside model %q's budget range", want, spec.ID) - } - return &Thinking{Type: thinkingTypeEnabled, BudgetTokens: &want}, nil, nil - } - - return nil, nil, nil -} - -// applyCacheBreakpoints translates the kernel's cache breakpoints into -// vendor-native cache_control markers, mutating system's, tools', and -// origMessages' blocks in place. -// -// origMessages MUST be the pre-coalescing, one-Message-per-kernel-message -// slice: after_message_index is defined against the kernel's original -// message list, and coalesceMessages (called after this function returns) -// changes indices by merging consecutive same-role messages — applying -// breakpoints beforehand is what keeps a breakpoint's placement correct -// regardless of how the messages are later merged. -func applyCacheBreakpoints(breakpoints []*modelv1.CacheBreakpoint, spec model.Spec, system []TextBlock, tools []Tool, origMessages []Message) error { - if !spec.Caching.ExplicitMarkers { - // MUST ignore per StreamCompletionRequest.cache_breakpoints: this - // field is meaningful only under explicit-marker caching, and - // placement is a kernel decision this adapter only executes. - return nil - } - if len(breakpoints) > maxCacheBreakpoints { - return newInvalidRequestError("cache_breakpoints: %d requested, exceeds Anthropic's cap of %d per request", len(breakpoints), maxCacheBreakpoints) - } - - ephemeral := &CacheControl{Type: cacheControlEphemeral} - for _, bp := range breakpoints { - switch v := bp.GetPosition().(type) { - case *modelv1.CacheBreakpoint_AfterAssembledContext_: - if len(system) == 0 { - return newInvalidRequestError("cache_breakpoints: after_assembled_context set but assembled_context is empty") - } - system[len(system)-1].CacheControl = ephemeral - - case *modelv1.CacheBreakpoint_AfterTools_: - if len(tools) == 0 { - return newInvalidRequestError("cache_breakpoints: after_tools set but no tools were declared") - } - tools[len(tools)-1].CacheControl = ephemeral - - case *modelv1.CacheBreakpoint_AfterMessageIndex: - idx := v.AfterMessageIndex - if idx < 0 || idx >= int64(len(origMessages)) { - return newInvalidRequestError("cache_breakpoints: after_message_index %d is out of range for %d messages", idx, len(origMessages)) - } - msg := &origMessages[idx] - if len(msg.Content) == 0 { - return newInvalidRequestError("cache_breakpoints: after_message_index %d has no content blocks to mark", idx) - } - msg.Content[len(msg.Content)-1].CacheControl = ephemeral - - default: - return newInvalidRequestError("cache_breakpoints: entry has no position set") - } - } - return nil -} - -// translateMessage translates one canonical content.v1.Message into an -// Anthropic Message. -func translateMessage(m *contentv1.Message, spec model.Spec) (Message, error) { - role, err := translateRole(m.GetRole()) - if err != nil { - return Message{}, err - } - blocks, err := translateBlocks(m.GetContent(), spec) - if err != nil { - return Message{}, err - } - return Message{Role: role, Content: blocks}, nil -} - -// translateRole translates a canonical content.v1.Role into Anthropic's -// role string. -func translateRole(r contentv1.Role) (string, error) { - switch r { - case contentv1.Role_ROLE_USER: - return roleUser, nil - case contentv1.Role_ROLE_ASSISTANT: - return roleAssistant, nil - default: - return "", newInvalidRequestError("message role is unset or unknown (%v)", r) - } -} - -// coalesceMessages merges consecutive same-role messages into one message -// whose Content is the concatenation of theirs. -// -// Anthropic's own docs are contradictory about whether the API merges -// consecutive same-role messages server-side, so this adapter does it -// itself rather than relying on undocumented vendor behavior. This is also -// what guarantees that every tool_result block answering one assistant -// turn lands in a single user message, which Anthropic documents as a firm -// requirement — the kernel may emit several ToolResultBlocks as separate -// canonical messages, and without this step they would arrive as several -// consecutive user messages instead of one. -func coalesceMessages(msgs []Message) []Message { - if len(msgs) == 0 { - return nil - } - out := make([]Message, 0, len(msgs)) - out = append(out, msgs[0]) - for _, m := range msgs[1:] { - last := &out[len(out)-1] - if last.Role == m.Role { - last.Content = append(last.Content, m.Content...) - continue - } - out = append(out, m) - } - return out -} - -// translateBlocks translates a slice of canonical content blocks in order. -func translateBlocks(blocks []*contentv1.ContentBlock, spec model.Spec) ([]Block, error) { - if len(blocks) == 0 { - return nil, nil - } - out := make([]Block, 0, len(blocks)) - for _, b := range blocks { - blk, err := translateBlock(b, spec) - if err != nil { - return nil, err - } - out = append(out, blk) - } - return out, nil -} - -// translateBlock translates one canonical content.v1.ContentBlock variant -// into an Anthropic Block, rejecting a variant the target model's spec -// doesn't support. -func translateBlock(b *contentv1.ContentBlock, spec model.Spec) (Block, error) { - switch v := b.GetBlock().(type) { - case *contentv1.ContentBlock_Text: - return Block{Type: blockText, Text: v.Text.GetText()}, nil - - case *contentv1.ContentBlock_Image: - if !spec.SupportsVision { - return Block{}, newInvalidRequestError("model %q does not support image content blocks", spec.ID) - } - return Block{ - Type: blockImage, - Source: &Source{ - Type: sourceBase64, - MediaType: v.Image.GetMediaType(), - // Image bytes are raw binary and, unlike - // ThinkingBlock.Signature/RedactedThinkingBlock.Data below, - // genuinely need base64 encoding here. - Data: base64.StdEncoding.EncodeToString(v.Image.GetData()), - }, - }, nil - - case *contentv1.ContentBlock_Document: - if !spec.SupportsDocuments { - return Block{}, newInvalidRequestError("model %q does not support document content blocks", spec.ID) - } - blk := Block{ - Type: blockDocument, - Source: &Source{ - Type: sourceBase64, - MediaType: v.Document.GetMediaType(), - Data: base64.StdEncoding.EncodeToString(v.Document.GetData()), - }, - } - if fn := v.Document.GetFilename(); fn != "" { - blk.Title = fn - } - return blk, nil - - case *contentv1.ContentBlock_ToolUse: - if !spec.SupportsToolUse { - return Block{}, newInvalidRequestError("model %q does not support tool use", spec.ID) - } - input, err := structToJSON(v.ToolUse.GetArguments()) - if err != nil { - return Block{}, newInvalidRequestError("tool_use %q arguments: %s", v.ToolUse.GetName(), err) - } - return Block{ - Type: blockToolUse, - ID: v.ToolUse.GetId(), - Name: v.ToolUse.GetName(), - Input: input, - }, nil - - case *contentv1.ContentBlock_ToolResult: - content, err := translateBlocks(v.ToolResult.GetContent(), spec) - if err != nil { - return Block{}, err - } - return Block{ - Type: blockToolResult, - ToolUseID: v.ToolResult.GetToolUseId(), - Content: content, - IsError: v.ToolResult.GetIsError(), - }, nil - - case *contentv1.ContentBlock_Thinking: - return Block{ - Type: blockThinking, - Thinking: v.Thinking.GetText(), - // Signature holds the literal ASCII bytes of Anthropic's own - // base64 signature text, carried through verbatim. Do NOT - // base64-encode or decode it: Go's encoder isn't guaranteed to - // reproduce the vendor's exact padding/alphabet, and any - // deviation makes the vendor reject the next turn outright. - Signature: string(v.Thinking.GetSignature()), - }, nil - - case *contentv1.ContentBlock_RedactedThinking: - return Block{ - Type: blockRedactedThinking, - // Same do-not-re-encode rule as Signature above: Data is the - // literal ASCII bytes of Anthropic's own encrypted blob text. - Data: string(v.RedactedThinking.GetData()), - }, nil - - default: - return Block{}, newInvalidRequestError("content block has no set variant") - } -} diff --git a/internal/anthropic/messages/request_test.go b/internal/anthropic/messages/request_test.go deleted file mode 100644 index 31a45cf..0000000 --- a/internal/anthropic/messages/request_test.go +++ /dev/null @@ -1,950 +0,0 @@ -package messages - -import ( - "encoding/base64" - "encoding/json" - "reflect" - "strings" - "testing" - - "google.golang.org/protobuf/types/known/structpb" - - content "github.com/pluggableharness/agent/pkg/content" - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" - schemapkg "github.com/pluggableharness/agent/pkg/schema" - schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" -) - -// fullSpec is a model.Spec with every content-block capability enabled and -// discrete-effort thinking, used as the default test fixture for -// capability-gated content blocks and effort-ladder thinking. -func fullSpec() model.Spec { - return model.Spec{ - ID: "claude-opus-5", - MaxOutputTokens: 4096, - SupportsToolUse: true, - SupportsVision: true, - SupportsDocuments: true, - Thinking: model.ThinkingSpec{ - Supported: true, - Effort: &model.EffortControl{ - Levels: []string{"low", "medium", "high"}, - Default: "medium", - }, - AdaptiveByDefault: true, - Disable: modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_NEVER, - }, - Caching: model.CachingSpec{ - Supported: true, - ExplicitMarkers: true, - }, - } -} - -// budgetSpec is a model.Spec using continuous-budget thinking instead of -// discrete effort, used to exercise the budget-token path and the -// temperature-inclusion rule (only the effort ladder rejects temperature). -func budgetSpec(canDisable bool) model.Spec { - disable := modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_NEVER - if canDisable { - disable = modelv1.ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_ALWAYS - } - return model.Spec{ - ID: "claude-legacy", - MaxOutputTokens: 4096, - SupportsToolUse: true, - Thinking: model.ThinkingSpec{ - Supported: true, - Budget: &model.BudgetControl{ - Range: model.ThinkingBudgetRange{Min: 1024, Max: 32000}, - }, - Disable: disable, - }, - Caching: model.CachingSpec{}, - } -} - -// minimalSpec has no optional content-block capability and no -// thinking/caching support, used to exercise every capability-gate -// rejection. -func minimalSpec() model.Spec { - return model.Spec{ - ID: "claude-minimal", - MaxOutputTokens: 2048, - Thinking: model.ThinkingSpec{}, - Caching: model.CachingSpec{}, - } -} - -func mustStruct(t *testing.T, m map[string]any) *structpb.Struct { - t.Helper() - s, err := structpb.NewStruct(m) - if err != nil { - t.Fatalf("structpb.NewStruct: %v", err) - } - return s -} - -// decodeJSON unmarshals raw into a generic map for structural comparison, -// the same technique schema_test.go uses. -func decodeJSON(t *testing.T, raw []byte) map[string]any { - t.Helper() - var m map[string]any - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatalf("unmarshal: %v\nraw: %s", err, raw) - } - return m -} - -// TestBuildRequest_fullWorkedExample builds the request from -// docs/specifications/model/examples.md#a-full-streamcompletion-event-sequence -// and asserts the resulting Request's shape: the assembled-context section -// wrapped into `system` with its cache breakpoint applied, the tool -// declaration translated through schemaToJSON, and the single user message. -func TestBuildRequest_fullWorkedExample(t *testing.T) { - t.Parallel() - - pathSchema := schemapkg.String() - inputSchema, err := schemapkg.Object(map[string]*schemav1.Schema{"path": pathSchema}, schemapkg.WithRequired("path")) - if err != nil { - t.Fatalf("build tool schema: %v", err) - } - wantToolSchema, err := schemaToJSON(inputSchema) - if err != nil { - t.Fatalf("schemaToJSON: %v", err) - } - - in := &modelv1.StreamCompletionRequest{ - Messages: []*contentv1.Message{ - {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("What's in main.go?")}}, - }, - ModelId: "claude-opus-5", - Tools: []*modelv1.ToolDeclaration{ - {Name: "read_file", InputSchema: inputSchema}, - }, - AssembledContext: []*contentv1.ContextSection{ - { - Provider: "project-context", - Label: "CLAUDE.md", - Content: []*contentv1.ContentBlock{content.Text("This is CLAUDE.md content.")}, - Tokens: 812, - Stability: contentv1.Stability_STABILITY_STATIC, - }, - }, - CacheBreakpoints: []*modelv1.CacheBreakpoint{ - {Position: &modelv1.CacheBreakpoint_AfterAssembledContext_{AfterAssembledContext: &modelv1.CacheBreakpoint_AfterAssembledContext{}}}, - }, - } - - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - raw, err := json.Marshal(got) - if err != nil { - t.Fatalf("marshal Request: %v", err) - } - - want := map[string]any{ - "model": "claude-opus-5", - "max_tokens": float64(4096), - "stream": true, - "system": []any{ - map[string]any{ - "type": "text", - "text": "\nThis is CLAUDE.md content.\n", - "cache_control": map[string]any{"type": "ephemeral"}, - }, - }, - "tools": []any{ - map[string]any{ - "name": "read_file", - "input_schema": decodeJSON(t, wantToolSchema), - }, - }, - "messages": []any{ - map[string]any{ - "role": "user", - "content": []any{ - map[string]any{"type": "text", "text": "What's in main.go?"}, - }, - }, - }, - } - - if got := decodeJSON(t, raw); !reflect.DeepEqual(got, want) { - t.Fatalf("got %#v,\nwant %#v", got, want) - } -} - -// TestBuildRequest_messageCoalescing verifies that consecutive same-role -// messages merge into one, and specifically that several ToolResultBlocks -// answering one assistant turn — each arriving as its own canonical -// message, as the kernel does for parallel tool calls — land in a single -// user message. -func TestBuildRequest_messageCoalescing(t *testing.T) { - t.Parallel() - - in := &modelv1.StreamCompletionRequest{ - ModelId: "claude-opus-5", - Messages: []*contentv1.Message{ - {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("Hi")}}, - {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("there")}}, - {Role: contentv1.Role_ROLE_ASSISTANT, Content: []*contentv1.ContentBlock{ - content.ToolUse("tc1", "read_file", mustStruct(t, map[string]any{"path": "a.go"})), - }}, - {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.ToolResult("tc1", content.Text("r1"))}}, - {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.ToolResult("tc2", content.Text("r2"))}}, - }, - } - - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(got.Messages) != 3 { - t.Fatalf("expected 3 coalesced messages, got %d: %#v", len(got.Messages), got.Messages) - } - if got.Messages[0].Role != roleUser || len(got.Messages[0].Content) != 2 { - t.Fatalf("message 0: expected 2 merged user blocks, got %#v", got.Messages[0]) - } - if got.Messages[1].Role != roleAssistant || len(got.Messages[1].Content) != 1 { - t.Fatalf("message 1: expected 1 assistant block, got %#v", got.Messages[1]) - } - if got.Messages[2].Role != roleUser || len(got.Messages[2].Content) != 2 { - t.Fatalf("message 2: expected 2 merged tool_result blocks, got %#v", got.Messages[2]) - } - if got.Messages[2].Content[0].ToolUseID != "tc1" || got.Messages[2].Content[1].ToolUseID != "tc2" { - t.Fatalf("message 2: tool_result ids not preserved in order: %#v", got.Messages[2].Content) - } -} - -func TestBuildRequest_toolChoiceModes(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - choice *modelv1.ToolChoice - want *ToolChoice - wantErr bool - }{ - {name: "unset", choice: nil, want: nil}, - {name: "auto", choice: &modelv1.ToolChoice{Mode: modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_AUTO}, want: &ToolChoice{Type: toolChoiceAuto}}, - {name: "any", choice: &modelv1.ToolChoice{Mode: modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_ANY}, want: &ToolChoice{Type: toolChoiceAny}}, - {name: "none", choice: &modelv1.ToolChoice{Mode: modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_NONE}, want: &ToolChoice{Type: toolChoiceNone}}, - { - name: "specific with name", - choice: &modelv1.ToolChoice{Mode: modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_SPECIFIC, ToolName: strptr("delete_repo")}, - want: &ToolChoice{Type: toolChoiceTool, Name: "delete_repo"}, - }, - { - name: "specific without name is rejected", - choice: &modelv1.ToolChoice{Mode: modelv1.ToolChoiceMode_TOOL_CHOICE_MODE_SPECIFIC}, - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("hi")}}}, - Params: &modelv1.GenerationParams{ToolChoice: tc.choice}, - } - got, err := BuildRequest(in, fullSpec()) - if tc.wantErr { - if err == nil { - t.Fatal("expected an error, got nil") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !reflect.DeepEqual(got.ToolChoice, tc.want) { - t.Fatalf("got %#v, want %#v", got.ToolChoice, tc.want) - } - }) - } -} - -func strptr(s string) *string { return &s } - -func TestBuildRequest_stopSequences(t *testing.T) { - t.Parallel() - - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("hi")}}}, - Params: &modelv1.GenerationParams{StopSequences: []string{""}}, - } - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !reflect.DeepEqual(got.StopSequences, []string{""}) { - t.Fatalf("got %#v", got.StopSequences) - } -} - -func TestBuildRequest_maxTokens(t *testing.T) { - t.Parallel() - - msg := []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("hi")}}} - - t.Run("falls back to spec default when unset", func(t *testing.T) { - t.Parallel() - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg} - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.MaxTokens != fullSpec().MaxOutputTokens { - t.Fatalf("got %d, want %d", got.MaxTokens, fullSpec().MaxOutputTokens) - } - }) - - t.Run("falls back to spec default when zero", func(t *testing.T) { - t.Parallel() - zero := int64(0) - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{MaxOutputTokens: &zero}} - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.MaxTokens != fullSpec().MaxOutputTokens { - t.Fatalf("got %d, want %d", got.MaxTokens, fullSpec().MaxOutputTokens) - } - }) - - t.Run("uses override when positive", func(t *testing.T) { - t.Parallel() - override := int64(8000) - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{MaxOutputTokens: &override}} - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.MaxTokens != 8000 { - t.Fatalf("got %d, want 8000", got.MaxTokens) - } - }) -} - -func TestBuildRequest_thinkingEffort(t *testing.T) { - t.Parallel() - - msg := []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("hi")}}} - - t.Run("valid effort level", func(t *testing.T) { - t.Parallel() - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{ThinkingEffort: strptr("high")}} - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Thinking == nil || got.Thinking.Type != thinkingTypeAdaptive { - t.Fatalf("got Thinking %#v", got.Thinking) - } - if got.OutputConfig == nil || got.OutputConfig.Effort != "high" { - t.Fatalf("got OutputConfig %#v", got.OutputConfig) - } - }) - - t.Run("effort level outside model's ladder is rejected", func(t *testing.T) { - t.Parallel() - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{ThinkingEffort: strptr("ultra")}} - if _, err := BuildRequest(in, fullSpec()); err == nil { - t.Fatal("expected an error, got nil") - } - }) - - t.Run("no effort requested leaves both nil", func(t *testing.T) { - t.Parallel() - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg} - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Thinking != nil || got.OutputConfig != nil { - t.Fatalf("expected both nil, got Thinking=%#v OutputConfig=%#v", got.Thinking, got.OutputConfig) - } - }) - - t.Run("temperature is dropped on the discrete-effort ladder", func(t *testing.T) { - t.Parallel() - temp := 0.7 - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{Temperature: &temp}} - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Temperature != nil { - t.Fatalf("expected nil temperature, got %v", *got.Temperature) - } - }) -} - -func TestBuildRequest_thinkingBudget(t *testing.T) { - t.Parallel() - - msg := []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("hi")}}} - - t.Run("valid budget within range", func(t *testing.T) { - t.Parallel() - budget := int64(8000) - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{ThinkingBudgetTokens: &budget}} - got, err := BuildRequest(in, budgetSpec(true)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Thinking == nil || got.Thinking.Type != thinkingTypeEnabled || got.Thinking.BudgetTokens == nil || *got.Thinking.BudgetTokens != 8000 { - t.Fatalf("got Thinking %#v", got.Thinking) - } - if got.OutputConfig != nil { - t.Fatalf("expected nil OutputConfig, got %#v", got.OutputConfig) - } - }) - - t.Run("budget outside range is rejected", func(t *testing.T) { - t.Parallel() - budget := int64(100) - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{ThinkingBudgetTokens: &budget}} - if _, err := BuildRequest(in, budgetSpec(true)); err == nil { - t.Fatal("expected an error, got nil") - } - }) - - t.Run("zero budget disables thinking when the model allows it", func(t *testing.T) { - t.Parallel() - zero := int64(0) - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{ThinkingBudgetTokens: &zero}} - got, err := BuildRequest(in, budgetSpec(true)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Thinking == nil || got.Thinking.Type != thinkingTypeDisabled { - t.Fatalf("got Thinking %#v", got.Thinking) - } - }) - - t.Run("zero budget is rejected when the model cannot disable thinking", func(t *testing.T) { - t.Parallel() - zero := int64(0) - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{ThinkingBudgetTokens: &zero}} - if _, err := BuildRequest(in, budgetSpec(false)); err == nil { - t.Fatal("expected an error, got nil") - } - }) - - t.Run("no budget requested leaves both nil", func(t *testing.T) { - t.Parallel() - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg} - got, err := BuildRequest(in, budgetSpec(true)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Thinking != nil || got.OutputConfig != nil { - t.Fatalf("expected both nil, got Thinking=%#v OutputConfig=%#v", got.Thinking, got.OutputConfig) - } - }) - - t.Run("temperature is kept on continuous-budget models", func(t *testing.T) { - t.Parallel() - temp := 0.5 - in := &modelv1.StreamCompletionRequest{ModelId: "m", Messages: msg, Params: &modelv1.GenerationParams{Temperature: &temp}} - got, err := BuildRequest(in, budgetSpec(true)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Temperature == nil || *got.Temperature != 0.5 { - t.Fatalf("got %#v", got.Temperature) - } - }) -} - -func TestBuildRequest_cacheBreakpoints(t *testing.T) { - t.Parallel() - - baseIn := func() *modelv1.StreamCompletionRequest { - return &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{ - {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("Hi")}}, - {Role: contentv1.Role_ROLE_ASSISTANT, Content: []*contentv1.ContentBlock{content.Text("reply")}}, - {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("thanks")}}, - }, - Tools: []*modelv1.ToolDeclaration{{Name: "t", InputSchema: schemapkg.String()}}, - AssembledContext: []*contentv1.ContextSection{ - {Provider: "p", Label: "L", Content: []*contentv1.ContentBlock{content.Text("ctx")}}, - }, - } - } - - t.Run("every variant applies its cache_control marker", func(t *testing.T) { - t.Parallel() - in := baseIn() - in.CacheBreakpoints = []*modelv1.CacheBreakpoint{ - {Position: &modelv1.CacheBreakpoint_AfterAssembledContext_{AfterAssembledContext: &modelv1.CacheBreakpoint_AfterAssembledContext{}}}, - {Position: &modelv1.CacheBreakpoint_AfterTools_{AfterTools: &modelv1.CacheBreakpoint_AfterTools{}}}, - {Position: &modelv1.CacheBreakpoint_AfterMessageIndex{AfterMessageIndex: 1}}, - } - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.System[len(got.System)-1].CacheControl == nil { - t.Fatal("expected cache_control on last system block") - } - if got.Tools[len(got.Tools)-1].CacheControl == nil { - t.Fatal("expected cache_control on last tool") - } - // Message index 1 (the assistant message) isn't merged with any - // neighbor here (roles alternate), so it survives coalescing at - // the same position. - assistant := got.Messages[1] - if assistant.Role != roleAssistant || assistant.Content[len(assistant.Content)-1].CacheControl == nil { - t.Fatalf("expected cache_control on message index 1's last block, got %#v", assistant) - } - }) - - t.Run("more than four breakpoints is rejected", func(t *testing.T) { - t.Parallel() - in := baseIn() - for range 5 { - in.CacheBreakpoints = append(in.CacheBreakpoints, &modelv1.CacheBreakpoint{ - Position: &modelv1.CacheBreakpoint_AfterMessageIndex{AfterMessageIndex: 0}, - }) - } - if _, err := BuildRequest(in, fullSpec()); err == nil { - t.Fatal("expected an error, got nil") - } - }) - - t.Run("out-of-range message index is rejected", func(t *testing.T) { - t.Parallel() - in := baseIn() - in.CacheBreakpoints = []*modelv1.CacheBreakpoint{ - {Position: &modelv1.CacheBreakpoint_AfterMessageIndex{AfterMessageIndex: 99}}, - } - if _, err := BuildRequest(in, fullSpec()); err == nil { - t.Fatal("expected an error, got nil") - } - }) - - t.Run("ignored entirely when caching mode is not explicit markers", func(t *testing.T) { - t.Parallel() - in := baseIn() - // An out-of-range index would otherwise be rejected — proving the - // field is truly ignored, not just successfully validated. - in.CacheBreakpoints = []*modelv1.CacheBreakpoint{ - {Position: &modelv1.CacheBreakpoint_AfterMessageIndex{AfterMessageIndex: 99}}, - } - got, err := BuildRequest(in, minimalSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - for _, m := range got.Messages { - for _, b := range m.Content { - if b.CacheControl != nil { - t.Fatalf("expected no cache_control anywhere, found one on %#v", b) - } - } - } - }) - - t.Run("after_assembled_context with empty system is rejected", func(t *testing.T) { - t.Parallel() - in := baseIn() - in.AssembledContext = nil - in.CacheBreakpoints = []*modelv1.CacheBreakpoint{ - {Position: &modelv1.CacheBreakpoint_AfterAssembledContext_{AfterAssembledContext: &modelv1.CacheBreakpoint_AfterAssembledContext{}}}, - } - if _, err := BuildRequest(in, fullSpec()); err == nil { - t.Fatal("expected an error, got nil") - } - }) - - t.Run("after_tools with no declared tools is rejected", func(t *testing.T) { - t.Parallel() - in := baseIn() - in.Tools = nil - in.CacheBreakpoints = []*modelv1.CacheBreakpoint{ - {Position: &modelv1.CacheBreakpoint_AfterTools_{AfterTools: &modelv1.CacheBreakpoint_AfterTools{}}}, - } - if _, err := BuildRequest(in, fullSpec()); err == nil { - t.Fatal("expected an error, got nil") - } - }) - - t.Run("entry with no position set is rejected", func(t *testing.T) { - t.Parallel() - in := baseIn() - in.CacheBreakpoints = []*modelv1.CacheBreakpoint{{}} - if _, err := BuildRequest(in, fullSpec()); err == nil { - t.Fatal("expected an error, got nil") - } - }) -} - -func TestBuildRequest_contentBlockCapabilityGates(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - block *contentv1.ContentBlock - spec model.Spec - wantErr bool - }{ - {name: "image accepted when vision supported", block: content.Image([]byte{1, 2, 3}, "image/png"), spec: fullSpec()}, - {name: "image rejected when vision unsupported", block: content.Image([]byte{1, 2, 3}, "image/png"), spec: minimalSpec(), wantErr: true}, - {name: "document accepted when supported", block: content.Document([]byte("pdf"), "application/pdf"), spec: fullSpec()}, - {name: "document rejected when unsupported", block: content.Document([]byte("pdf"), "application/pdf"), spec: minimalSpec(), wantErr: true}, - { - name: "tool_use accepted when supported", - block: content.ToolUse("tc1", "t", mustStruct(t, map[string]any{"a": 1})), - spec: fullSpec(), - }, - { - name: "tool_use rejected when unsupported", - block: content.ToolUse("tc1", "t", mustStruct(t, map[string]any{"a": 1})), - spec: minimalSpec(), - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{tc.block}}}, - } - _, err := BuildRequest(in, tc.spec) - if tc.wantErr && err == nil { - t.Fatal("expected an error, got nil") - } - if !tc.wantErr && err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -func TestBuildRequest_thinkingAndRedactedThinkingRawBytesUntouched(t *testing.T) { - t.Parallel() - - sig := []byte("YmFzZTY0LXNpZ25hdHVyZQ==") - data := []byte("cmVkYWN0ZWQtcGF5bG9hZA==") - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{ - {Role: contentv1.Role_ROLE_ASSISTANT, Content: []*contentv1.ContentBlock{ - content.Thinking("reasoning", sig), - content.RedactedThinking(data), - }}, - }, - } - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - blocks := got.Messages[0].Content - if blocks[0].Signature != string(sig) { - t.Fatalf("signature: got %q, want %q", blocks[0].Signature, string(sig)) - } - if blocks[1].Data != string(data) { - t.Fatalf("data: got %q, want %q", blocks[1].Data, string(data)) - } -} - -func TestBuildRequest_imageAndDocumentDataAreBase64Encoded(t *testing.T) { - t.Parallel() - - imgData := []byte{0x89, 0x50, 0x4e, 0x47} - docData := []byte("%PDF-1.4 ...") - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{ - {Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{ - content.Image(imgData, "image/png"), - content.Document(docData, "application/pdf", content.WithFilename("spec.pdf")), - }}, - }, - } - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - blocks := got.Messages[0].Content - if blocks[0].Source.Data != base64.StdEncoding.EncodeToString(imgData) { - t.Fatalf("image data not base64-encoded: %q", blocks[0].Source.Data) - } - if blocks[1].Source.Data != base64.StdEncoding.EncodeToString(docData) { - t.Fatalf("document data not base64-encoded: %q", blocks[1].Source.Data) - } - if blocks[1].Title != "spec.pdf" { - t.Fatalf("document title: got %q, want %q", blocks[1].Title, "spec.pdf") - } -} - -func TestBuildRequest_toolUseArgumentsAreDeterministicJSON(t *testing.T) { - t.Parallel() - - args := mustStruct(t, map[string]any{"path": "main.go", "recursive": true}) - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{ - {Role: contentv1.Role_ROLE_ASSISTANT, Content: []*contentv1.ContentBlock{content.ToolUse("tc1", "read_file", args)}}, - }, - } - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - gotArgs := decodeJSON(t, got.Messages[0].Content[0].Input) - wantArgs := map[string]any{"path": "main.go", "recursive": true} - if !reflect.DeepEqual(gotArgs, wantArgs) { - t.Fatalf("got %#v, want %#v", gotArgs, wantArgs) - } -} - -func TestBuildRequest_systemSectionWithNonTextBlockIsRejected(t *testing.T) { - t.Parallel() - - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("hi")}}}, - AssembledContext: []*contentv1.ContextSection{ - {Provider: "p", Label: "L", Content: []*contentv1.ContentBlock{content.Image([]byte{1}, "image/png")}}, - }, - } - if _, err := BuildRequest(in, fullSpec()); err == nil { - t.Fatal("expected an error, got nil") - } -} - -func TestBuildRequest_messageRoleUnspecifiedIsRejected(t *testing.T) { - t.Parallel() - - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{{Content: []*contentv1.ContentBlock{content.Text("hi")}}}, - } - if _, err := BuildRequest(in, fullSpec()); err == nil { - t.Fatal("expected an error, got nil") - } -} - -func TestBuildRequest_emptyAssembledContextLeavesSystemNil(t *testing.T) { - t.Parallel() - - in := &modelv1.StreamCompletionRequest{ - ModelId: "m", - Messages: []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("hi")}}}, - } - got, err := BuildRequest(in, fullSpec()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.System != nil { - t.Fatalf("expected nil System, got %#v", got.System) - } -} - -// TestStructToJSON_isByteIdenticalAcrossRuns exists to stop a future edit -// from reintroducing protojson (or any other non-deterministic marshaler) -// into structToJSON. structpb.Struct.AsMap() returns a native Go map, whose -// own iteration order is randomized, so structToJSON is only deterministic -// because it lets encoding/json sort the keys during marshaling. If this -// test ever starts failing, the fix is in structToJSON, never in the test: -// the real-world failure mode is a tool call's arguments serializing -// differently turn to turn, which silently and permanently disables -// Anthropic's byte-exact prompt cache from that point forward — with no -// error or warning anywhere to reveal why. -func TestStructToJSON_isByteIdenticalAcrossRuns(t *testing.T) { - t.Parallel() - - s := mustStruct(t, map[string]any{ - "zulu": map[string]any{"nested_a": 1, "nested_b": "two"}, - "yankee": 2, - "xray": "three", - "whiskey": true, - "victor": map[string]any{"deep": map[string]any{"deeper": "value"}}, - "uniform": []any{1, 2, 3}, - "tango": 4.5, - "sierra": "six", - "romeo": false, - "quebec": 7, - "papa": "eight", - }) - - first, err := structToJSON(s) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - for i := range 100 { - got, err := structToJSON(s) - if err != nil { - t.Fatalf("run %d: unexpected error: %v", i, err) - } - if string(got) != string(first) { - t.Fatalf("run %d produced different bytes than run 0:\nrun 0: %s\nrun %d: %s", i, first, i, got) - } - } -} - -func TestStructToJSON_nilStruct(t *testing.T) { - t.Parallel() - - got, err := structToJSON(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if string(got) != "{}" { - t.Fatalf("got %q, want {}", got) - } -} - -func TestTranslateRole(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - role contentv1.Role - want string - wantErr bool - }{ - {name: "user", role: contentv1.Role_ROLE_USER, want: roleUser}, - {name: "assistant", role: contentv1.Role_ROLE_ASSISTANT, want: roleAssistant}, - {name: "unspecified", role: contentv1.Role_ROLE_UNSPECIFIED, wantErr: true}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, err := translateRole(tc.role) - if tc.wantErr { - if err == nil { - t.Fatal("expected an error, got nil") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tc.want { - t.Fatalf("got %q, want %q", got, tc.want) - } - }) - } -} - -func TestCoalesceMessages(t *testing.T) { - t.Parallel() - - t.Run("empty input", func(t *testing.T) { - t.Parallel() - if got := coalesceMessages(nil); got != nil { - t.Fatalf("got %#v, want nil", got) - } - }) - - t.Run("alternating roles do not merge", func(t *testing.T) { - t.Parallel() - in := []Message{ - {Role: roleUser, Content: []Block{{Type: blockText, Text: "a"}}}, - {Role: roleAssistant, Content: []Block{{Type: blockText, Text: "b"}}}, - {Role: roleUser, Content: []Block{{Type: blockText, Text: "c"}}}, - } - got := coalesceMessages(in) - if len(got) != 3 { - t.Fatalf("expected 3 messages, got %d: %#v", len(got), got) - } - }) - - t.Run("consecutive same-role messages merge", func(t *testing.T) { - t.Parallel() - in := []Message{ - {Role: roleUser, Content: []Block{{Type: blockText, Text: "a"}}}, - {Role: roleUser, Content: []Block{{Type: blockText, Text: "b"}}}, - {Role: roleUser, Content: []Block{{Type: blockText, Text: "c"}}}, - } - got := coalesceMessages(in) - want := []Message{ - {Role: roleUser, Content: []Block{ - {Type: blockText, Text: "a"}, - {Type: blockText, Text: "b"}, - {Type: blockText, Text: "c"}, - }}, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %#v, want %#v", got, want) - } - }) -} - -// TestBuildCountTokensRequest_carriesToolsAndSystem is the regression -// guard for what the request-shaped CountTokens RPC exists to fix. The -// earlier flat-text shape could carry neither tool schemas nor the system -// preamble, so a count omitted both — and tool schemas are frequently the -// single largest contributor to a request's input tokens, which is exactly -// the weight that decides whether a turn fits in the context window. -func TestBuildCountTokensRequest_carriesToolsAndSystem(t *testing.T) { - t.Parallel() - - in := &modelv1.CountTokensRequest{ - ModelId: "claude-opus-5", - Messages: []*contentv1.Message{{Role: contentv1.Role_ROLE_USER, Content: []*contentv1.ContentBlock{content.Text("what changed?")}}}, - AssembledContext: []*contentv1.ContextSection{{ - Provider: "project-context", - Label: "CLAUDE.md", - Content: []*contentv1.ContentBlock{content.Text("house rules")}, - }}, - Tools: []*modelv1.ToolDeclaration{{ - Name: "read", - Description: "read a file", - }}, - } - - got, err := BuildCountTokensRequest(in, fullSpec()) - if err != nil { - t.Fatalf("BuildCountTokensRequest: %v", err) - } - - if got.Model != "claude-opus-5" { - t.Errorf("Model = %q, want claude-opus-5", got.Model) - } - if len(got.Messages) != 1 { - t.Fatalf("len(Messages) = %d, want 1", len(got.Messages)) - } - if len(got.Tools) != 1 || got.Tools[0].Name != "read" { - t.Errorf("Tools = %+v, want the declared read tool", got.Tools) - } - if len(got.System) != 1 || !strings.Contains(got.System[0].Text, "house rules") { - t.Errorf("System = %+v, want the assembled context section", got.System) - } -} - -// TestBuildCountTokensRequest_emptyRequestIsValid proves an empty request -// is a legal thing to count: most vendors bill some fixed request -// overhead, so the answer is not necessarily zero and the adapter must not -// short-circuit it. -func TestBuildCountTokensRequest_emptyRequestIsValid(t *testing.T) { - t.Parallel() - - got, err := BuildCountTokensRequest(&modelv1.CountTokensRequest{ModelId: "claude-opus-5"}, fullSpec()) - if err != nil { - t.Fatalf("BuildCountTokensRequest: %v", err) - } - if got.Model != "claude-opus-5" { - t.Errorf("Model = %q, want claude-opus-5", got.Model) - } - if len(got.Messages) != 0 || len(got.Tools) != 0 || len(got.System) != 0 { - t.Errorf("got %+v, want an empty body apart from the model", got) - } -} diff --git a/internal/anthropic/messages/schema.go b/internal/anthropic/messages/schema.go deleted file mode 100644 index c3893e7..0000000 --- a/internal/anthropic/messages/schema.go +++ /dev/null @@ -1,86 +0,0 @@ -package messages - -import ( - "encoding/json" - "fmt" - - schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" -) - -// schemaTypeJSON maps a schemav1.SchemaType to the JSON Schema "type" -// keyword Anthropic's tool input_schema field expects. -var schemaTypeJSON = map[schemav1.SchemaType]string{ - schemav1.SchemaType_SCHEMA_TYPE_OBJECT: "object", - schemav1.SchemaType_SCHEMA_TYPE_STRING: "string", - schemav1.SchemaType_SCHEMA_TYPE_NUMBER: "number", - schemav1.SchemaType_SCHEMA_TYPE_BOOLEAN: "boolean", - schemav1.SchemaType_SCHEMA_TYPE_ARRAY: "array", -} - -// schemaToJSON converts s into the JSON Schema object Anthropic's tool -// input_schema field expects, as deterministic bytes. A nil s produces a -// valid empty object schema rather than an error — Anthropic requires an -// object schema even for a no-argument tool. -// -// The result is built as a tree of native Go maps/slices and marshaled with -// encoding/json rather than protojson: encoding/json sorts map[string]any -// keys during marshaling, which is what makes the properties object -// deterministic despite schemav1.Schema.Properties being a proto -// map with random Go iteration order. protojson makes no -// such guarantee and, per this package's CLAUDE.md, is never used here — -// non-deterministic tool schema bytes would silently and permanently -// disable Anthropic's prompt cache from the first tool-bearing request -// onward. -func schemaToJSON(s *schemav1.Schema) (json.RawMessage, error) { - if s == nil { - return json.Marshal(map[string]any{ - "type": "object", - "properties": map[string]any{}, - }) - } - tree, err := schemaToTree(s) - if err != nil { - return nil, err - } - return json.Marshal(tree) -} - -// schemaToTree recursively converts s into a native Go map, the shared -// building block schemaToJSON marshals for the top-level call and that -// schemaToTree itself calls for nested properties/items. -func schemaToTree(s *schemav1.Schema) (map[string]any, error) { - typeName, ok := schemaTypeJSON[s.GetType()] - if !ok { - return nil, fmt.Errorf("schema: unsupported or unspecified type %v", s.GetType()) - } - - tree := map[string]any{"type": typeName} - if d := s.GetDescription(); d != "" { - tree["description"] = d - } - if props := s.GetProperties(); len(props) > 0 { - propTree := make(map[string]any, len(props)) - for name, prop := range props { - sub, err := schemaToTree(prop) - if err != nil { - return nil, err - } - propTree[name] = sub - } - tree["properties"] = propTree - } - if req := s.GetRequired(); len(req) > 0 { - tree["required"] = req - } - if items := s.GetItems(); items != nil { - sub, err := schemaToTree(items) - if err != nil { - return nil, err - } - tree["items"] = sub - } - if enum := s.GetEnumValues(); len(enum) > 0 { - tree["enum"] = enum - } - return tree, nil -} diff --git a/internal/anthropic/messages/schema_test.go b/internal/anthropic/messages/schema_test.go deleted file mode 100644 index dc60bf6..0000000 --- a/internal/anthropic/messages/schema_test.go +++ /dev/null @@ -1,270 +0,0 @@ -package messages - -import ( - "encoding/json" - "fmt" - "reflect" - "testing" - - schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" -) - -// decodeSchemaJSON unmarshals raw into a generic map for structural -// comparison against a hand-built expectation, rather than asserting on -// exact bytes — key order is already guaranteed deterministic by -// encoding/json's own map-key sort, so a structural comparison is both -// sufficient and less brittle here than a literal string match. -func decodeSchemaJSON(t *testing.T, raw json.RawMessage) map[string]any { - t.Helper() - var m map[string]any - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatalf("unmarshal schema JSON: %v", err) - } - return m -} - -func TestSchemaToJSON_nilSchema(t *testing.T) { - t.Parallel() - - got, err := schemaToJSON(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want := map[string]any{ - "type": "object", - "properties": map[string]any{}, - } - if got := decodeSchemaJSON(t, got); !reflect.DeepEqual(got, want) { - t.Fatalf("nil schema: got %#v, want %#v", got, want) - } -} - -func TestSchemaToJSON_typeMapping(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in *schemav1.Schema - want map[string]any - }{ - { - name: "object with no properties", - in: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT}, - want: map[string]any{"type": "object"}, - }, - { - name: "string", - in: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_STRING}, - want: map[string]any{"type": "string"}, - }, - { - name: "number", - in: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_NUMBER}, - want: map[string]any{"type": "number"}, - }, - { - name: "boolean", - in: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_BOOLEAN}, - want: map[string]any{"type": "boolean"}, - }, - { - name: "array with no items", - in: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_ARRAY}, - want: map[string]any{"type": "array"}, - }, - { - name: "description", - in: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_STRING, Description: "a name"}, - want: map[string]any{"type": "string", "description": "a name"}, - }, - { - name: "string with enum values", - in: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_STRING, EnumValues: []string{"low", "medium", "high"}}, - want: map[string]any{"type": "string", "enum": []any{"low", "medium", "high"}}, - }, - { - name: "object with properties and required", - in: &schemav1.Schema{ - Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT, - Properties: map[string]*schemav1.Schema{ - "path": {Type: schemav1.SchemaType_SCHEMA_TYPE_STRING}, - }, - Required: []string{"path"}, - }, - want: map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{"type": "string"}, - }, - "required": []any{"path"}, - }, - }, - { - name: "array with items", - in: &schemav1.Schema{ - Type: schemav1.SchemaType_SCHEMA_TYPE_ARRAY, - Items: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_NUMBER}, - }, - want: map[string]any{ - "type": "array", - "items": map[string]any{"type": "number"}, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - raw, err := schemaToJSON(tc.in) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got := decodeSchemaJSON(t, raw); !reflect.DeepEqual(got, tc.want) { - t.Fatalf("got %#v, want %#v", got, tc.want) - } - }) - } -} - -func TestSchemaToJSON_nesting(t *testing.T) { - t.Parallel() - - in := &schemav1.Schema{ - Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT, - Properties: map[string]*schemav1.Schema{ - "files": { - Type: schemav1.SchemaType_SCHEMA_TYPE_ARRAY, - Items: &schemav1.Schema{ - Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT, - Properties: map[string]*schemav1.Schema{ - "path": {Type: schemav1.SchemaType_SCHEMA_TYPE_STRING}, - "lines": {Type: schemav1.SchemaType_SCHEMA_TYPE_NUMBER}, - }, - Required: []string{"path"}, - }, - }, - }, - Required: []string{"files"}, - } - - want := map[string]any{ - "type": "object", - "properties": map[string]any{ - "files": map[string]any{ - "type": "array", - "items": map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{"type": "string"}, - "lines": map[string]any{"type": "number"}, - }, - "required": []any{"path"}, - }, - }, - }, - "required": []any{"files"}, - } - - raw, err := schemaToJSON(in) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got := decodeSchemaJSON(t, raw); !reflect.DeepEqual(got, want) { - t.Fatalf("got %#v, want %#v", got, want) - } -} - -func TestSchemaToJSON_unspecifiedTypeErrors(t *testing.T) { - t.Parallel() - - if _, err := schemaToJSON(&schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_UNSPECIFIED}); err == nil { - t.Fatal("expected an error for SCHEMA_TYPE_UNSPECIFIED, got nil") - } -} - -func TestSchemaToJSON_nestedInvalidTypePropagates(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in *schemav1.Schema - }{ - { - name: "invalid property type", - in: &schemav1.Schema{ - Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT, - Properties: map[string]*schemav1.Schema{ - "bad": {Type: schemav1.SchemaType_SCHEMA_TYPE_UNSPECIFIED}, - }, - }, - }, - { - name: "invalid array items type", - in: &schemav1.Schema{ - Type: schemav1.SchemaType_SCHEMA_TYPE_ARRAY, - Items: &schemav1.Schema{Type: schemav1.SchemaType_SCHEMA_TYPE_UNSPECIFIED}, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - if _, err := schemaToJSON(tc.in); err == nil { - t.Fatal("expected an error, got nil") - } - }) - } -} - -// TestSchemaToJSON_isByteIdenticalAcrossRuns exists to stop a future edit -// from reintroducing protojson (or any other marshaler that doesn't sort -// map keys) into schemaToJSON. schemav1.Schema.Properties is a proto -// map, which decodes into a Go map with randomized -// iteration order — schemaToJSON is only deterministic because it builds a -// native map[string]any tree and lets encoding/json sort the keys during -// marshaling. If this test ever starts failing, the fix is in -// schemaToJSON's marshaler, never in the test: the real-world failure mode -// of non-deterministic tool-schema bytes is a silently and permanently -// disabled Anthropic prompt cache, discovered only in a bill weeks later. -func TestSchemaToJSON_isByteIdenticalAcrossRuns(t *testing.T) { - t.Parallel() - - // Deliberately not in alphabetical order, and nested two levels deep - // (each top-level property is itself an object with its own - // sub-properties), so a naive unsorted marshaler would show it. - keys := []string{ - "zulu", "yankee", "xray", "whiskey", "victor", - "uniform", "tango", "sierra", "romeo", "quebec", "papa", - } - props := make(map[string]*schemav1.Schema, len(keys)) - for i, k := range keys { - props[k] = &schemav1.Schema{ - Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT, - Description: fmt.Sprintf("field %d", i), - Properties: map[string]*schemav1.Schema{ - "inner_a": {Type: schemav1.SchemaType_SCHEMA_TYPE_STRING}, - "inner_b": {Type: schemav1.SchemaType_SCHEMA_TYPE_NUMBER}, - }, - Required: []string{"inner_a"}, - } - } - root := &schemav1.Schema{ - Type: schemav1.SchemaType_SCHEMA_TYPE_OBJECT, - Properties: props, - } - - first, err := schemaToJSON(root) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - for i := range 100 { - got, err := schemaToJSON(root) - if err != nil { - t.Fatalf("run %d: unexpected error: %v", i, err) - } - if string(got) != string(first) { - t.Fatalf("run %d produced different bytes than run 0:\nrun 0: %s\nrun %d: %s", i, first, i, got) - } - } -} diff --git a/internal/anthropic/messages/sse.go b/internal/anthropic/messages/sse.go deleted file mode 100644 index e170804..0000000 --- a/internal/anthropic/messages/sse.go +++ /dev/null @@ -1,87 +0,0 @@ -package messages - -import ( - "encoding/json" - "fmt" - "io" - - "github.com/pluggableharness/agent/pkg/sse" -) - -// Scanner reads Anthropic's server-sent event stream, decoding one -// StreamEvent per call to Next. -// -// The SSE framing itself lives in pkg/sse, where every plugin author can -// reach it — nothing about reading blank-line-separated data: frames is -// Anthropic-specific. What stays here is the part that genuinely is: which -// field decides an event's type. -// -// Anthropic sends both an event: line and a JSON payload carrying its own -// "type", and this decodes from the payload alone. The JSON is -// authoritative even where it disagrees with, or the wire omits, a -// matching event: line — which is exactly why the shared scanner surfaces -// both fields and takes no position on either. Every event is decoded -// here, including ping; Handle (events.go) is where ping is filtered, so -// that choice lives in exactly one place. -type Scanner struct { - scan *sse.Scanner - cur StreamEvent - err error -} - -// NewScanner returns a Scanner reading Anthropic's SSE stream from r. -func NewScanner(r io.Reader) *Scanner { - return &Scanner{scan: sse.NewScanner(r)} -} - -// Next advances the Scanner to the next decoded event. It returns false at -// EOF or once Err reports a non-nil error, and true when Event has a new -// value ready. -func (s *Scanner) Next() bool { - if s.err != nil { - return false - } - if !s.scan.Next() { - if err := s.scan.Err(); err != nil { - s.err = fmt.Errorf("anthropic: sse: %w", err) - } - return false - } - return s.decode(s.scan.Data()) -} - -// decode unmarshals one frame's payload into s.cur. A parse failure is an -// error, not a skip: a data payload that doesn't parse means something is -// wrong with the vendor's wire, not with one ignorable event. -func (s *Scanner) decode(data []byte) bool { - // Reset before unmarshaling. Both halves of this matter and both are - // silent corruption if skipped: - // - // - encoding/json reuses a non-nil pointer field rather than - // allocating a fresh one, so decoding two content_block_delta - // events into the same struct makes both share one *StreamDelta — - // the second event's contents overwrite the first's, in place, - // after the caller already holds it. - // - A field absent from event N+1 keeps event N's value, so a - // content_block_delta would appear to carry the preceding - // message_start's usage. - // - // Zeroing costs one small struct assignment per event and removes - // both. - s.cur = StreamEvent{} - if err := json.Unmarshal(data, &s.cur); err != nil { - s.err = fmt.Errorf("anthropic: sse: decode event: %w", err) - return false - } - return true -} - -// Event returns the event most recently decoded by Next. -func (s *Scanner) Event() StreamEvent { - return s.cur -} - -// Err returns the error that stopped iteration, or nil at a clean EOF. -func (s *Scanner) Err() error { - return s.err -} diff --git a/internal/anthropic/messages/sse_test.go b/internal/anthropic/messages/sse_test.go deleted file mode 100644 index 636524d..0000000 --- a/internal/anthropic/messages/sse_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package messages - -import ( - "errors" - "strings" - "testing" -) - -// scanAll drains s, returning every decoded event and the terminal error -// (nil at a clean EOF). -func scanAll(t *testing.T, s *Scanner) ([]StreamEvent, error) { - t.Helper() - var events []StreamEvent - for s.Next() { - events = append(events, s.Event()) - } - return events, s.Err() -} - -func TestScanner_singleEvent(t *testing.T) { - t.Parallel() - - raw := "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n" - events, err := scanAll(t, NewScanner(strings.NewReader(raw))) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(events) != 1 { - t.Fatalf("got %d events, want 1", len(events)) - } - ev := events[0] - if ev.Type != eventContentBlockDelta || ev.Index != 0 || ev.Delta == nil || ev.Delta.Text != "Hello" { - t.Fatalf("decoded event = %+v", ev) - } -} - -func TestScanner_multipleEventsInOrder(t *testing.T) { - t.Parallel() - - raw := "data: {\"type\":\"ping\"}\n\n" + - "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"a\"}}\n\n" + - "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"b\"}}\n\n" - events, err := scanAll(t, NewScanner(strings.NewReader(raw))) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want := []string{eventPing, eventContentBlockDelta, eventContentBlockDelta} - if len(events) != len(want) { - t.Fatalf("got %d events, want %d", len(events), len(want)) - } - for i, w := range want { - if events[i].Type != w { - t.Errorf("event %d: type = %q, want %q", i, events[i].Type, w) - } - } - if events[1].Delta.Text != "a" || events[2].Delta.Text != "b" { - t.Fatalf("delta text mismatch: %+v", events) - } -} - -func TestScanner_pingSurfaced(t *testing.T) { - t.Parallel() - - // Scanner surfaces ping rather than filtering it — the translator - // (events.go) is where ping is dropped, so this is the seam that - // proves the split. - events, err := scanAll(t, NewScanner(strings.NewReader("data: {\"type\":\"ping\"}\n\n"))) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(events) != 1 || events[0].Type != eventPing { - t.Fatalf("events = %+v", events) - } -} - -func TestScanner_commentLinesIgnored(t *testing.T) { - t.Parallel() - - raw := ": this is a comment\n" + - "data: {\"type\":\"ping\"}\n" + - ": another comment\n" + - "\n" - events, err := scanAll(t, NewScanner(strings.NewReader(raw))) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(events) != 1 || events[0].Type != eventPing { - t.Fatalf("events = %+v", events) - } -} - -func TestScanner_extraBlankLinesDoNotProduceEmptyEvents(t *testing.T) { - t.Parallel() - - raw := "\n\n\ndata: {\"type\":\"ping\"}\n\n\n\n" - events, err := scanAll(t, NewScanner(strings.NewReader(raw))) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(events) != 1 || events[0].Type != eventPing { - t.Fatalf("events = %+v", events) - } -} - -func TestScanner_multiLineDataConcatenatesWithNewline(t *testing.T) { - t.Parallel() - - // SSE joins repeated data: lines with "\n" before parsing; JSON - // tolerates the resulting whitespace between tokens, so this must - // still decode to {"type":"ping"}. - raw := "data: {\"type\":\n" + "data: \"ping\"}\n\n" - events, err := scanAll(t, NewScanner(strings.NewReader(raw))) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(events) != 1 || events[0].Type != eventPing { - t.Fatalf("events = %+v", events) - } -} - -func TestScanner_trailingEventWithoutBlankLine(t *testing.T) { - t.Parallel() - - // The stream ends immediately after the final event's data, with no - // terminating blank line before EOF. - raw := "data: {\"type\":\"ping\"}" - events, err := scanAll(t, NewScanner(strings.NewReader(raw))) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(events) != 1 || events[0].Type != eventPing { - t.Fatalf("events = %+v", events) - } -} - -func TestScanner_unparseableDataIsAnError(t *testing.T) { - t.Parallel() - - s := NewScanner(strings.NewReader("data: {not valid json\n\n")) - if s.Next() { - t.Fatalf("Next() = true for unparseable data, want false") - } - if s.Err() == nil { - t.Fatalf("Err() = nil, want a decode error") - } -} - -func TestScanner_cleanEOFReturnsNilErr(t *testing.T) { - t.Parallel() - - s := NewScanner(strings.NewReader("")) - if s.Next() { - t.Fatalf("Next() = true for empty input, want false") - } - if err := s.Err(); err != nil { - t.Fatalf("Err() = %v, want nil", err) - } -} - -func TestScanner_oversizedEventPastDefaultLineCap(t *testing.T) { - t.Parallel() - - // bufio.Scanner's default 64 KiB line cap would silently truncate - // (and error on) a data: line this large; NewScanner raises the - // buffer specifically so this must still decode cleanly. - big := strings.Repeat("A", 200*1024) - raw := "data: {\"type\":\"content_block_start\",\"content_block\":{\"type\":\"redacted_thinking\",\"data\":\"" + big + "\"}}\n\n" - events, err := scanAll(t, NewScanner(strings.NewReader(raw))) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(events) != 1 { - t.Fatalf("got %d events, want 1", len(events)) - } - ev := events[0] - if ev.ContentBlock == nil || len(ev.ContentBlock.Data) != len(big) { - t.Fatalf("redacted_thinking data length = %d, want %d", len(ev.ContentBlock.Data), len(big)) - } -} - -func TestScanner_readError(t *testing.T) { - t.Parallel() - - wantErr := errors.New("boom") - s := NewScanner(&erroringReader{err: wantErr}) - if s.Next() { - t.Fatalf("Next() = true, want false") - } - if err := s.Err(); err == nil || !strings.Contains(err.Error(), "boom") { - t.Fatalf("Err() = %v, want wrapping %v", err, wantErr) - } -} - -// erroringReader always returns err on Read, simulating a transport-level -// failure mid-stream. -type erroringReader struct { - err error -} - -func (r *erroringReader) Read([]byte) (int, error) { - return 0, r.err -} diff --git a/internal/anthropic/messages/types.go b/internal/anthropic/messages/types.go deleted file mode 100644 index 7aa8421..0000000 --- a/internal/anthropic/messages/types.go +++ /dev/null @@ -1,297 +0,0 @@ -package messages - -import "encoding/json" - -// Anthropic's own wire vocabulary. Every string constant below is a -// literal that appears on the vendor's wire; nothing here is a -// PluggableHarness concept. -const ( - // Block types, shared between request content and streamed - // content_block_start payloads. - blockText = "text" - blockImage = "image" - blockDocument = "document" - blockToolUse = "tool_use" - blockToolResult = "tool_result" - blockThinking = "thinking" - blockRedactedThinking = "redacted_thinking" - - // Source types inside an image or document block. - sourceBase64 = "base64" - - // cache_control's only currently-defined type. - cacheControlEphemeral = "ephemeral" - - // tool_choice types. - toolChoiceAuto = "auto" - toolChoiceAny = "any" - toolChoiceNone = "none" - toolChoiceTool = "tool" - - // Conversation roles. Anthropic has no system role — system content - // is the top-level `system` field, which is exactly why - // content.v1.Role has no SYSTEM value either. - roleUser = "user" - roleAssistant = "assistant" -) - -// Request is the JSON body of POST /v1/messages. -// -// This is Anthropic's schema, not a second Go representation of a -// PluggableHarness wire message — see this package's CLAUDE.md for why -// that distinction matters and why go-layout.md's one-representation rule -// is not in tension with it. -// -// Every optional field is a pointer or a slice with `omitempty` so an -// unset field is absent from the JSON rather than present as a zero -// value. That is not cosmetic: Anthropic rejects `temperature` outright on -// current models, and a `"temperature": 0` emitted for an unset override -// would turn every request into a 400. -type Request struct { - Model string `json:"model"` - MaxTokens int64 `json:"max_tokens"` - Messages []Message `json:"messages"` - Stream bool `json:"stream"` - - System []TextBlock `json:"system,omitempty"` - Tools []Tool `json:"tools,omitempty"` - ToolChoice *ToolChoice `json:"tool_choice,omitempty"` - StopSequences []string `json:"stop_sequences,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - Thinking *Thinking `json:"thinking,omitempty"` - OutputConfig *OutputConfig `json:"output_config,omitempty"` -} - -// Message is one turn in Anthropic's conversation array. -type Message struct { - Role string `json:"role"` - Content []Block `json:"content"` -} - -// Block is one content block. Anthropic discriminates on "type" and puts -// every variant's fields at the same level, so this is one flat struct -// with omitempty rather than a Go union — unmarshaling a discriminated -// union into a sum type would need a custom UnmarshalJSON per block, and -// buys nothing here because the adapter always knows which variant it is -// building or reading. -// -// Input is json.RawMessage rather than any: a tool call's arguments -// arrive from the kernel as a structpb.Struct and must reach the wire -// byte-for-byte identically on every turn, which means they are -// pre-serialized once by a deterministic marshaler and carried as raw -// bytes from there. See CLAUDE.md's protojson prohibition — this field is -// the reason that rule exists. -type Block struct { - Type string `json:"type"` - - // text - Text string `json:"text,omitempty"` - - // image, document - Source *Source `json:"source,omitempty"` - // document only; several vendors surface it to the model as a - // citation label. - Title string `json:"title,omitempty"` - - // tool_use - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - - // tool_result - ToolUseID string `json:"tool_use_id,omitempty"` - Content []Block `json:"content,omitempty"` - IsError bool `json:"is_error,omitempty"` - - // thinking - Thinking string `json:"thinking,omitempty"` - Signature string `json:"signature,omitempty"` - - // redacted_thinking - Data string `json:"data,omitempty"` - - CacheControl *CacheControl `json:"cache_control,omitempty"` -} - -// TextBlock is the one block shape Anthropic accepts inside the -// top-level `system` array. Modeled separately from Block because system -// content is text-only and a shared struct would invite a caller to set -// fields the vendor rejects there. -type TextBlock struct { - Type string `json:"type"` - Text string `json:"text"` - CacheControl *CacheControl `json:"cache_control,omitempty"` -} - -// Source carries inline bytes for an image or document block. -type Source struct { - Type string `json:"type"` - MediaType string `json:"media_type"` - Data string `json:"data"` -} - -// CacheControl is the vendor-native prompt-cache marker the kernel's -// CacheBreakpoint translates into. -type CacheControl struct { - Type string `json:"type"` -} - -// Tool is one tool declaration. -// -// InputSchema is json.RawMessage for the same determinism reason as -// Block.Input: the schema is derived from a proto message containing a -// map, and it must serialize identically on every turn or Anthropic's -// prefix cache misses on every request after the first. -type Tool struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - InputSchema json.RawMessage `json:"input_schema"` - CacheControl *CacheControl `json:"cache_control,omitempty"` -} - -// ToolChoice constrains whether and which tool the model must call. -type ToolChoice struct { - Type string `json:"type"` - // Name is set only when Type is "tool". - Name string `json:"name,omitempty"` -} - -// Thinking is the reasoning-control parameter. Type is "adaptive", -// "enabled", or "disabled"; BudgetTokens accompanies "enabled" only, and -// is rejected on models that dropped the manual budget form. -type Thinking struct { - Type string `json:"type"` - BudgetTokens *int64 `json:"budget_tokens,omitempty"` -} - -// OutputConfig carries the effort level. It is a sibling of `format` -// (structured outputs), which this adapter does not use. -type OutputConfig struct { - Effort string `json:"effort,omitempty"` -} - -// Usage is Anthropic's token accounting, as it appears on message_start -// and (cumulatively) on message_delta. -// -// Every count is a pointer because "the vendor did not report this" and -// "the vendor reported zero" are different facts the protocol preserves: -// model.Usage's cache and reasoning counters are pointers for exactly the -// same reason. -type Usage struct { - InputTokens *int64 `json:"input_tokens,omitempty"` - OutputTokens *int64 `json:"output_tokens,omitempty"` - CacheCreationInputTokens *int64 `json:"cache_creation_input_tokens,omitempty"` - CacheReadInputTokens *int64 `json:"cache_read_input_tokens,omitempty"` -} - -// APIError is the error envelope Anthropic returns on a non-2xx response -// and inside a mid-stream `error` SSE event. -type APIError struct { - Type string `json:"type"` - Error APIErrorBody `json:"error"` - RequestID string `json:"request_id,omitempty"` -} - -// APIErrorBody is the inner object of an APIError, carrying the vendor's -// own error taxonomy string. -type APIErrorBody struct { - Type string `json:"type"` - Message string `json:"message"` -} - -// Anthropic's error.type values, exhaustive as of the roster's sourcing -// date. Each maps to one HTTP status and to one -// modelv1.ModelErrorCategory — see classify.go for the table. -const ( - errInvalidRequest = "invalid_request_error" - errAuthentication = "authentication_error" - errBilling = "billing_error" - errPermission = "permission_error" - errNotFound = "not_found_error" - errConflict = "conflict_error" - errRequestTooLarge = "request_too_large" - errRateLimit = "rate_limit_error" - errAPI = "api_error" - errTimeout = "timeout_error" - errOverloaded = "overloaded_error" -) - -// Streamed SSE event type names, as they appear both on the `event:` line -// and as the JSON payload's own "type" field. -const ( - eventMessageStart = "message_start" - eventContentBlockStart = "content_block_start" - eventContentBlockDelta = "content_block_delta" - eventContentBlockStop = "content_block_stop" - eventMessageDelta = "message_delta" - eventMessageStop = "message_stop" - eventPing = "ping" - eventError = "error" -) - -// content_block_delta delta.type values. -const ( - deltaText = "text_delta" - deltaInputJSON = "input_json_delta" - deltaThinking = "thinking_delta" - deltaSignature = "signature_delta" -) - -// Anthropic's stop_reason values. -const ( - stopEndTurn = "end_turn" - stopToolUse = "tool_use" - stopMaxTokens = "max_tokens" - stopStopSequence = "stop_sequence" - stopRefusal = "refusal" - stopPauseTurn = "pause_turn" -) - -// StreamEvent is one decoded SSE event payload. Anthropic's events are a -// discriminated union on "type" with disjoint field sets, flattened here -// for the same reason as Block. -type StreamEvent struct { - Type string `json:"type"` - - // message_start - Message *StreamMessage `json:"message,omitempty"` - - // content_block_start / _delta / _stop - Index int64 `json:"index,omitempty"` - ContentBlock *Block `json:"content_block,omitempty"` - Delta *StreamDelta `json:"delta,omitempty"` - - // message_delta - Usage *Usage `json:"usage,omitempty"` - - // error - Error *APIErrorBody `json:"error,omitempty"` -} - -// StreamMessage is the partially-populated Message object carried by -// message_start, whose only field this adapter reads is Usage. -type StreamMessage struct { - ID string `json:"id"` - Model string `json:"model"` - Usage *Usage `json:"usage,omitempty"` -} - -// StreamDelta is the delta object on a content_block_delta, and — with a -// different field set — the top-level delta on a message_delta. Anthropic -// reuses the key for both, so one struct covers both. -type StreamDelta struct { - Type string `json:"type"` - - // text_delta - Text string `json:"text,omitempty"` - // input_json_delta - PartialJSON string `json:"partial_json,omitempty"` - // thinking_delta - Thinking string `json:"thinking,omitempty"` - // signature_delta - Signature string `json:"signature,omitempty"` - - // message_delta's own delta carries these two instead. - StopReason string `json:"stop_reason,omitempty"` - StopSequence string `json:"stop_sequence,omitempty"` -} diff --git a/internal/anthropic/provider.go b/internal/anthropic/provider.go deleted file mode 100644 index 349ac81..0000000 --- a/internal/anthropic/provider.go +++ /dev/null @@ -1,201 +0,0 @@ -package anthropic - -import ( - "context" - "log/slog" - "net/http" - "sync" - - "google.golang.org/protobuf/types/known/structpb" - - "github.com/pluggableharness/agent/internal/anthropic/catalog" - "github.com/pluggableharness/agent/internal/anthropic/messages" - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// Provider implements model.Provider against Anthropic's Messages API. -// -// The zero value is not usable; construct one with New. A Provider is -// safe for concurrent use: the kernel may issue GetCapabilities and -// StreamCompletion calls from several goroutines, and Configure races -// with neither because the guarded state is swapped under a lock. -type Provider struct { - // mu guards settings/client, which Configure replaces wholesale and - // every RPC reads. A RWMutex rather than an atomic.Pointer because - // the pair must change together — a client built from one settings - // value and a settings value from another configure call would be a - // silently inconsistent provider. - mu sync.RWMutex - settings settings - client *messages.Client - - logger *slog.Logger - // transport is injected by tests so the unit tier can drive a fake - // vendor without a network. Nil means http.DefaultTransport. - transport http.RoundTripper -} - -// Compile-time proof this type serves the three MUST RPCs and the SHOULD -// one. Render (MAY) is deliberately not implemented — the kernel's -// generic fallback renders this provider's payloads (plain text, tool -// calls, usage) perfectly well, and -// docs/specifications/model/protocol.md#render says as much. -var ( - _ model.Provider = (*Provider)(nil) - _ model.TokenCounter = (*Provider)(nil) -) - -// Option configures a Provider built by New. -type Option func(*Provider) - -// WithLogger sets the logger this provider and its HTTP client write to. -// Defaults to slog.Default(). -func WithLogger(logger *slog.Logger) Option { - return func(p *Provider) { p.logger = logger } -} - -// WithTransport overrides the HTTP transport the vendor client dials -// through. It exists for the unit tier, which drives a fake vendor via an -// injected http.RoundTripper rather than a real network — there is no -// agent.hcl attribute for it, deliberately, because an operator has no -// reason to replace the transport and every reason not to. -func WithTransport(rt http.RoundTripper) Option { - return func(p *Provider) { p.transport = rt } -} - -// New returns a Provider that is not yet configured. The kernel calls -// Configure before any completion, so construction takes no credentials. -func New(opts ...Option) *Provider { - p := &Provider{logger: slog.Default()} - for _, opt := range opts { - opt(p) - } - return p -} - -// Capabilities returns the compiled-in model roster and this provider's -// config schema, per docs/specifications/model/protocol.md#getcapabilities. -// -// No vendor call and no lock: the roster is pure data and the schema is -// rebuilt per call, so this stays cheap enough for the kernel to invoke -// before every routing decision, which the spec requires. -func (p *Provider) Capabilities(context.Context) (*model.Capabilities, error) { - schema, err := ConfigSchema() - if err != nil { - return nil, err - } - // No slash commands and no hook points: this provider contributes - // neither. Both fields are MAY-be-empty. - return model.NewCapabilities(catalog.Models(), schema) -} - -// Configure decodes and validates the provider's agent.hcl block and -// builds the vendor client from it. -// -// It fails immediately on a bad config rather than deferring to the first -// completion (docs/specifications/model/protocol.md#configure), and it -// never logs or echoes the API key — the DEBUG line below deliberately -// records only the endpoint and the timeout. -func (p *Provider) Configure(ctx context.Context, cfg *structpb.Struct) error { - s, err := decodeSettings(cfg) - if err != nil { - return err - } - - client := messages.NewClient(messages.ClientConfig{ - BaseURL: s.baseURL, - APIKey: s.apiKey, - Timeout: s.requestTimeout, - Transport: p.transport, - Logger: p.logger, - }) - - p.mu.Lock() - p.settings = s - p.client = client - p.mu.Unlock() - - p.logger.DebugContext(ctx, "anthropic: configured", - "base_url", s.baseURL, "request_timeout", s.requestTimeout) - return nil -} - -// StreamCompletion translates the kernel's request into Anthropic's wire -// format, streams the vendor's response, and writes every event to sink. -// -// Cancellation is normal control flow, not a failure: the returned -// context.Canceled travels up to pkg/model's statusFromErr, which turns -// it into a bare codes.Canceled rather than an application error. -func (p *Provider) StreamCompletion(ctx context.Context, req *modelv1.StreamCompletionRequest, sink *model.Sink) error { - client, err := p.readyClient("stream completion") - if err != nil { - return err - } - - spec, err := specByID(req.GetModelId()) - if err != nil { - return err - } - - vendorReq, err := messages.BuildRequest(req, spec) - if err != nil { - return err - } - - p.logger.DebugContext(ctx, "anthropic: stream completion: starting", - "model_id", req.GetModelId(), - "messages", len(req.GetMessages()), - "tools", len(req.GetTools())) - - return client.Stream(ctx, vendorReq, sink) -} - -// CountTokens satisfies model.TokenCounter using Anthropic's real -// tokenizer endpoint, so the kernel marks these counts exact instead of -// falling back to its ceil(bytes/4) heuristic -// (docs/specifications/kernel-callbacks.md#the-fallback-heuristic). -// -// docs/specifications/model/protocol.md#counttokens calls the fallback a -// genuine last resort rather than a normal operating path, which is why -// this is implemented even though it is only a SHOULD: Anthropic exposes -// exact counting over a cheap endpoint, so declining to use it would be -// choosing a worse number for no reason. -func (p *Provider) CountTokens(ctx context.Context, req *modelv1.CountTokensRequest) (int64, error) { - client, err := p.readyClient("count tokens") - if err != nil { - return 0, err - } - spec, err := specByID(req.GetModelId()) - if err != nil { - return 0, err - } - return client.CountTokens(ctx, req, spec) -} - -// readyClient returns the configured vendor client, or the structured -// error the kernel gets when it calls an RPC before Configure succeeded. -func (p *Provider) readyClient(rpc string) (*messages.Client, error) { - p.mu.RLock() - client := p.client - p.mu.RUnlock() - - if client == nil { - return nil, notConfiguredError(rpc) - } - return client, nil -} - -// specByID resolves a model_id against the catalog. -// -// The kernel resolves a model against GetCapabilities before dispatching, -// so a miss here means the kernel's view and the catalog's have diverged -// — a kernel/adapter bug, which is what invalid_request classifies. -func specByID(id string) (model.Spec, error) { - for _, spec := range catalog.Models() { - if spec.ID == id { - return spec, nil - } - } - return model.Spec{}, unknownModelError("resolve model", id) -} diff --git a/internal/anthropic/provider_e2e_test.go b/internal/anthropic/provider_e2e_test.go deleted file mode 100644 index 5fed6a2..0000000 --- a/internal/anthropic/provider_e2e_test.go +++ /dev/null @@ -1,267 +0,0 @@ -//go:build e2e - -// The e2e tier makes one real, billed call to Anthropic. It exists to -// catch the single class of bug every other tier is structurally blind -// to: our idea of the wire format having drifted from the vendor's. A -// recorded transcript can only ever confirm we agree with our own past -// reading of the docs. -// -// It is double-gated on ANTHROPIC_API_KEY *and* AGENT_E2E_LIVE=1. One -// gate would not be enough — a key is present in a lot of developer -// environments for unrelated reasons, and a test that silently spends -// money whenever it finds a credential is a test people learn to distrust. -// The second gate has to be set deliberately. -// -// Not part of the required CI checks. -package anthropic_test - -import ( - "context" - "os" - "strings" - "sync" - "testing" - "time" - - "github.com/pluggableharness/agent/internal/anthropic/catalog" - "github.com/pluggableharness/agent/internal/anthropic/messages" - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" - - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" -) - -// liveModelID is the cheapest model in the roster ($1/$5 per MTok). The -// point of this tier is wire-format agreement, which every model shares, -// so paying Opus rates for it would be spending money on nothing. -const liveModelID = "claude-haiku-4-5" - -// liveMaxOutputTokens is deliberately tiny. A handful of tokens is enough -// to prove the stream parses; anything more is just cost. -const liveMaxOutputTokens = 16 - -// requireLive skips unless both gates are set, and returns the key. -func requireLive(t *testing.T) string { - t.Helper() - - key := os.Getenv("ANTHROPIC_API_KEY") - if key == "" { - t.Skip("e2e: ANTHROPIC_API_KEY is not set") - } - if os.Getenv("AGENT_E2E_LIVE") != "1" { - t.Skip("e2e: AGENT_E2E_LIVE=1 is not set — refusing to spend money without an explicit opt-in") - } - return key -} - -// liveClient builds a client against the real endpoint. -func liveClient(t *testing.T) *messages.Client { - t.Helper() - return messages.NewClient(messages.ClientConfig{ - BaseURL: "https://api.anthropic.com", - APIKey: requireLive(t), - Timeout: 60 * time.Second, - }) -} - -// liveSpec returns the roster entry for liveModelID. -func liveSpec(t *testing.T) model.Spec { - t.Helper() - for _, spec := range catalog.Models() { - if spec.ID == liveModelID { - return spec - } - } - t.Fatalf("roster has no %q", liveModelID) - return model.Spec{} -} - -// TestLive_streamCompletion runs one real completion and asserts the -// stream produced text, a usage event with a plausible token count, and a -// terminal stop — i.e. that every stage of the wire format still parses -// against the live vendor. -func TestLive_streamCompletion(t *testing.T) { - client := liveClient(t) - spec := liveSpec(t) - - req := &modelv1.StreamCompletionRequest{ - ModelId: liveModelID, - Params: &modelv1.GenerationParams{MaxOutputTokens: ptr[int64](liveMaxOutputTokens)}, - Messages: []*contentv1.Message{{ - Id: "01JE2E", - Role: contentv1.Role_ROLE_USER, - Content: []*contentv1.ContentBlock{{ - Block: &contentv1.ContentBlock_Text{ - Text: &contentv1.TextBlock{Text: "Reply with exactly the word: pong"}, - }, - }}, - }}, - } - - vendorReq, err := messages.BuildRequest(req, spec) - if err != nil { - t.Fatalf("BuildRequest: %v", err) - } - - sink := &liveSink{} - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if err := client.Stream(ctx, vendorReq, sink); err != nil { - t.Fatalf("Stream against the live API: %v", err) - } - - if got := sink.text(); strings.TrimSpace(got) == "" { - t.Error("the live stream produced no text") - } - usage, ok := sink.lastUsage() - if !ok { - t.Fatal("the live stream produced no usage event — the kernel would have no cost to persist") - } - if usage.InputTokens <= 0 { - t.Errorf("input tokens = %d, want a positive count", usage.InputTokens) - } - if usage.OutputTokens <= 0 { - t.Errorf("output tokens = %d, want a positive count", usage.OutputTokens) - } - if !sink.stopped() { - t.Error("the live stream never reached a terminal stop") - } - if err := sink.streamError(); err != nil { - t.Errorf("the live stream reported an in-band error: %v", err) - } - // Only the live tier can prove the vendor actually publishes the - // header stream_start is built on — a recorded transcript would only - // confirm we agree with our own past reading of the docs. - if sink.providerRequestID() == "" { - t.Error("the live stream produced no provider request id — nothing to correlate a failure against") - } -} - -// TestLive_countTokens proves the tokenizer endpoint still answers in the -// shape we parse. This is the RPC that decides whether the kernel treats -// a count as exact or falls back to its ceil(bytes/4) heuristic, so a -// silent break here degrades every context-budget decision. -func TestLive_countTokens(t *testing.T) { - client := liveClient(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - req := &modelv1.CountTokensRequest{ - ModelId: liveModelID, - Messages: []*contentv1.Message{{ - Role: contentv1.Role_ROLE_USER, - Content: []*contentv1.ContentBlock{{ - Block: &contentv1.ContentBlock_Text{ - Text: &contentv1.TextBlock{Text: "The quick brown fox jumps over the lazy dog."}, - }, - }}, - }}, - } - - count, err := client.CountTokens(ctx, req, liveSpec(t)) - if err != nil { - t.Fatalf("CountTokens against the live API: %v", err) - } - if count <= 0 { - t.Errorf("count = %d, want a positive count", count) - } -} - -// liveSink records what the live stream produced. Hand-written rather -// than generated, and mutex-guarded because nothing promises the client -// drives it from the calling goroutine. -type liveSink struct { - mu sync.Mutex - textBuf strings.Builder - usage *model.Usage - stop bool - err *model.Error - requestID string -} - -var _ messages.EventSink = (*liveSink)(nil) - -// StreamStart records the vendor's request id, which a live-run failure -// report can quote when asking Anthropic about a specific request. -func (s *liveSink) StreamStart(providerRequestID string) error { - s.mu.Lock() - defer s.mu.Unlock() - s.requestID = providerRequestID - return nil -} - -func (s *liveSink) TextDelta(text string) error { - s.mu.Lock() - defer s.mu.Unlock() - s.textBuf.WriteString(text) - return nil -} - -func (s *liveSink) ThinkingDelta(string) error { return nil } -func (s *liveSink) ThinkingSignature([]byte) error { return nil } -func (s *liveSink) RedactedThinking([]byte) error { return nil } -func (s *liveSink) ToolCallStart(_, _ string) error { return nil } -func (s *liveSink) ToolCallDelta(_, _ string) error { return nil } -func (s *liveSink) ToolCallDone(string) error { return nil } - -func (s *liveSink) Usage(u model.Usage) error { - s.mu.Lock() - defer s.mu.Unlock() - s.usage = &u - return nil -} - -func (s *liveSink) Stop(modelv1.StopReason, string) error { - s.mu.Lock() - defer s.mu.Unlock() - s.stop = true - return nil -} - -func (s *liveSink) Error(modelErr *model.Error) error { - s.mu.Lock() - defer s.mu.Unlock() - s.err = modelErr - return nil -} - -func (s *liveSink) providerRequestID() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.requestID -} - -func (s *liveSink) text() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.textBuf.String() -} - -func (s *liveSink) lastUsage() (model.Usage, bool) { - s.mu.Lock() - defer s.mu.Unlock() - if s.usage == nil { - return model.Usage{}, false - } - return *s.usage, true -} - -func (s *liveSink) stopped() bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.stop -} - -func (s *liveSink) streamError() error { - s.mu.Lock() - defer s.mu.Unlock() - if s.err == nil { - return nil - } - return s.err -} - -// ptr returns a pointer to v, for the optional proto scalars. -func ptr[T any](v T) *T { return &v } diff --git a/internal/anthropic/provider_integration_test.go b/internal/anthropic/provider_integration_test.go deleted file mode 100644 index d603dc0..0000000 --- a/internal/anthropic/provider_integration_test.go +++ /dev/null @@ -1,562 +0,0 @@ -//go:build integration - -// Package anthropic_test's integration tier launches the real -// cmd/anthropic binary as a go-plugin subprocess and drives it through -// the generated ModelService client, exactly as the kernel does — against -// an httptest.Server replaying a hand-written Anthropic SSE transcript -// rather than the live vendor. -// -// This is the tier that proves the parts unit tests structurally cannot: -// that the binary handshakes, that Describe and GetCapabilities -// round-trip over the wire, that Configure's decoded Struct survives the -// schema-to-cty bridge's shape, that a full stream reaches a real -// *model.Sink, that a vendor error becomes the right grpc code, and that -// a mid-stream cancellation tears down cleanly. -package anthropic_test - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/structpb" - - "github.com/pluggableharness/agent/internal/eventbus" - "github.com/pluggableharness/agent/internal/kernelcallback" - "github.com/pluggableharness/agent/internal/log" - "github.com/pluggableharness/agent/internal/pluginruntime" - "github.com/pluggableharness/agent/internal/telemetry" - telemetryfake "github.com/pluggableharness/agent/internal/telemetry/drivers/fake" - "github.com/pluggableharness/agent/internal/telemetryrelay" - commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// The model this tier drives. A real roster entry rather than a fixture -// id, because GetCapabilities is served by the real catalog. -const testModelID = "claude-opus-5" - -// pluginBinary is the built cmd/anthropic every test here launches. -var pluginBinary string - -func TestMain(m *testing.M) { os.Exit(run(m)) } - -// run builds the plugin once and delegates to m.Run, so every cleanup -// happens before os.Exit — which skips deferred calls (go-style.md). -func run(m *testing.M) int { - // bin/ is the only sanctioned output path for a compiled artifact in - // this repo, test fixtures included — the project CLAUDE.md's - // "Build output — bin/ only, no exceptions". - binDir, err := filepath.Abs(filepath.Join("..", "..", "bin")) - if err != nil { - fmt.Fprintln(os.Stderr, "anthropic: integration: resolve bin/:", err) - return 1 - } - if err := os.MkdirAll(binDir, 0o750); err != nil { - fmt.Fprintln(os.Stderr, "anthropic: integration: mkdir bin/:", err) - return 1 - } - pluginBinary = filepath.Join(binDir, "anthropic-integration") - - cmd := exec.CommandContext(context.Background(), "go", "build", "-o", pluginBinary, "./../../cmd/anthropic") - if out, err := cmd.CombinedOutput(); err != nil { - fmt.Fprintf(os.Stderr, "anthropic: integration: build plugin: %v\n%s", err, out) - return 1 - } - defer func() { _ = os.Remove(pluginBinary) }() - - return m.Run() -} - -// transcriptToolUse is the worked event sequence from -// docs/specifications/model/examples.md#a-full-streamcompletion-event-sequence, -// written in Anthropic's own SSE format: text, then one tool call, then -// usage, then a tool_use stop. -const transcriptToolUse = `event: message_start -data: {"type":"message_start","message":{"id":"msg_int_1","type":"message","role":"assistant","model":"claude-opus-5","content":[],"stop_reason":null,"usage":{"input_tokens":412,"cache_read_input_tokens":128,"cache_creation_input_tokens":64,"output_tokens":1}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: ping -data: {"type":"ping"} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me check "}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"that file."}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: content_block_start -data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tc_1","name":"read_file","input":{}}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"main.go\"}"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":1} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":28}} - -event: message_stop -data: {"type":"message_stop"} - -` - -// TestPlugin_describeAndCapabilities proves the handshake, the Describe -// identity a dev_overrides binary depends on, and the real roster -// crossing the wire. -func TestPlugin_describeAndCapabilities(t *testing.T) { - client, _ := launchPlugin(t, staticTranscript(transcriptToolUse)) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - describe, err := client.Describe(ctx, &modelv1.DescribeRequest{}) - if err != nil { - t.Fatalf("Describe: %v", err) - } - producer := describe.GetProducer() - if producer.GetName() != "anthropic" { - t.Errorf("Describe name = %q, want %q", producer.GetName(), "anthropic") - } - if producer.GetCategory() != commonv1.Category_CATEGORY_MODEL { - t.Errorf("Describe category = %v, want CATEGORY_MODEL", producer.GetCategory()) - } - - caps, err := client.GetCapabilities(ctx, &modelv1.GetCapabilitiesRequest{}) - if err != nil { - t.Fatalf("GetCapabilities: %v", err) - } - - var found *modelv1.ModelSpec - for _, m := range caps.GetCapabilities().GetModels() { - if m.GetId() == testModelID { - found = m - } - } - if found == nil { - t.Fatalf("roster has no %q", testModelID) - } - if found.GetContextWindow() != 1_000_000 { - t.Errorf("context window = %d, want 1000000", found.GetContextWindow()) - } - if !found.GetSupportsToolUse() { - t.Error("model must declare tool-use support") - } - if got := found.GetPricing().GetTiers(); len(got) == 0 { - t.Error("pricing must carry at least one tier — the kernel bills from it") - } - - // The config schema rides along on GetCapabilities so the kernel - // knows what Configure expects before ever calling it. - var sawAPIKey bool - for _, attr := range caps.GetCapabilities().GetConfigSchema().GetAttributes() { - if attr.GetName() == "api_key" { - sawAPIKey = true - if !attr.GetSensitive() { - t.Error("api_key must be declared sensitive across the wire") - } - } - } - if !sawAPIKey { - t.Error("config schema did not reach the kernel") - } -} - -// TestPlugin_streamCompletionDeliversEveryEvent drives the worked -// transcript end to end and asserts the events a real *model.Sink -// produced, in order. -func TestPlugin_streamCompletionDeliversEveryEvent(t *testing.T) { - client, server := launchPlugin(t, staticTranscript(transcriptToolUse)) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - configure(t, ctx, client, server.URL) - - stream, err := client.StreamCompletion(ctx, sampleRequest()) - if err != nil { - t.Fatalf("StreamCompletion: %v", err) - } - - var ( - text strings.Builder - arguments strings.Builder - usage *modelv1.Usage - stop *modelv1.StreamEvent_Stop - toolID string - toolName string - toolDone bool - ) - for { - ev, err := stream.Recv() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - t.Fatalf("Recv: %v", err) - } - switch { - case ev.GetTextDelta() != nil: - text.WriteString(ev.GetTextDelta().GetText()) - case ev.GetToolCallStart() != nil: - toolID = ev.GetToolCallStart().GetId() - toolName = ev.GetToolCallStart().GetName() - case ev.GetToolCallDelta() != nil: - arguments.WriteString(ev.GetToolCallDelta().GetArgumentsFragment()) - case ev.GetToolCallDone() != nil: - toolDone = true - case ev.GetUsage() != nil: - usage = ev.GetUsage() - case ev.GetStop() != nil: - stop = ev.GetStop() - } - } - - if got, want := text.String(), "Let me check that file."; got != want { - t.Errorf("text = %q, want %q", got, want) - } - if toolID != "tc_1" || toolName != "read_file" { - t.Errorf("tool call = (%q, %q), want (tc_1, read_file)", toolID, toolName) - } - if got, want := arguments.String(), `{"path":"main.go"}`; got != want { - t.Errorf("accumulated arguments = %q, want %q", got, want) - } - if !toolDone { - t.Error("no tool_call_done — the kernel would never dispatch the call") - } - if stop == nil || stop.GetReason() != modelv1.StopReason_STOP_REASON_TOOL_USE { - t.Errorf("stop = %v, want STOP_REASON_TOOL_USE", stop) - } - - if usage == nil { - t.Fatal("no usage event — the kernel would have no cost to persist") - } - if usage.GetInputTokens() != 412 { - t.Errorf("input tokens = %d, want 412", usage.GetInputTokens()) - } - // Anthropic's message_delta usage is cumulative, so the adapter must - // merge rather than emit twice: input/cache counts come from - // message_start, output tokens from the last message_delta. - if usage.GetOutputTokens() != 28 { - t.Errorf("output tokens = %d, want 28", usage.GetOutputTokens()) - } - if usage.GetCacheReadTokens() != 128 { - t.Errorf("cache read tokens = %d, want 128", usage.GetCacheReadTokens()) - } - if usage.GetCacheWriteTokens() != 64 { - t.Errorf("cache write tokens = %d, want 64", usage.GetCacheWriteTokens()) - } -} - -// TestPlugin_vendorErrorMapsToStatusCode proves the taxonomy survives the -// plugin boundary: a vendor 429 must arrive as codes.ResourceExhausted, -// which is what tells internal/modelcall to back off rather than fail the -// turn (.claude/rules/grpc.md's mapping table). -func TestPlugin_vendorErrorMapsToStatusCode(t *testing.T) { - handler := func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("content-type", "application/json") - w.Header().Set("retry-after", "7") - w.WriteHeader(http.StatusTooManyRequests) - _, _ = io.WriteString(w, `{"type":"error","error":{"type":"rate_limit_error","message":"rate limit exceeded"},"request_id":"req_int_429"}`) - } - client, server := launchPlugin(t, handler) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - configure(t, ctx, client, server.URL) - - stream, err := client.StreamCompletion(ctx, sampleRequest()) - if err != nil { - t.Fatalf("StreamCompletion: %v", err) - } - - // The failure may surface either as a terminal in-band Error event or - // as the stream's status, depending on whether the adapter had - // already opened the stream. Both are legal per - // docs/specifications/model/data-types.md#streamevent; assert - // whichever arrives carries the right classification. - var inBand *modelv1.ModelError - for { - ev, recvErr := stream.Recv() - if recvErr != nil { - if errors.Is(recvErr, io.EOF) { - break - } - if got, want := status.Code(recvErr), codes.ResourceExhausted; got != want { - t.Fatalf("stream status code = %v, want %v (err: %v)", got, want, recvErr) - } - return - } - if e := ev.GetError(); e != nil { - inBand = e.GetError() - } - } - - if inBand == nil { - t.Fatal("a 429 produced neither an error status nor an error event") - } - if inBand.GetCategory() != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_RATE_LIMITED { - t.Errorf("category = %v, want RATE_LIMITED", inBand.GetCategory()) - } - if !inBand.GetRetryable() { - t.Error("a rate limit must be retryable — the kernel backs off and retries") - } - if got := inBand.GetRetryAfter().AsDuration(); got != 7*time.Second { - t.Errorf("retry_after = %v, want 7s (from the retry-after header)", got) - } -} - -// TestPlugin_cancellationIsCleanShutdown cancels mid-stream and asserts -// the plugin treats it as normal control flow: the stream ends promptly, -// the subprocess stays healthy enough to close cleanly, and nothing is -// logged at ERROR for the cancellation itself -// (docs/specifications/model/README.md#transport--lifecycle, -// .claude/rules/grpc.md). -func TestPlugin_cancellationIsCleanShutdown(t *testing.T) { - released := make(chan struct{}) - handler := func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("content-type", "text/event-stream") - w.WriteHeader(http.StatusOK) - flusher, ok := w.(http.Flusher) - if !ok { - t.Error("httptest response writer does not flush") - return - } - // Enough of a stream to prove the plugin is mid-flight, then hold - // it open until the client goes away. - _, _ = io.WriteString(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"usage\":{\"input_tokens\":5}}}\n\n") - _, _ = io.WriteString(w, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n") - _, _ = io.WriteString(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"thinking\"}}\n\n") - flusher.Flush() - <-r.Context().Done() - close(released) - } - - client, server, logs := launchPluginWithLogs(t, handler) - configureCtx, configureCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer configureCancel() - configure(t, configureCtx, client, server.URL) - - streamCtx, cancel := context.WithCancel(context.Background()) - stream, err := client.StreamCompletion(streamCtx, sampleRequest()) - if err != nil { - cancel() - t.Fatalf("StreamCompletion: %v", err) - } - - // Read until the first real delta so the cancel genuinely lands - // mid-stream rather than before the request was even issued. - for { - ev, recvErr := stream.Recv() - if recvErr != nil { - cancel() - t.Fatalf("Recv before cancel: %v", recvErr) - } - if ev.GetTextDelta() != nil { - break - } - } - - cancel() - - // The stream must end, and it must end as a cancellation rather than - // as an application error. - _, recvErr := stream.Recv() - if recvErr == nil { - t.Fatal("stream did not end after cancellation") - } - if code := status.Code(recvErr); code != codes.Canceled && !errors.Is(recvErr, context.Canceled) { - t.Errorf("post-cancel status = %v, want Canceled", code) - } - - select { - case <-released: - case <-time.After(5 * time.Second): - t.Error("the plugin did not release the upstream HTTP request after cancellation") - } - - for _, rec := range logs.records() { - if rec.level >= slog.LevelError { - t.Errorf("cancellation produced an ERROR log, which trains operators to ignore real failures: %q", rec.msg) - } - } -} - -// sampleRequest is the canonical request every streaming test sends — -// the worked example's shape from -// docs/specifications/model/examples.md. -func sampleRequest() *modelv1.StreamCompletionRequest { - return &modelv1.StreamCompletionRequest{ - ModelId: testModelID, - Messages: []*contentv1.Message{{ - Id: "01JINTEGRATION", - Role: contentv1.Role_ROLE_USER, - Content: []*contentv1.ContentBlock{{ - Block: &contentv1.ContentBlock_Text{ - Text: &contentv1.TextBlock{Text: "What's in main.go?"}, - }, - }}, - }}, - CallContext: &commonv1.CallContext{ - SessionId: "01JSESSION", - TurnId: "01JTURN", - }, - } -} - -// configure calls Configure with a fake key and the test server's URL. -func configure(t *testing.T, ctx context.Context, client modelv1.ModelServiceClient, baseURL string) { - t.Helper() - - cfg, err := structpb.NewStruct(map[string]any{ - "api_key": "sk-ant-integration-not-a-real-key", - // The loopback carve-out in validateBaseURL is what makes this - // legal — an httptest.Server is plain http on 127.0.0.1. - "base_url": baseURL, - }) - if err != nil { - t.Fatalf("structpb.NewStruct: %v", err) - } - if _, err := client.Configure(ctx, &modelv1.ConfigureRequest{Config: cfg}); err != nil { - t.Fatalf("Configure: %v", err) - } -} - -// staticTranscript serves body as an SSE stream for any request. -func staticTranscript(body string) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("content-type", "text/event-stream") - w.WriteHeader(http.StatusOK) - _, _ = io.WriteString(w, body) - } -} - -// launchPlugin builds the launch and discards the captured logs. -func launchPlugin(t *testing.T, handler http.HandlerFunc) (modelv1.ModelServiceClient, *httptest.Server) { - t.Helper() - client, server, _ := launchPluginWithLogs(t, handler) - return client, server -} - -// launchPluginWithLogs starts the fake vendor, launches the real plugin -// binary through internal/pluginruntime exactly as the kernel does, and -// returns the dispensed category client alongside the captured kernel-side -// log records. -func launchPluginWithLogs(t *testing.T, handler http.HandlerFunc) (modelv1.ModelServiceClient, *httptest.Server, *logCapture) { - t.Helper() - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - capture := &logCapture{} - logger := slog.New(capture) - - producer := &commonv1.ProducerRef{ - Category: commonv1.Category_CATEGORY_MODEL, - Name: "anthropic", - Version: "0.0.0", - } - - backend := telemetryfake.New() - prov, err := telemetry.New(context.Background(), telemetry.DefaultConfig, backend, nil) - if err != nil { - t.Fatalf("telemetry.New: %v", err) - } - t.Cleanup(func() { - if err := prov.Shutdown(context.Background()); err != nil { - t.Errorf("telemetry.Shutdown: %v", err) - } - }) - - bus := eventbus.New() - t.Cleanup(func() { _ = bus.Close() }) - - callback := kernelcallback.NewServer(kernelcallback.Config{ - Log: log.NewServer(logger), - Producer: producer, - Telemetry: prov, - TelemetryRelay: telemetryrelay.New(backend.RelayedSpans), - Bus: bus, - Logger: logger, - }) - - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - pl, err := pluginruntime.Launch(ctx, pluginruntime.Config{ - BinaryPath: pluginBinary, - Producer: producer, - Callback: callback, - Telemetry: prov, - Logger: logger, - }) - if err != nil { - t.Fatalf("Launch: %v", err) - } - t.Cleanup(func() { - closeCtx, closeCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer closeCancel() - if err := pl.Close(closeCtx); err != nil { - t.Errorf("Close: %v", err) - } - }) - - client, ok := pl.Dispensed().(modelv1.ModelServiceClient) - if !ok { - t.Fatalf("Dispensed() is %T, want modelv1.ModelServiceClient", pl.Dispensed()) - } - return client, server, capture -} - -// logRecord is the flattened slog record the cancellation test inspects. -type logRecord struct { - level slog.Level - msg string -} - -// logCapture is a concurrency-safe slog.Handler fake — the plugin's own -// log callbacks arrive on a background goroutine, concurrently with the -// test's assertions (go-testing.md: fakes, not mocking frameworks). -type logCapture struct { - mu sync.Mutex - recs []logRecord -} - -func (c *logCapture) Enabled(context.Context, slog.Level) bool { return true } - -func (c *logCapture) Handle(_ context.Context, r slog.Record) error { - c.mu.Lock() - defer c.mu.Unlock() - c.recs = append(c.recs, logRecord{level: r.Level, msg: r.Message}) - return nil -} - -func (c *logCapture) WithAttrs([]slog.Attr) slog.Handler { return c } -func (c *logCapture) WithGroup(string) slog.Handler { return c } - -func (c *logCapture) records() []logRecord { - c.mu.Lock() - defer c.mu.Unlock() - return append([]logRecord(nil), c.recs...) -} diff --git a/internal/anthropic/provider_test.go b/internal/anthropic/provider_test.go deleted file mode 100644 index 6b9c93b..0000000 --- a/internal/anthropic/provider_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package anthropic - -import ( - "context" - "errors" - "net/http" - "strings" - "testing" - - "google.golang.org/protobuf/types/known/structpb" - - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - "github.com/pluggableharness/agent/pkg/model" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" -) - -// countReq builds the CountTokensRequest shape the provider's CountTokens -// RPC now takes: a whole request, with loose text carried as one user -// message (model/protocol.md#counttokens). -func countReq(text, modelID string) *modelv1.CountTokensRequest { - return &modelv1.CountTokensRequest{ - ModelId: modelID, - Messages: []*contentv1.Message{{ - Role: contentv1.Role_ROLE_USER, - Content: []*contentv1.ContentBlock{{ - Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: text}}, - }}, - }}, - } -} - -// TestCapabilities_servesTheRosterAndSchema checks the one RPC the kernel -// calls before every routing decision. It must not touch the network and -// must not need Configure to have run — a provider that could only -// describe itself after being configured would deadlock the kernel, which -// needs the config schema in order to configure it. -func TestCapabilities_servesTheRosterAndSchema(t *testing.T) { - t.Parallel() - - p := New() - caps, err := p.Capabilities(context.Background()) - if err != nil { - t.Fatalf("Capabilities on an unconfigured provider: %v", err) - } - if len(caps.Models) == 0 { - t.Fatal("no models advertised") - } - if caps.ConfigSchema == nil { - t.Fatal("no config schema advertised — the kernel could never configure this provider") - } - - var sawOpus5 bool - for _, m := range caps.Models { - if m.ID == "claude-opus-5" { - sawOpus5 = true - } - } - if !sawOpus5 { - t.Error("the roster is missing claude-opus-5") - } -} - -// TestProvider_implementsTokenCounter pins the SHOULD from -// docs/specifications/model/protocol.md#counttokens. Anthropic exposes a -// real tokenizer endpoint, so declining to implement it would mean the -// kernel silently falling back to ceil(bytes/4) for every context-budget -// decision against this provider. -func TestProvider_implementsTokenCounter(t *testing.T) { - t.Parallel() - - if _, ok := any(New()).(model.TokenCounter); !ok { - t.Fatal("Provider must implement model.TokenCounter") - } -} - -// TestProvider_doesNotImplementRenderer records a deliberate choice -// rather than an omission: Render is a MAY, and the kernel's generic -// fallback renders this provider's payloads (text, tool calls, usage) -// correctly. If someone implements it later they should delete this test -// consciously, not discover it failing. -func TestProvider_doesNotImplementRenderer(t *testing.T) { - t.Parallel() - - if _, ok := any(New()).(model.Renderer); ok { - t.Fatal("Render is now implemented — delete this test and say why in the commit") - } -} - -// TestRPCs_beforeConfigureAreRejected covers the ordering the kernel is -// supposed to guarantee. Reaching an RPC unconfigured is a kernel bug, so -// it must be a clean invalid_request rather than a nil dereference. -func TestRPCs_beforeConfigureAreRejected(t *testing.T) { - t.Parallel() - - p := New() - - // A nil sink is safe here precisely because the guard returns before - // anything touches it — which is the property being asserted. - streamErr := p.StreamCompletion(context.Background(), - &modelv1.StreamCompletionRequest{ModelId: "claude-opus-5"}, nil) - assertInvalidRequest(t, streamErr, "not configured") - - _, countErr := p.CountTokens(context.Background(), countReq("hello", "claude-opus-5")) - assertInvalidRequest(t, countErr, "not configured") -} - -// TestConfigure_rejectsABadConfig proves Configure fails immediately -// rather than deferring to the first completion, per -// docs/specifications/model/protocol.md#configure. -func TestConfigure_rejectsABadConfig(t *testing.T) { - t.Parallel() - - p := New() - empty, err := structpb.NewStruct(map[string]any{}) - if err != nil { - t.Fatalf("structpb.NewStruct: %v", err) - } - - assertInvalidRequest(t, p.Configure(context.Background(), empty), "api_key is required") - - // A failed Configure must leave the provider unconfigured rather than - // half-configured: a later completion should report the ordering - // problem, not fire a request with an empty credential. - assertInvalidRequest(t, - p.StreamCompletion(context.Background(), &modelv1.StreamCompletionRequest{ModelId: "claude-opus-5"}, nil), - "not configured") -} - -// TestStreamCompletion_rejectsAnUnknownModel covers the case where the -// kernel's view of the roster and the catalog's have diverged. -func TestStreamCompletion_rejectsAnUnknownModel(t *testing.T) { - t.Parallel() - - p := configuredProvider(t) - - err := p.StreamCompletion(context.Background(), - &modelv1.StreamCompletionRequest{ModelId: "claude-does-not-exist"}, nil) - assertInvalidRequest(t, err, "unknown model") - - _, countErr := p.CountTokens(context.Background(), countReq("hello", "claude-does-not-exist")) - assertInvalidRequest(t, countErr, "unknown model") -} - -// TestConfigure_neverLeaksTheKey guards -// docs/specifications/model/protocol.md#configure's secret rule at the -// provider level, complementing config_test.go's coverage of the decoder: -// no error surfaced by any RPC may contain the credential. -func TestConfigure_neverLeaksTheKey(t *testing.T) { - t.Parallel() - - const secret = "sk-ant-provider-level-secret" - p := New(WithTransport(failingTransport{})) - - cfg, err := structpb.NewStruct(map[string]any{"api_key": secret}) - if err != nil { - t.Fatalf("structpb.NewStruct: %v", err) - } - if err := p.Configure(context.Background(), cfg); err != nil { - t.Fatalf("Configure: %v", err) - } - - // Force a transport-level failure and confirm the key is absent from - // whatever comes back. - _, countErr := p.CountTokens(context.Background(), countReq("hello", "claude-opus-5")) - if countErr == nil { - t.Fatal("expected the failing transport to produce an error") - } - if strings.Contains(countErr.Error(), secret) { - t.Fatalf("the api key leaked into an RPC error: %q", countErr.Error()) - } -} - -// configuredProvider returns a Provider configured against a transport -// that always fails, which is enough for every test here — none of them -// exercises a successful vendor round trip, which is the integration -// tier's job. -func configuredProvider(t *testing.T) *Provider { - t.Helper() - - p := New(WithTransport(failingTransport{})) - cfg, err := structpb.NewStruct(map[string]any{"api_key": "sk-ant-unit-test"}) - if err != nil { - t.Fatalf("structpb.NewStruct: %v", err) - } - if err := p.Configure(context.Background(), cfg); err != nil { - t.Fatalf("Configure: %v", err) - } - return p -} - -// failingTransport fails every request, so a unit test can reach the -// provider's own guards without a network. -type failingTransport struct{} - -func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) { - return nil, errors.New("unit test: the network is not available") -} - -// assertInvalidRequest checks err is a *model.Error classified -// invalid_request, non-retryable, and mentioning want. -func assertInvalidRequest(t *testing.T, err error, want string) { - t.Helper() - - if err == nil { - t.Fatalf("expected an error mentioning %q, got nil", want) - } - var modelErr *model.Error - if !errors.As(err, &modelErr) { - t.Fatalf("error is %T, want a *model.Error the kernel can classify: %v", err, err) - } - if modelErr.Category != modelv1.ModelErrorCategory_MODEL_ERROR_CATEGORY_INVALID_REQUEST { - t.Errorf("category = %v, want INVALID_REQUEST", modelErr.Category) - } - if modelErr.Retryable { - t.Error("an adapter/kernel bug is not retryable") - } - if !strings.Contains(modelErr.Message, want) { - t.Errorf("message %q does not mention %q", modelErr.Message, want) - } -} diff --git a/internal/kernel/bringup.go b/internal/kernel/bringup.go index ab6e9cd..f6965ad 100644 --- a/internal/kernel/bringup.go +++ b/internal/kernel/bringup.go @@ -9,10 +9,15 @@ import ( "os" "time" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + "github.com/pluggableharness/agent/internal/config" "github.com/pluggableharness/agent/internal/eventbus" "github.com/pluggableharness/agent/internal/hookdispatch" + "github.com/pluggableharness/agent/internal/kernelcallback" "github.com/pluggableharness/agent/internal/log" + "github.com/pluggableharness/agent/internal/metadata" + "github.com/pluggableharness/agent/internal/pending" "github.com/pluggableharness/agent/internal/plugincache" "github.com/pluggableharness/agent/internal/pluginhost" catalogplugin "github.com/pluggableharness/agent/internal/providercatalog/drivers/plugin" @@ -65,9 +70,51 @@ func bringUp(ctx context.Context, opts Options) (*kernel, error) { if err := k.buildHooks(ctx); err != nil { return k, err } + if err := k.startFrontends(ctx); err != nil { + return k, err + } return k, nil } +// isFrontend is the category predicate that splits bring-up into its two +// Configure passes. +func isFrontend(c commonv1.Category) bool { + return c == commonv1.Category_CATEGORY_FRONTEND +} + +// startFrontends closes the loop bringUp's phasing exists for: build the +// session runner, install the frontend host behind it, and only then let +// frontend plugins run their Configure handlers. +// +// A frontend calls CreateSession from inside Configure, so every +// collaborator that call reaches — the catalog, the hook chains, the +// runner, the host slot — has to be live before this point. Everything +// above in bringUp is that prerequisite, in dependency order. +// +// In -prompt mode the frontend pass is skipped entirely. A frontend's +// Configure is what makes it seize the terminal and start a session of +// its own, and a non-interactive run has neither a terminal to give away +// nor anything for a second session to do. Frontends are still prepared, +// so they appear in the registry and the catalog exactly as any other +// provider — only the step with side effects is withheld. +func (k *kernel) startFrontends(ctx context.Context) error { + runner, err := k.newRunner(ctx) + if err != nil { + return err + } + k.runner = runner + k.hostSlot.Set(newFrontendHost(k, runner, k.plans, k.inter)) + + if k.opts.Prompt != "" { + k.logger.DebugContext(ctx, "kernel: non-interactive run, frontends left unconfigured") + return nil + } + if err := k.supervisor.Configure(ctx, isFrontend); err != nil { + return fmt.Errorf("kernel: start frontends: %w", err) + } + return nil +} + // loadConfig parses agent.hcl under a throwaway, fully-disabled telemetry // Provider: config.LoadFile requires one, and the real Provider's own // configuration is inside the file being loaded. @@ -199,6 +246,11 @@ func (k *kernel) openStores(ctx context.Context) error { k.sessions = sessionstate.NewTable() k.plugins = pluginhost.NewRegistry() k.tokens = tokencount.NewCounter(k.plugins, k.telem, k.logger) + k.metadata = metadata.NewStore() + k.deltas = kernelcallback.NewDeltaHub() + k.hostSlot = &kernelcallback.HostSlot{} + k.plans = pending.NewPlanBridge() + k.inter = pending.NewInteractiveBridge() k.logger.DebugContext(ctx, "kernel: stores open", "sessions_dir", k.paths.SessionsDir, @@ -250,6 +302,9 @@ func (k *kernel) startPlugins(ctx context.Context) error { Scopes: k.scopes, Sessions: k.sessions, Tokens: k.tokens, + Metadata: k.metadata, + Deltas: k.deltas, + HostSlot: k.hostSlot, ProviderBodies: k.cfg.ProviderBodies, ProviderEnv: k.cfg.ProviderEnv, BusSubscribeQueueBound: k.cfg.Settings.EventBus.SubscribeQueueBound, @@ -263,7 +318,18 @@ func (k *kernel) startPlugins(ctx context.Context) error { // halfway through it leaves earlier plugins running. k.supervisor = sup - if err := sup.Start(ctx); err != nil { + // Two phases, deliberately. Prepare launches, describes, and + // registers every provider — which is what makes each one's real + // category known, including a dev override's, whose lock file has + // none. Configure then runs for everything except frontends, so the + // catalog below is built over configured tool and model plugins + // while frontends are still holding at their Configure handler. The + // frontend pass runs from startFrontends, once the host they call + // back into exists. + if err := sup.Prepare(ctx); err != nil { + return fmt.Errorf("kernel: start plugins: %w", err) + } + if err := sup.Configure(ctx, func(c commonv1.Category) bool { return !isFrontend(c) }); err != nil { return fmt.Errorf("kernel: start plugins: %w", err) } diff --git a/internal/kernel/frontendhost.go b/internal/kernel/frontendhost.go new file mode 100644 index 0000000..a4be201 --- /dev/null +++ b/internal/kernel/frontendhost.go @@ -0,0 +1,250 @@ +package kernel + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" + + "github.com/pluggableharness/agent/internal/kernelcallback" + "github.com/pluggableharness/agent/internal/pending" + "github.com/pluggableharness/agent/internal/plandecision" + "github.com/pluggableharness/agent/internal/session" +) + +// frontendHost implements kernelcallback.FrontendHost over a session.Runner +// and the pending bridges that complete plan/interactive resolutions. +type frontendHost struct { + k *kernel + + mu sync.Mutex + runner *session.Runner + handles map[string]*session.Handle // sessionID -> handle + plans *pending.PlanBridge + inter *pending.InteractiveBridge +} + +func newFrontendHost(k *kernel, runner *session.Runner, plans *pending.PlanBridge, inter *pending.InteractiveBridge) *frontendHost { + return &frontendHost{ + k: k, + runner: runner, + handles: make(map[string]*session.Handle), + plans: plans, + inter: inter, + } +} + +var _ kernelcallback.FrontendHost = (*frontendHost)(nil) + +func (h *frontendHost) CreateSession(ctx context.Context, req *kernelv1.CreateSessionRequest) (*sessionv1.SessionInfo, error) { + profile := "" + if req.Profile != nil { + profile = *req.Profile + } + wd := h.k.opts.WorkingDirectory + if req.WorkingDirectory != nil && *req.WorkingDirectory != "" { + wd = *req.WorkingDirectory + } + initial := "" + if req.InitialPrompt != nil { + initial = *req.InitialPrompt + } + + // Open with empty prompt so history is empty; Submit the initial + // prompt below when present (avoids double-seeding). + handle, err := h.runner.Open(ctx, session.Spec{ + Profile: profile, + WorkingDirectory: wd, + }) + if err != nil { + return nil, err + } + + h.mu.Lock() + h.handles[handle.SessionID()] = handle + h.mu.Unlock() + + if initial != "" { + // The first turn outlives this RPC, so it detaches from ctx's + // cancellation — but WithoutCancel, never Background: a fresh root + // context severs trace parentage silently, so every session opened + // with a prompt would lose its first turn from the trace + // (logging-telemetry.md, go-architecture.md's WithoutCancel rule). + submitCtx := context.WithoutCancel(ctx) + go func() { + if _, err := handle.Submit(submitCtx, []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: initial}}}, + }); err != nil { + h.k.logger.ErrorContext(submitCtx, "kernel: initial prompt submit failed", + "session_id", handle.SessionID(), "err", err) + } + }() + } + + return handle.Info(ctx) +} + +func (h *frontendHost) SubmitInput(ctx context.Context, sessionID string, content []*contentv1.ContentBlock) (string, error) { + handle, err := h.handle(sessionID) + if err != nil { + return "", err + } + return handle.Submit(ctx, content) +} + +func (h *frontendHost) Interrupt(_ context.Context, sessionID string) error { + handle, err := h.handle(sessionID) + if err != nil { + return err + } + handle.Interrupt() + return nil +} + +func (h *frontendHost) ListSessions(ctx context.Context, req *kernelv1.ListSessionsRequest) ([]*sessionv1.SessionInfo, error) { + metas, err := h.k.store.List(ctx) + if err != nil { + return nil, err + } + out := make([]*sessionv1.SessionInfo, 0, len(metas)) + for _, m := range metas { + if req.GetRootsOnly() && m.ParentSessionID != "" { + continue + } + if req.ParentSessionId != nil && m.ParentSessionID != *req.ParentSessionId { + continue + } + if req.Status != nil && m.Status != *req.Status { + continue + } + info := &sessionv1.SessionInfo{ + SessionId: m.SessionID, + Profile: m.Profile, + Status: m.Status, + Depth: int32(m.Depth), // #nosec G115 + StartedAt: timestamppb.New(m.StartedAt), + } + if m.ParentSessionID != "" { + info.ParentSessionId = &m.ParentSessionID + } + if m.EndedAt != nil { + info.EndedAt = timestamppb.New(*m.EndedAt) + } + out = append(out, info) + } + return out, nil +} + +func (h *frontendHost) GetSessionState(ctx context.Context, sessionID string) (*sessionv1.SessionState, error) { + handle, err := h.handle(sessionID) + if err != nil { + return nil, err + } + return handle.State(ctx) +} + +func (h *frontendHost) ResolvePlanDecision(_ context.Context, req *kernelv1.ResolvePlanDecisionRequest) error { + terminal, err := pending.ClientDecisionToTerminal(req.GetDecision()) + if err != nil { + return status.Error(codes.InvalidArgument, err.Error()) + } + scope := req.GetScope() + if scope == planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_UNSPECIFIED { + scope = planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE + } + dec := plandecision.Decision{ + Decision: terminal, + Scope: scope, + CorrectedInput: req.CorrectedInput, + DecidedBy: "operator", + } + if err := h.plans.Answer(req.GetSessionId(), req.GetPlanItemId(), dec); err != nil { + if errors.Is(err, pending.ErrNoWaiter) || errors.Is(err, pending.ErrAlreadyResolved) { + return status.Error(codes.FailedPrecondition, err.Error()) + } + return err + } + return nil +} + +func (h *frontendHost) ResolveInteractive(_ context.Context, req *kernelv1.ResolveInteractiveRequest) error { + if err := h.inter.Complete(req.GetCallId(), req.GetResponse()); err != nil { + if errors.Is(err, pending.ErrNoWaiter) || errors.Is(err, pending.ErrAlreadyResolved) { + return status.Error(codes.FailedPrecondition, err.Error()) + } + return err + } + return nil +} + +func (h *frontendHost) InvokeSlashCommand(ctx context.Context, req *kernelv1.InvokeSlashCommandRequest) error { + // Expand to a user message so the model (or a future dedicated slash + // path) can act. Dedicated slashcommand.Invoke is a later refinement. + text := "/" + req.GetName() + if args := req.GetArgs(); args != "" { + text += " " + args + } + _, err := h.SubmitInput(ctx, req.GetSessionId(), []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: text}}}, + }) + return err +} + +func (h *frontendHost) TriggerAction(ctx context.Context, req *kernelv1.TriggerActionRequest) error { + if h.k.catalog == nil { + return status.Error(codes.FailedPrecondition, "kernel: catalog not ready") + } + th, err := h.k.catalog.Tool(req.GetProvider(), req.GetToolName()) + if err != nil { + return status.Errorf(codes.NotFound, "kernel: tool %s.%s: %v", req.GetProvider(), req.GetToolName(), err) + } + input := req.GetArgs() + if input == nil { + input, _ = structpb.NewStruct(nil) + } + stream, err := th.Client.Invoke(ctx, &toolv1.InvokeRequest{ + Call: &toolv1.ToolCall{ + Id: req.GetNodeId(), + ToolName: req.GetToolName(), + Arguments: input, + CallContext: &commonv1.CallContext{ + SessionId: req.GetSessionId(), + }, + }, + }) + if err != nil { + return fmt.Errorf("kernel: trigger action invoke: %w", err) + } + for { + _, recvErr := stream.Recv() + if recvErr != nil { + if errors.Is(recvErr, io.EOF) { + return nil + } + return fmt.Errorf("kernel: trigger action stream: %w", recvErr) + } + } +} + +func (h *frontendHost) handle(sessionID string) (*session.Handle, error) { + h.mu.Lock() + defer h.mu.Unlock() + handle, ok := h.handles[sessionID] + if !ok { + return nil, status.Errorf(codes.NotFound, "kernel: no interactive handle for session %s", sessionID) + } + return handle, nil +} diff --git a/internal/kernel/kernel.go b/internal/kernel/kernel.go index ae50088..0e31aee 100644 --- a/internal/kernel/kernel.go +++ b/internal/kernel/kernel.go @@ -9,12 +9,16 @@ import ( "os" "time" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" "github.com/pluggableharness/agent/internal/config" "github.com/pluggableharness/agent/internal/eventbus" "github.com/pluggableharness/agent/internal/hookdispatch" + "github.com/pluggableharness/agent/internal/kernelcallback" "github.com/pluggableharness/agent/internal/log" + "github.com/pluggableharness/agent/internal/metadata" + "github.com/pluggableharness/agent/internal/pending" "github.com/pluggableharness/agent/internal/pluginhost" "github.com/pluggableharness/agent/internal/providercatalog" "github.com/pluggableharness/agent/internal/session" @@ -57,9 +61,12 @@ type Options struct { // BuiltinDefaultProfile when no such block is declared. Profile string - // Prompt is the single non-interactive prompt this session runs. - // Required — this build has no frontend and therefore no way to ask - // for one. + // Prompt is a single non-interactive prompt to run to completion. + // + // Empty selects frontend-hosted mode instead: the kernel brings every + // provider up, installs the frontend host, and waits while a frontend + // plugin drives sessions over the kernel callback channel. That mode + // requires a frontend provider to be loaded; see ErrNoFrontend. Prompt string // LogLevel overrides settings.log_level when non-empty. Accepts the @@ -80,16 +87,25 @@ type Options struct { Stderr io.Writer } -// ErrNoPrompt reports an Options with no Prompt. This build runs exactly -// one non-interactive session, so there is nowhere else a prompt could -// come from. -var ErrNoPrompt = errors.New("kernel: a prompt is required") +// ErrNoFrontend reports frontend-hosted mode (an Options with no Prompt) +// against a configuration that loads no frontend provider. There would be +// nothing to drive a session and nothing to wait for, so the kernel would +// sit idle forever rather than doing anything an operator could observe — +// which is worse than saying so. +var ErrNoFrontend = errors.New("kernel: no prompt and no frontend provider: pass a prompt, or declare a frontend in required_providers") + +// frontendPollInterval is how often hosted mode checks whether the +// frontend subprocess is still alive. +// +// A poll rather than a notification because hashicorp/go-plugin exposes no +// completion channel, and 250ms because this only bounds how long the +// kernel outlives a closed UI — it is not on any latency path. Every real +// signal (input, renders, deltas) travels the callback channel, which is +// event-driven. +const frontendPollInterval = 250 * time.Millisecond // normalize fills Options' defaults, returning the resolved copy. func (o Options) normalize() (Options, error) { - if o.Prompt == "" { - return Options{}, ErrNoPrompt - } if o.WorkingDirectory == "" { wd, err := os.Getwd() if err != nil { @@ -142,6 +158,18 @@ type kernel struct { // built here and the per-session *statebackend.Session internal/session // creates for itself. See turnstack.go. sink *sessionSink + + // runner drives sessions in both modes. Built during bring-up rather + // than in run, because the frontend host wraps it and a frontend + // calls back from inside its own Configure. + runner *session.Runner + + // Frontend state-surface process-wide collaborators. + metadata *metadata.Store + deltas *kernelcallback.DeltaHub + hostSlot *kernelcallback.HostSlot + plans *pending.PlanBridge + inter *pending.InteractiveBridge } // Run loads config, launches every resolved plugin, runs exactly one @@ -167,18 +195,69 @@ func Run(ctx context.Context, opts Options) error { return errors.Join(upErr, k.shutdown(ctx)) } - runErr := k.runSession(ctx) + runErr := k.run(ctx) return errors.Join(runErr, k.shutdown(ctx)) } -// runSession builds the session driver over the process-wide collaborators -// and runs exactly one session. +// run picks a mode: one non-interactive session when a prompt was given, +// otherwise wait while a frontend plugin drives. +// +// The session runner and the frontend host are both built during +// bring-up rather than here, in both modes. A frontend calls back the +// moment its Configure handler runs, so the host has to exist before +// that — see bringUp's startFrontends. +func (k *kernel) run(ctx context.Context) error { + if k.opts.Prompt != "" { + return k.runSession(ctx, k.runner) + } + return k.hostFrontend(ctx) +} + +// hostFrontend blocks while a frontend plugin drives sessions over the +// callback channel, returning when the operator closes it or ctx is +// canceled. Sessions are created, fed, and ended entirely through +// frontendHost — this function starts none of them. +// +// It returns nil for both exits: an operator quitting the UI and an +// operator pressing Ctrl-C are ordinary ways to finish, not failures. +func (k *kernel) hostFrontend(ctx context.Context) error { + frontends := k.plugins.ByCategory(commonv1.Category_CATEGORY_FRONTEND) + if len(frontends) == 0 { + return ErrNoFrontend + } + + names := make([]string, 0, len(frontends)) + for _, f := range frontends { + names = append(names, f.LocalName) + } + k.logger.InfoContext(ctx, "kernel: hosting frontend", "providers", names) + + ticker := time.NewTicker(frontendPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + k.logger.InfoContext(ctx, "kernel: canceled, stopping") + return nil + case <-ticker.C: + for _, f := range frontends { + if f.Exited() { + k.logger.InfoContext(ctx, "kernel: frontend exited, stopping", "provider", f.LocalName) + return nil + } + } + } + } +} + +// newRunner builds the session driver over the process-wide collaborators. // // The Runner (and the turn stack under it) is constructed per session, not // per process: internal/plangate and internal/tooldispatch share one // *circuitbreaker.Breaker scoped to a single session, and the gate needs // that session's id at construction. See internal/session's CLAUDE.md. -func (k *kernel) runSession(ctx context.Context) error { +func (k *kernel) newRunner(_ context.Context) (*session.Runner, error) { stack := newTurnStack(k) runner, err := session.New(session.Config{ //nolint:contextcheck // session.New takes no context by design; nothing is dropped @@ -196,9 +275,14 @@ func (k *kernel) runSession(ctx context.Context) error { Logger: k.logger, }) if err != nil { - return fmt.Errorf("kernel: session driver: %w", err) + return nil, fmt.Errorf("kernel: session driver: %w", err) } + return runner, nil +} +// runSession runs exactly one non-interactive session and writes its final +// message to stdout — the -prompt path. +func (k *kernel) runSession(ctx context.Context, runner *session.Runner) error { result, err := runner.Run(ctx, session.Spec{ Profile: k.opts.Profile, Prompt: k.opts.Prompt, diff --git a/internal/kernel/kernel_test.go b/internal/kernel/kernel_test.go index d06c8d2..7daec93 100644 --- a/internal/kernel/kernel_test.go +++ b/internal/kernel/kernel_test.go @@ -15,11 +15,18 @@ import ( "github.com/pluggableharness/agent/internal/doomloop" ) -func TestOptionsNormalize_requiresAPrompt(t *testing.T) { +// An empty Prompt is no longer an error: it selects frontend-hosted mode. +// Whether that mode is actually usable depends on a frontend provider being +// loaded, which normalize cannot know and hostFrontend reports instead. +func TestOptionsNormalize_emptyPromptSelectsHostedMode(t *testing.T) { t.Parallel() - if _, err := (Options{}).normalize(); !errors.Is(err, ErrNoPrompt) { - t.Fatalf("normalize with no prompt = %v, want ErrNoPrompt", err) + got, err := (Options{}).normalize() + if err != nil { + t.Fatalf("normalize with no prompt = %v, want nil", err) + } + if got.Prompt != "" { + t.Errorf("Prompt = %q, want empty", got.Prompt) } } @@ -212,6 +219,21 @@ func TestRun_unknownLogLevelIsRejected(t *testing.T) { } } +// TestRun_hostedModeWithoutAFrontendIsRejected asserts the kernel refuses +// to sit idle. Without a prompt it waits for a frontend to drive it, so a +// configuration that loads none would block forever with nothing an +// operator could see happening — the one outcome worse than an error. +func TestRun_hostedModeWithoutAFrontendIsRejected(t *testing.T) { + project := newProject(t, minimalConfig) + opts := testOptions(t, project, &stringSink{}, &stringSink{}) + opts.Prompt = "" + + err := Run(context.Background(), opts) + if !errors.Is(err, ErrNoFrontend) { + t.Fatalf("Run with no prompt and no frontend = %v, want ErrNoFrontend", err) + } +} + // TestRun_noModelProviderFailsTheSession asserts a kernel with no plugins // at all still brings up, runs, and reports the real reason it cannot // proceed — the profile has no model to route to. diff --git a/internal/kernel/resolvers.go b/internal/kernel/resolvers.go new file mode 100644 index 0000000..e768f5e --- /dev/null +++ b/internal/kernel/resolvers.go @@ -0,0 +1,84 @@ +package kernel + +import ( + "context" + + "github.com/pluggableharness/agent/internal/interactive" + "github.com/pluggableharness/agent/internal/interactive/drivers/unattended" + "github.com/pluggableharness/agent/internal/pending" + "github.com/pluggableharness/agent/internal/plandecision" + "github.com/pluggableharness/agent/internal/plandecision/drivers/autoallow" +) + +// planResolver routes ASK decisions to the pending PlanBridge when the +// session was opened interactively (frontend host has a handle), and to +// autoallow for CLI-driven Runner.Run sessions. +type planResolver struct { + k *kernel + bridge *pending.PlanBridge + fallback plandecision.Resolver +} + +func newPlanResolver(k *kernel) (plandecision.Resolver, error) { + fb, err := autoallow.New(autoallow.Config{ + AcknowledgeUnsafeAutoAllow: true, + Logger: k.logger, + Telemetry: k.telem, + }) + if err != nil { + return nil, err + } + return &planResolver{k: k, bridge: k.plans, fallback: fb}, nil +} + +func (p *planResolver) Resolve(ctx context.Context, req plandecision.Request) (plandecision.Decision, error) { + if p.interactive(req.SessionID) { + return p.bridge.Resolve(ctx, req) + } + return p.fallback.Resolve(ctx, req) +} + +func (p *planResolver) interactive(sessionID string) bool { + host, ok := p.k.hostSlot.Get().(*frontendHost) + if !ok || host == nil { + return false + } + host.mu.Lock() + defer host.mu.Unlock() + _, ok = host.handles[sessionID] + return ok +} + +// interactiveResolver routes interactive tool calls similarly. +type interactiveResolver struct { + k *kernel + bridge *pending.InteractiveBridge + fallback interactive.Resolver +} + +func newInteractiveResolver(k *kernel) interactive.Resolver { + return &interactiveResolver{ + k: k, + bridge: k.inter, + fallback: unattended.New(k.logger, k.telem), + } +} + +func (r *interactiveResolver) Resolve(ctx context.Context, req interactive.Request) (interactive.Response, error) { + // Interactive requests lack session id on the Request type; prefer the + // bridge whenever any interactive handle exists (frontend attached). + if r.anyInteractive() { + return r.bridge.Resolve(ctx, req) + } + return r.fallback.Resolve(ctx, req) +} + +func (r *interactiveResolver) anyInteractive() bool { + host, ok := r.k.hostSlot.Get().(*frontendHost) + if !ok || host == nil { + return false + } + host.mu.Lock() + defer host.mu.Unlock() + return len(host.handles) > 0 +} diff --git a/internal/kernel/turnstack.go b/internal/kernel/turnstack.go index 48e5d22..57107ae 100644 --- a/internal/kernel/turnstack.go +++ b/internal/kernel/turnstack.go @@ -10,9 +10,7 @@ import ( "github.com/pluggableharness/agent/internal/circuitbreaker" "github.com/pluggableharness/agent/internal/contextassembly" "github.com/pluggableharness/agent/internal/hookdispatch" - "github.com/pluggableharness/agent/internal/interactive/drivers/unattended" "github.com/pluggableharness/agent/internal/modelcall" - "github.com/pluggableharness/agent/internal/plandecision/drivers/autoallow" "github.com/pluggableharness/agent/internal/plangate" "github.com/pluggableharness/agent/internal/retrypolicy" "github.com/pluggableharness/agent/internal/session" @@ -20,6 +18,8 @@ import ( "github.com/pluggableharness/agent/internal/statebackend" "github.com/pluggableharness/agent/internal/tooldispatch" "github.com/pluggableharness/agent/internal/turn" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" ) // Circuit-breaker thresholds, and why these numbers. @@ -226,21 +226,32 @@ func (k *kernel) newTurnDriver(ctx context.Context, sessionID string) (session.T Logger: k.logger, }) + var onDelta func(sessionID, targetID, text string) + if k.deltas != nil { + onDelta = func(sessionID, targetID, text string) { + k.deltas.Publish(&kernelv1.TokenDelta{ + SessionId: sessionID, + TargetId: targetID, + Text: text, + }) + } + } + caller := modelcall.New(modelcall.Config{ - Retry: retrypolicy.FromConfig(k.cfg.Settings.Retry, sessionMaxRetries), - Events: k.sink, - Telemetry: k.telem, - Logger: k.logger, + Retry: retrypolicy.FromConfig(k.cfg.Settings.Retry, sessionMaxRetries), + Events: k.sink, + Telemetry: k.telem, + Logger: k.logger, + OnTextDelta: onDelta, }) + planRes, err := newPlanResolver(k) //nolint:contextcheck + if err != nil { + return nil, fmt.Errorf("kernel: plan-decision resolver: %w", err) + } + scheduler := tooldispatch.New(tooldispatch.Config{ //nolint:contextcheck // see newTurnDriver's note - // TRACKED DEVIATION: no frontend exists to ask a human anything, - // so every interactive-kind call is refused rather than answered - // with a fabricated response. See - // internal/interactive/drivers/unattended's package doc for why - // this one needs no acknowledgment flag while its autoallow - // sibling below does. - Interactive: unattended.New(k.logger, k.telem), + Interactive: newInteractiveResolver(k), Breaker: breaker, Events: k.sink, DefaultTimeout: toolTimeout(k.cfg.Settings.DefaultToolTimeoutMS), @@ -248,39 +259,6 @@ func (k *kernel) newTurnDriver(ctx context.Context, sessionID string) (session.T Logger: k.logger, }) - // ------------------------------------------------------------------ - // TRACKED DEVIATION FROM A SPEC MUST — READ BEFORE CHANGING. - // - // plan-apply-gate.md#decision-semantics requires an `ask` decision to - // emit a permission-request event and BLOCK that plan item until a - // frontend returns a human's verdict. This kernel has no frontend - // attach path, so it cannot satisfy that MUST. Until one exists, - // every `ask` item is auto-approved by the operator-approved stand-in - // below, and the acknowledgment is spelled out at this call site - // precisely so no code review can miss it. - // - // Consequence, in plain terms: a session run by this build executes - // mutating tool calls that a human was supposed to approve, and its - // plan_items.decided_by audit rows say exactly that, per item. - // autoallow.New logs one WARN at construction and one per resolution. - // - // The fix is not to soften anything here: it is the real - // internal/plandecision/drivers/frontend resolver, which stops this - // driver being the default the moment it lands. - // ------------------------------------------------------------------ - resolver, err := autoallow.New(autoallow.Config{ //nolint:contextcheck // see newTurnDriver's note - AcknowledgeUnsafeAutoAllow: true, - Logger: k.logger, - Telemetry: k.telem, - }) - if err != nil { - return nil, fmt.Errorf("kernel: plan-decision resolver: %w", err) - } - k.logger.WarnContext(ctx, "kernel: UNSAFE plan-decision resolver active: every ask-decision plan item will be auto-approved with no human in the loop", - "session_id", sessionID, - "decided_by", autoallow.DecidedBy, - "reason", "no frontend attach path exists in this build") - gate := plangate.New(plangate.Config{ //nolint:contextcheck // see newTurnDriver's note SessionID: sessionID, Rules: k.cfg.Policies, @@ -289,7 +267,7 @@ func (k *kernel) newTurnDriver(ctx context.Context, sessionID string) (session.T // itself. Do not write a second one — see internal/turn's // CLAUDE.md on why plangate keeps its own types. Hooks: turn.GateHooks{Dispatcher: k.hooks}, - Resolver: resolver, + Resolver: planRes, Breaker: breaker, Events: k.sink, Tools: k.catalog, diff --git a/internal/kernelcallback/deltas.go b/internal/kernelcallback/deltas.go new file mode 100644 index 0000000..a81ac55 --- /dev/null +++ b/internal/kernelcallback/deltas.go @@ -0,0 +1,77 @@ +package kernelcallback + +import ( + "context" + "sync" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +// DeltaHub fans live TokenDeltas out to StreamDeltas subscribers, per +// session. Out-of-band with respect to the event bus: no topic matching, +// no shared subscriber queue. Per-stream FIFO only; not durable. +// +// Safe for concurrent use. A nil *DeltaHub is treated as "no deltas" by +// Server.StreamDeltas. +type DeltaHub struct { + mu sync.Mutex + subs map[string]map[chan *kernelv1.TokenDelta]struct{} // sessionID -> set of chans +} + +// NewDeltaHub returns an empty hub. +func NewDeltaHub() *DeltaHub { + return &DeltaHub{subs: make(map[string]map[chan *kernelv1.TokenDelta]struct{})} +} + +// Publish delivers delta to every active StreamDeltas subscriber for its +// session. Non-blocking: a slow subscriber that has filled its buffer +// drops that one delta (live-only, best-effort within a stream — the +// finished text still arrives via ReadEvents as a RenderTree). +func (h *DeltaHub) Publish(delta *kernelv1.TokenDelta) { + if h == nil || delta == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.subs[delta.GetSessionId()] { + select { + case ch <- delta: + default: + } + } +} + +// Serve registers a subscriber for sessionID and forwards deltas to +// stream until ctx is canceled. +func (h *DeltaHub) Serve(ctx context.Context, sessionID string, stream kernelv1.KernelCallbackService_StreamDeltasServer) error { + ch := make(chan *kernelv1.TokenDelta, 64) + h.mu.Lock() + if h.subs[sessionID] == nil { + h.subs[sessionID] = make(map[chan *kernelv1.TokenDelta]struct{}) + } + h.subs[sessionID][ch] = struct{}{} + h.mu.Unlock() + + defer func() { + h.mu.Lock() + delete(h.subs[sessionID], ch) + if len(h.subs[sessionID]) == 0 { + delete(h.subs, sessionID) + } + h.mu.Unlock() + }() + + for { + select { + case <-ctx.Done(): + return nil + case delta, ok := <-ch: + if !ok { + return nil + } + if err := stream.Send(delta); err != nil { + return err + } + } + } +} diff --git a/internal/kernelcallback/frontend.go b/internal/kernelcallback/frontend.go new file mode 100644 index 0000000..896dcdd --- /dev/null +++ b/internal/kernelcallback/frontend.go @@ -0,0 +1,357 @@ +package kernelcallback + +import ( + "context" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/pluggableharness/agent/internal/eventbus" + "github.com/pluggableharness/agent/internal/metadata" + "github.com/pluggableharness/agent/internal/sessionscope" + "github.com/pluggableharness/agent/internal/telemetry" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + metadatav1 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" +) + +// GetSessionState implements the GetSessionState RPC: the fixed-schema +// "where am I" snapshot for one authorized session. +func (s *Server) GetSessionState(ctx context.Context, req *kernelv1.GetSessionStateRequest) (*kernelv1.GetSessionStateResult, error) { + ctx, span := s.telemetry.StartKernelCallbackGetSession(ctx, req.GetSessionId(), s.producer) + var err error + defer func() { telemetry.EndSpan(span, err) }() + + s.logger.DebugContext(ctx, "kernelcallback: get_session_state", "session_id", req.GetSessionId()) + + if _, err = s.authorizedSession(ctx, req.GetSessionId()); err != nil { + s.logger.WarnContext(ctx, "kernelcallback: get_session_state: rejected", "err", err) + return nil, err + } + + if s.host() != nil { + state, hostErr := s.host().GetSessionState(ctx, req.GetSessionId()) + if hostErr == nil && state != nil { + return &kernelv1.GetSessionStateResult{State: state}, nil + } + // Fall through to live-table assembly when host has no handle. + } + + live, err := s.authorizedSession(ctx, req.GetSessionId()) + if err != nil { + return nil, err + } + meta, metaErr := live.Meta(ctx) + if metaErr != nil { + err = status.Errorf(codes.Internal, "kernelcallback: get_session_state: %v", metaErr) + return nil, err + } + totalCostUSD, costErr := live.TotalCostUSD(ctx) + if costErr != nil { + err = status.Errorf(codes.Internal, "kernelcallback: get_session_state: cost: %v", costErr) + return nil, err + } + + info := &sessionv1.SessionInfo{ + SessionId: meta.SessionID, + Profile: meta.Profile, + Status: meta.Status, + Depth: int32(meta.Depth), // #nosec G115 -- session-tree depth is a tiny bounded count + StartedAt: timestamppb.New(meta.StartedAt), + } + if meta.ParentSessionID != "" { + info.ParentSessionId = &meta.ParentSessionID + } + if meta.EndedAt != nil { + info.EndedAt = timestamppb.New(*meta.EndedAt) + } + if totalCostUSD != 0 { + info.CostUsd = &totalCostUSD + } + + elapsed := time.Since(meta.StartedAt) + if meta.EndedAt != nil { + elapsed = meta.EndedAt.Sub(meta.StartedAt) + } + + state := &sessionv1.SessionState{ + Info: info, + Elapsed: durationpb.New(elapsed), + } + return &kernelv1.GetSessionStateResult{State: state}, nil +} + +// PublishMetadata upserts a MetadataBlock, stamps producer/liveness, and +// republishes on topic kernel.metadata. +func (s *Server) PublishMetadata(ctx context.Context, req *kernelv1.PublishMetadataRequest) (*kernelv1.PublishMetadataResult, error) { + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + if s.metadata == nil { + return nil, status.Error(codes.FailedPrecondition, "kernelcallback: metadata store not configured") + } + stored, err := s.metadata.Publish(req.GetSessionId(), s.producer, req.GetBlock()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "kernelcallback: publish metadata: %v", err) + } + s.publishMetadataBus(ctx, stored) + return &kernelv1.PublishMetadataResult{Block: stored}, nil +} + +// RetractMetadata flips a block to DISCONNECTED and republishes it. +func (s *Server) RetractMetadata(ctx context.Context, req *kernelv1.RetractMetadataRequest) (*kernelv1.RetractMetadataResult, error) { + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + if s.metadata == nil { + return nil, status.Error(codes.FailedPrecondition, "kernelcallback: metadata store not configured") + } + stored, err := s.metadata.Retract(req.GetSessionId(), req.GetBlockId()) + if err != nil { + return nil, status.Errorf(codes.NotFound, "kernelcallback: retract metadata: %v", err) + } + s.publishMetadataBus(ctx, stored) + return &kernelv1.RetractMetadataResult{Block: stored}, nil +} + +// ListMetadata returns every known MetadataBlock for an authorized session. +func (s *Server) ListMetadata(ctx context.Context, req *kernelv1.ListMetadataRequest) (*kernelv1.ListMetadataResult, error) { + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + if s.metadata == nil { + return &kernelv1.ListMetadataResult{}, nil + } + return &kernelv1.ListMetadataResult{Blocks: s.metadata.List(req.GetSessionId())}, nil +} + +func (s *Server) publishMetadataBus(ctx context.Context, block *metadatav1.MetadataBlock) { + if s.bus == nil || block == nil { + return + } + payload, err := proto.Marshal(block) + if err != nil { + s.logger.WarnContext(ctx, "kernelcallback: metadata bus marshal failed", "err", err) + return + } + if err := s.bus.Publish(ctx, eventbus.Event{Topic: metadata.Topic, Payload: payload}); err != nil { + s.logger.WarnContext(ctx, "kernelcallback: metadata bus publish failed", "err", err) + } +} + +// SubmitInput submits operator content as the next turn. +func (s *Server) SubmitInput(ctx context.Context, req *kernelv1.SubmitInputRequest) (*kernelv1.SubmitInputResult, error) { + if s.host() == nil { + return nil, status.Error(codes.Unimplemented, "kernelcallback: SubmitInput not implemented") + } + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + turnID, err := s.host().SubmitInput(ctx, req.GetSessionId(), req.GetContent()) + if err != nil { + return nil, status.Errorf(codes.Internal, "kernelcallback: submit input: %v", err) + } + return &kernelv1.SubmitInputResult{TurnId: turnID}, nil +} + +// ResolvePlanDecision answers a pending plan item. +func (s *Server) ResolvePlanDecision(ctx context.Context, req *kernelv1.ResolvePlanDecisionRequest) (*kernelv1.ResolvePlanDecisionResult, error) { + if s.host() == nil { + return nil, status.Error(codes.Unimplemented, "kernelcallback: ResolvePlanDecision not implemented") + } + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + if err := s.host().ResolvePlanDecision(ctx, req); err != nil { + return nil, mapHostErr(err) + } + return &kernelv1.ResolvePlanDecisionResult{}, nil +} + +// ResolveInteractive answers a pending interactive-kind tool call. +func (s *Server) ResolveInteractive(ctx context.Context, req *kernelv1.ResolveInteractiveRequest) (*kernelv1.ResolveInteractiveResult, error) { + if s.host() == nil { + return nil, status.Error(codes.Unimplemented, "kernelcallback: ResolveInteractive not implemented") + } + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + if err := s.host().ResolveInteractive(ctx, req); err != nil { + return nil, mapHostErr(err) + } + return &kernelv1.ResolveInteractiveResult{}, nil +} + +// Interrupt cancels the running turn for a session. +func (s *Server) Interrupt(ctx context.Context, req *kernelv1.InterruptRequest) (*kernelv1.InterruptResult, error) { + if s.host() == nil { + return nil, status.Error(codes.Unimplemented, "kernelcallback: Interrupt not implemented") + } + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + if err := s.host().Interrupt(ctx, req.GetSessionId()); err != nil { + return nil, status.Errorf(codes.Internal, "kernelcallback: interrupt: %v", err) + } + return &kernelv1.InterruptResult{}, nil +} + +// CreateSession creates a new session and auto-attaches the caller. +func (s *Server) CreateSession(ctx context.Context, req *kernelv1.CreateSessionRequest) (*kernelv1.CreateSessionResult, error) { + if s.host() == nil { + return nil, status.Error(codes.Unimplemented, "kernelcallback: CreateSession not implemented") + } + info, err := s.host().CreateSession(ctx, req) + if err != nil { + return nil, status.Errorf(codes.Internal, "kernelcallback: create session: %v", err) + } + // Auto-attach the calling frontend. + release := s.scopes.Grant(sessionscope.KeyFor(s.producer), info.GetSessionId()) + s.trackAttach(info.GetSessionId(), release) + return &kernelv1.CreateSessionResult{Info: info}, nil +} + +// AttachSession grants the calling producer a scope for session_id when the +// session is live, and returns its SessionInfo. +func (s *Server) AttachSession(ctx context.Context, req *kernelv1.AttachSessionRequest) (*kernelv1.AttachSessionResult, error) { + sessionID := req.GetSessionId() + if sessionID == "" { + return nil, status.Error(codes.InvalidArgument, "kernelcallback: attach session: session_id is required") + } + live, ok := s.sessions.Get(sessionID) + if !ok { + return nil, status.Error(codes.NotFound, "kernelcallback: attach session: session not found") + } + release := s.scopes.Grant(sessionscope.KeyFor(s.producer), sessionID) + s.trackAttach(sessionID, release) + + meta, err := live.Meta(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "kernelcallback: attach session: %v", err) + } + info := &sessionv1.SessionInfo{ + SessionId: meta.SessionID, + Profile: meta.Profile, + Status: meta.Status, + Depth: int32(meta.Depth), // #nosec G115 -- session-tree depth is a tiny bounded count + StartedAt: timestamppb.New(meta.StartedAt), + } + if meta.ParentSessionID != "" { + info.ParentSessionId = &meta.ParentSessionID + } + return &kernelv1.AttachSessionResult{Info: info}, nil +} + +// ResumeSession currently behaves like AttachSession; re-open semantics +// for terminal sessions land with the session runner. +func (s *Server) ResumeSession(ctx context.Context, req *kernelv1.ResumeSessionRequest) (*kernelv1.ResumeSessionResult, error) { + res, err := s.AttachSession(ctx, &kernelv1.AttachSessionRequest{SessionId: req.GetSessionId()}) + if err != nil { + return nil, err + } + return &kernelv1.ResumeSessionResult{Info: res.GetInfo()}, nil +} + +// DetachSession drops one outstanding grant for session_id that this +// Server's AttachSession previously took. +func (s *Server) DetachSession(_ context.Context, req *kernelv1.DetachSessionRequest) (*kernelv1.DetachSessionResult, error) { + sessionID := req.GetSessionId() + if sessionID == "" { + return nil, status.Error(codes.InvalidArgument, "kernelcallback: detach session: session_id is required") + } + s.releaseAttach(sessionID) + return &kernelv1.DetachSessionResult{}, nil +} + +// ListSessions returns a filtered session summary list. +func (s *Server) ListSessions(ctx context.Context, req *kernelv1.ListSessionsRequest) (*kernelv1.ListSessionsResult, error) { + if s.host() == nil { + return nil, status.Error(codes.Unimplemented, "kernelcallback: ListSessions not implemented") + } + sessions, err := s.host().ListSessions(ctx, req) + if err != nil { + return nil, status.Errorf(codes.Internal, "kernelcallback: list sessions: %v", err) + } + return &kernelv1.ListSessionsResult{Sessions: sessions}, nil +} + +// InvokeSlashCommand dispatches a slash command. +func (s *Server) InvokeSlashCommand(ctx context.Context, req *kernelv1.InvokeSlashCommandRequest) (*kernelv1.InvokeSlashCommandResult, error) { + if s.host() == nil { + return nil, status.Error(codes.Unimplemented, "kernelcallback: InvokeSlashCommand not implemented") + } + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + if err := s.host().InvokeSlashCommand(ctx, req); err != nil { + return nil, mapHostErr(err) + } + return &kernelv1.InvokeSlashCommandResult{}, nil +} + +// TriggerAction dispatches an ActionNode activation. +func (s *Server) TriggerAction(ctx context.Context, req *kernelv1.TriggerActionRequest) (*kernelv1.TriggerActionResult, error) { + if s.host() == nil { + return nil, status.Error(codes.Unimplemented, "kernelcallback: TriggerAction not implemented") + } + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return nil, err + } + if err := s.host().TriggerAction(ctx, req); err != nil { + return nil, mapHostErr(err) + } + return &kernelv1.TriggerActionResult{}, nil +} + +func mapHostErr(err error) error { + if err == nil { + return nil + } + // Preserve FailedPrecondition / NotFound style when the host surfaces them. + if st, ok := status.FromError(err); ok { + return st.Err() + } + return status.Errorf(codes.FailedPrecondition, "kernelcallback: %v", err) +} + +// StreamDeltas is the live-only token fast path. When no DeltaHub is +// configured the stream stays open until ctx is canceled (no deltas). +func (s *Server) StreamDeltas(req *kernelv1.StreamDeltasRequest, stream kernelv1.KernelCallbackService_StreamDeltasServer) error { + ctx := stream.Context() + if _, err := s.authorizedSession(ctx, req.GetSessionId()); err != nil { + return err + } + if s.deltas == nil { + <-ctx.Done() + return nil + } + return s.deltas.Serve(ctx, req.GetSessionId(), stream) +} + +// trackAttach records the release func AttachSession obtained so +// DetachSession can drop exactly one grant. +func (s *Server) trackAttach(sessionID string, release func()) { + s.attachMu.Lock() + defer s.attachMu.Unlock() + s.attachReleases[sessionID] = append(s.attachReleases[sessionID], release) +} + +// releaseAttach pops and runs one tracked release for sessionID, if any. +func (s *Server) releaseAttach(sessionID string) { + s.attachMu.Lock() + defer s.attachMu.Unlock() + list := s.attachReleases[sessionID] + if len(list) == 0 { + return + } + release := list[len(list)-1] + s.attachReleases[sessionID] = list[:len(list)-1] + if len(s.attachReleases[sessionID]) == 0 { + delete(s.attachReleases, sessionID) + } + release() +} diff --git a/internal/kernelcallback/frontend_test.go b/internal/kernelcallback/frontend_test.go new file mode 100644 index 0000000..ac76557 --- /dev/null +++ b/internal/kernelcallback/frontend_test.go @@ -0,0 +1,108 @@ +package kernelcallback + +import ( + "context" + "testing" + + "github.com/pluggableharness/agent/internal/bounds" + "github.com/pluggableharness/agent/internal/metadata" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + metabuilders "github.com/pluggableharness/agent/pkg/metadata" + metadatav1 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" + + "google.golang.org/grpc/codes" +) + +func TestServer_PublishListRetractMetadata(t *testing.T) { + t.Parallel() + + store := metadata.NewStore() + f := newTestServer(t, testProducer(), func(cfg *Config) { + cfg.Metadata = store + }) + sessionID, release := newLiveSession(t, f, bounds.Limits{}) + t.Cleanup(release) + + block := metabuilders.KeyValue("branch", "branch", "main") + pub, err := f.server.PublishMetadata(context.Background(), &kernelv1.PublishMetadataRequest{ + SessionId: sessionID, + Block: block, + }) + if err != nil { + t.Fatalf("PublishMetadata: %v", err) + } + if pub.GetBlock().GetProducer().GetName() != testProducer().GetName() { + t.Errorf("producer = %v", pub.GetBlock().GetProducer()) + } + if pub.GetBlock().GetLiveness() != metadatav1.Liveness_LIVENESS_LIVE { + t.Errorf("liveness = %v", pub.GetBlock().GetLiveness()) + } + + list, err := f.server.ListMetadata(context.Background(), &kernelv1.ListMetadataRequest{SessionId: sessionID}) + if err != nil { + t.Fatalf("ListMetadata: %v", err) + } + if len(list.GetBlocks()) != 1 { + t.Fatalf("List = %d blocks", len(list.GetBlocks())) + } + + ret, err := f.server.RetractMetadata(context.Background(), &kernelv1.RetractMetadataRequest{ + SessionId: sessionID, + BlockId: "branch", + }) + if err != nil { + t.Fatalf("RetractMetadata: %v", err) + } + if ret.GetBlock().GetLiveness() != metadatav1.Liveness_LIVENESS_DISCONNECTED { + t.Errorf("retract liveness = %v", ret.GetBlock().GetLiveness()) + } + list, err = f.server.ListMetadata(context.Background(), &kernelv1.ListMetadataRequest{SessionId: sessionID}) + if err != nil { + t.Fatal(err) + } + if len(list.GetBlocks()) != 1 { + t.Fatalf("retract deleted block") + } +} + +func TestServer_PublishMetadata_requiresAuthz(t *testing.T) { + t.Parallel() + + f := newTestServer(t, testProducer(), func(cfg *Config) { + cfg.Metadata = metadata.NewStore() + }) + _, err := f.server.PublishMetadata(context.Background(), &kernelv1.PublishMetadataRequest{ + SessionId: "no-such-session", + Block: metabuilders.KeyValue("x", "k", "v"), + }) + assertCode(t, err, codes.PermissionDenied) +} + +func TestServer_GetSessionState(t *testing.T) { + t.Parallel() + + f := newTestServer(t, testProducer()) + sessionID, release := newLiveSession(t, f, bounds.Limits{}) + t.Cleanup(release) + + res, err := f.server.GetSessionState(context.Background(), &kernelv1.GetSessionStateRequest{ + SessionId: sessionID, + }) + if err != nil { + t.Fatalf("GetSessionState: %v", err) + } + if res.GetState().GetInfo().GetSessionId() != sessionID { + t.Errorf("session_id = %q, want %q", res.GetState().GetInfo().GetSessionId(), sessionID) + } + if res.GetState().GetElapsed() == nil { + t.Error("elapsed is nil") + } +} + +func TestDeltaHub_PublishNoSubscribers(t *testing.T) { + t.Parallel() + + hub := NewDeltaHub() + // Must not panic with zero subscribers. + hub.Publish(&kernelv1.TokenDelta{SessionId: "s1", TargetId: "t", Text: "hi"}) +} diff --git a/internal/kernelcallback/host.go b/internal/kernelcallback/host.go new file mode 100644 index 0000000..8889fdf --- /dev/null +++ b/internal/kernelcallback/host.go @@ -0,0 +1,53 @@ +package kernelcallback + +import ( + "context" + "sync/atomic" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" +) + +// FrontendHost is the agent-loop surface KernelCallbackService frontend +// RPCs need. Implemented by internal/kernel's frontendHost. +type FrontendHost interface { + CreateSession(ctx context.Context, req *kernelv1.CreateSessionRequest) (*sessionv1.SessionInfo, error) + SubmitInput(ctx context.Context, sessionID string, content []*contentv1.ContentBlock) (turnID string, err error) + Interrupt(ctx context.Context, sessionID string) error + ListSessions(ctx context.Context, req *kernelv1.ListSessionsRequest) ([]*sessionv1.SessionInfo, error) + GetSessionState(ctx context.Context, sessionID string) (*sessionv1.SessionState, error) + ResolvePlanDecision(ctx context.Context, req *kernelv1.ResolvePlanDecisionRequest) error + ResolveInteractive(ctx context.Context, req *kernelv1.ResolveInteractiveRequest) error + InvokeSlashCommand(ctx context.Context, req *kernelv1.InvokeSlashCommandRequest) error + TriggerAction(ctx context.Context, req *kernelv1.TriggerActionRequest) error +} + +// HostSlot is a late-bound FrontendHost shared by every per-plugin +// kernelcallback.Server. The agent-loop host is installed after plugins +// start (it needs the catalog and turn stack), so callback servers hold a +// slot rather than a concrete host at construction. +type HostSlot struct { + v atomic.Value // stores FrontendHost +} + +// Set installs host for every Server sharing this slot. +func (s *HostSlot) Set(host FrontendHost) { + if s == nil { + return + } + s.v.Store(host) +} + +// Get returns the installed host, or nil. +func (s *HostSlot) Get() FrontendHost { + if s == nil { + return nil + } + v := s.v.Load() + if v == nil { + return nil + } + h, _ := v.(FrontendHost) + return h +} diff --git a/internal/kernelcallback/server.go b/internal/kernelcallback/server.go index 4fe200d..1579843 100644 --- a/internal/kernelcallback/server.go +++ b/internal/kernelcallback/server.go @@ -4,8 +4,11 @@ import ( "context" "log/slog" + "sync" + "github.com/pluggableharness/agent/internal/eventbus" "github.com/pluggableharness/agent/internal/log" + "github.com/pluggableharness/agent/internal/metadata" "github.com/pluggableharness/agent/internal/producer" "github.com/pluggableharness/agent/internal/sessionscope" "github.com/pluggableharness/agent/internal/sessionstate" @@ -97,6 +100,21 @@ type Config struct { // a model provider's own CountTokens RPC is reachable, the single // documented fallback heuristic otherwise. MUST be set. Tokens *tokencount.Counter + + // Metadata is the process-wide MetadataBlock collection behind + // PublishMetadata/RetractMetadata/ListMetadata. MAY be nil; when nil + // those RPCs return FailedPrecondition (or empty List). Prefer + // wiring a shared *metadata.Store from cmd/ wiring. + Metadata *metadata.Store + + // Deltas is the live TokenDelta fan-out for StreamDeltas. MAY be nil; + // when nil StreamDeltas stays open until the client cancels with no + // deltas delivered. + Deltas *DeltaHub + + // HostSlot is the late-bound agent-loop host shared across plugins. + // MAY be nil; frontend RPCs then return Unimplemented until Set. + HostSlot *HostSlot } // defaultBusSubscribeQueueBound is the fallback per-Subscribe-stream @@ -129,6 +147,14 @@ type Server struct { scopes *sessionscope.Registry sessions *sessionstate.Table tokens *tokencount.Counter + metadata *metadata.Store + deltas *DeltaHub + hostSlot *HostSlot + + // attachReleases tracks Grant release funcs from AttachSession so + // DetachSession can drop exactly one grant per call. + attachMu sync.Mutex + attachReleases map[string][]func() } // NewServer returns a Server bound to cfg — see Config's field comments @@ -159,7 +185,19 @@ func NewServer(cfg Config) *Server { scopes: cfg.Scopes, sessions: cfg.Sessions, tokens: cfg.Tokens, + metadata: cfg.Metadata, + deltas: cfg.Deltas, + hostSlot: cfg.HostSlot, + attachReleases: make(map[string][]func()), + } +} + +// host returns the late-bound FrontendHost, if any. +func (s *Server) host() FrontendHost { + if s.hostSlot == nil { + return nil } + return s.hostSlot.Get() } // Log implements the Log RPC by injecting this Server's fixed producer diff --git a/internal/metadata/CLAUDE.md b/internal/metadata/CLAUDE.md new file mode 100644 index 0000000..44e3413 --- /dev/null +++ b/internal/metadata/CLAUDE.md @@ -0,0 +1,5 @@ +# metadata + +- Never delete a block; only flip liveness. +- Producer identity is always server-derived — never trust a client-set `producer` field on the incoming block. +- Topic is the fixed string `kernel.metadata`; `session_id` is on the payload, not in the topic name. diff --git a/internal/metadata/README.md b/internal/metadata/README.md new file mode 100644 index 0000000..2fdb7d7 --- /dev/null +++ b/internal/metadata/README.md @@ -0,0 +1,9 @@ +# metadata + +Kernel-side collection of `MetadataBlock` values for the frontend **Metadata** surface. + +- **Publish** upserts a block, stamping server-derived producer identity and `liveness=LIVE`. +- **Retract** / producer disconnect flips `liveness=DISCONNECTED` and keeps the row — the kernel never deletes. +- **List** is the snapshot half of snapshot-then-subscribe; live updates go on the event bus topic `kernel.metadata`. + +Wired into `internal/kernelcallback` as the implementation behind `PublishMetadata`, `RetractMetadata`, and `ListMetadata`. diff --git a/internal/metadata/doc.go b/internal/metadata/doc.go new file mode 100644 index 0000000..ea2f697 --- /dev/null +++ b/internal/metadata/doc.go @@ -0,0 +1,7 @@ +// Package metadata is the kernel-side MetadataBlock collection: upsert on +// PublishMetadata, liveness flip on RetractMetadata / publisher exit, never +// delete. Frontends snapshot via ListMetadata and subscribe to topic +// kernel.metadata for live updates. +// +// See docs/specifications/frontend/ and kernel-callbacks.md. +package metadata diff --git a/internal/metadata/store.go b/internal/metadata/store.go new file mode 100644 index 0000000..fa17958 --- /dev/null +++ b/internal/metadata/store.go @@ -0,0 +1,132 @@ +package metadata + +import ( + "fmt" + "sort" + "sync" + + "google.golang.org/protobuf/proto" + + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + metadatav1 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" +) + +// Topic is the event-bus topic the kernel publishes metadata changes on. +// session_id lives in the payload, not the topic, to keep topic cardinality +// bounded (event-bus.md / plan: topics without session_id segments). +const Topic = "kernel.metadata" + +// Store is an in-memory, per-process MetadataBlock collection keyed by +// (session_id, block_id). Safe for concurrent use. +type Store struct { + mu sync.RWMutex + // blocks[sessionID][blockID] = block + blocks map[string]map[string]*metadatav1.MetadataBlock +} + +// NewStore returns an empty Store. +func NewStore() *Store { + return &Store{blocks: make(map[string]map[string]*metadatav1.MetadataBlock)} +} + +// Publish upserts block under sessionID, stamping producer and +// liveness=LIVE. Returns a clone of the stored block. +func (s *Store) Publish(sessionID string, producer *commonv1.ProducerRef, block *metadatav1.MetadataBlock) (*metadatav1.MetadataBlock, error) { + if sessionID == "" { + return nil, fmt.Errorf("metadata: publish: session_id is required") + } + if block == nil || block.GetId() == "" { + return nil, fmt.Errorf("metadata: publish: block id is required") + } + if block.GetBody() == nil { + return nil, fmt.Errorf("metadata: publish: block body is required") + } + + stored := proto.Clone(block).(*metadatav1.MetadataBlock) + stored.SessionId = sessionID + stored.Producer = producer + stored.Liveness = metadatav1.Liveness_LIVENESS_LIVE + if stored.Tone == metadatav1.Tone_TONE_UNSPECIFIED { + stored.Tone = metadatav1.Tone_TONE_NEUTRAL + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.blocks[sessionID] == nil { + s.blocks[sessionID] = make(map[string]*metadatav1.MetadataBlock) + } + s.blocks[sessionID][stored.GetId()] = stored + return proto.Clone(stored).(*metadatav1.MetadataBlock), nil +} + +// Retract flips the named block to DISCONNECTED. Returns the updated +// block, or an error if it was never published. +func (s *Store) Retract(sessionID, blockID string) (*metadatav1.MetadataBlock, error) { + if sessionID == "" || blockID == "" { + return nil, fmt.Errorf("metadata: retract: session_id and block_id are required") + } + s.mu.Lock() + defer s.mu.Unlock() + byID := s.blocks[sessionID] + if byID == nil || byID[blockID] == nil { + return nil, fmt.Errorf("metadata: retract: block %q not found in session %q", blockID, sessionID) + } + stored := proto.Clone(byID[blockID]).(*metadatav1.MetadataBlock) + stored.Liveness = metadatav1.Liveness_LIVENESS_DISCONNECTED + byID[blockID] = stored + return proto.Clone(stored).(*metadatav1.MetadataBlock), nil +} + +// DisconnectProducer flips every LIVE block owned by producer in +// sessionID to DISCONNECTED. Returns the updated blocks (may be empty). +func (s *Store) DisconnectProducer(sessionID string, producer *commonv1.ProducerRef) []*metadatav1.MetadataBlock { + if sessionID == "" || producer == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + byID := s.blocks[sessionID] + if byID == nil { + return nil + } + out := make([]*metadatav1.MetadataBlock, 0) + for id, b := range byID { + if b.GetLiveness() != metadatav1.Liveness_LIVENESS_LIVE { + continue + } + if !sameProducer(b.GetProducer(), producer) { + continue + } + stored := proto.Clone(b).(*metadatav1.MetadataBlock) + stored.Liveness = metadatav1.Liveness_LIVENESS_DISCONNECTED + byID[id] = stored + out = append(out, proto.Clone(stored).(*metadatav1.MetadataBlock)) + } + sort.Slice(out, func(i, j int) bool { return out[i].GetId() < out[j].GetId() }) + return out +} + +// List returns every block for sessionID in stable id order. +func (s *Store) List(sessionID string) []*metadatav1.MetadataBlock { + s.mu.RLock() + defer s.mu.RUnlock() + byID := s.blocks[sessionID] + if len(byID) == 0 { + return nil + } + out := make([]*metadatav1.MetadataBlock, 0, len(byID)) + for _, b := range byID { + out = append(out, proto.Clone(b).(*metadatav1.MetadataBlock)) + } + sort.Slice(out, func(i, j int) bool { return out[i].GetId() < out[j].GetId() }) + return out +} + +func sameProducer(a, b *commonv1.ProducerRef) bool { + if a == nil || b == nil { + return a == b + } + return a.GetCategory() == b.GetCategory() && + a.GetName() == b.GetName() && + a.GetVersion() == b.GetVersion() +} diff --git a/internal/metadata/store_test.go b/internal/metadata/store_test.go new file mode 100644 index 0000000..148d68e --- /dev/null +++ b/internal/metadata/store_test.go @@ -0,0 +1,133 @@ +package metadata_test + +import ( + "testing" + + "github.com/pluggableharness/agent/internal/metadata" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + metabuilders "github.com/pluggableharness/agent/pkg/metadata" + metadatav1 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" +) + +func producer(name string) *commonv1.ProducerRef { + return &commonv1.ProducerRef{ + Category: commonv1.Category_CATEGORY_WIDGET, + Name: name, + Version: "1.0.0", + } +} + +func TestPublishAndList(t *testing.T) { + t.Parallel() + + s := metadata.NewStore() + block := metabuilders.KeyValue("branch", "branch", "main") + got, err := s.Publish("sess-1", producer("git"), block) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if got.GetProducer().GetName() != "git" { + t.Errorf("producer = %v, want git", got.GetProducer()) + } + if got.GetLiveness() != metadatav1.Liveness_LIVENESS_LIVE { + t.Errorf("liveness = %v, want LIVE", got.GetLiveness()) + } + if got.GetSessionId() != "sess-1" { + t.Errorf("session_id = %q", got.GetSessionId()) + } + + list := s.List("sess-1") + if len(list) != 1 || list[0].GetId() != "branch" { + t.Fatalf("List = %+v", list) + } +} + +func TestPublishUpserts(t *testing.T) { + t.Parallel() + + s := metadata.NewStore() + p := producer("git") + if _, err := s.Publish("s", p, metabuilders.KeyValue("branch", "branch", "main")); err != nil { + t.Fatal(err) + } + if _, err := s.Publish("s", p, metabuilders.KeyValue("branch", "branch", "develop")); err != nil { + t.Fatal(err) + } + list := s.List("s") + if len(list) != 1 || list[0].GetKeyValue().GetValue() != "develop" { + t.Fatalf("upsert = %+v", list) + } +} + +func TestRetract(t *testing.T) { + t.Parallel() + + s := metadata.NewStore() + p := producer("git") + if _, err := s.Publish("s", p, metabuilders.KeyValue("b", "k", "v")); err != nil { + t.Fatal(err) + } + got, err := s.Retract("s", "b") + if err != nil { + t.Fatalf("Retract: %v", err) + } + if got.GetLiveness() != metadatav1.Liveness_LIVENESS_DISCONNECTED { + t.Errorf("liveness = %v, want DISCONNECTED", got.GetLiveness()) + } + // Still listed — never deleted. + if len(s.List("s")) != 1 { + t.Fatalf("retract deleted the block") + } +} + +func TestRetractMissing(t *testing.T) { + t.Parallel() + + s := metadata.NewStore() + if _, err := s.Retract("s", "nope"); err == nil { + t.Fatal("expected error for missing block") + } +} + +func TestDisconnectProducer(t *testing.T) { + t.Parallel() + + s := metadata.NewStore() + git := producer("git") + ctx := producer("context") + if _, err := s.Publish("s", git, metabuilders.KeyValue("g", "k", "v")); err != nil { + t.Fatal(err) + } + if _, err := s.Publish("s", ctx, metabuilders.Status("c", "ok", "")); err != nil { + t.Fatal(err) + } + out := s.DisconnectProducer("s", git) + if len(out) != 1 || out[0].GetId() != "g" { + t.Fatalf("DisconnectProducer = %+v", out) + } + if out[0].GetLiveness() != metadatav1.Liveness_LIVENESS_DISCONNECTED { + t.Errorf("git block liveness = %v", out[0].GetLiveness()) + } + // context block still LIVE + for _, b := range s.List("s") { + if b.GetId() == "c" && b.GetLiveness() != metadatav1.Liveness_LIVENESS_LIVE { + t.Errorf("context block should stay LIVE") + } + } +} + +func TestListStableOrder(t *testing.T) { + t.Parallel() + + s := metadata.NewStore() + p := producer("w") + for _, id := range []string{"c", "a", "b"} { + if _, err := s.Publish("s", p, metabuilders.KeyValue(id, id, id)); err != nil { + t.Fatal(err) + } + } + list := s.List("s") + if len(list) != 3 || list[0].GetId() != "a" || list[1].GetId() != "b" || list[2].GetId() != "c" { + t.Fatalf("order = %v", []string{list[0].GetId(), list[1].GetId(), list[2].GetId()}) + } +} diff --git a/internal/modelcall/complete.go b/internal/modelcall/complete.go index 0a84fcb..dea5098 100644 --- a/internal/modelcall/complete.go +++ b/internal/modelcall/complete.go @@ -63,7 +63,8 @@ func (c *Caller) Complete(ctx context.Context, req Request) (Response, error) { c.cfg.Logger.DebugContext(ctx, "modelcall: starting completion", "model_id", modelID, "message_id", req.MessageID) for attemptNum := 1; ; attemptNum++ { - message, usage, stop, modelErr, attemptErr := c.doAttempt(ctx, req, attemptNum) + done, modelErr, attemptErr := c.doAttempt(ctx, req, attemptNum) + message, usage, stop := done.message, done.usage, done.stop if attemptErr != nil { if isCancellation(attemptErr) { c.cfg.Logger.DebugContext(ctx, "modelcall: canceled", "model_id", modelID, "attempt", attemptNum) @@ -93,7 +94,14 @@ func (c *Caller) Complete(ctx context.Context, req Request) (Response, error) { ModelID: modelID, }) c.cfg.Logger.DebugContext(ctx, "modelcall: completion succeeded", "model_id", modelID, "attempt", attemptNum, "cost_usd", costUSD) - return Response{Message: message, Usage: usage, CostUSD: costUSD, Stop: stop, Attempts: attemptNum}, nil + return Response{ + Message: message, + Usage: usage, + CostUSD: costUSD, + Stop: stop, + Attempts: attemptNum, + ActualModel: done.metadata.GetActualModel(), + }, nil } category := modelErr.GetCategory() @@ -147,16 +155,31 @@ func modelErrToErr(modelErr *modelv1.ModelError) error { return errors.New(msg) } +// completion is one successful attempt's payload. +// +// Bundled rather than returned as a widening tuple: doAttempt already +// reported four values plus an error, and threading vendor metadata +// through as a sixth positional result would make every call site a +// puzzle. The struct also keeps the three failure returns uniform — +// completion{} says "nothing was produced" once, instead of repeating a +// nil/nil/UNSPECIFIED prefix at each one. +type completion struct { + message *contentv1.Message + usage *modelv1.Usage + stop modelv1.StopReason + metadata *modelv1.StreamEvent_StreamMetadata +} + // doAttempt performs exactly one StreamCompletion invocation: dial the // RPC, accumulate every event into a fresh streamaccum.Accumulator (never // reused across attempts, so a retried attempt never inherits a failed // prior attempt's partial state), and report the outcome as exactly one -// of: a successful message/usage/stop, a classified modelErr (either the +// of: a successful completion, a classified modelErr (either the // accumulator's own decoded ModelError, or a fallback classification of a // badly-behaved transport-level failure — see classifyTransportErr), or // an unclassified err for a structurally invalid stream or a // cancellation. -func (c *Caller) doAttempt(ctx context.Context, req Request, attemptNum int) (message *contentv1.Message, usage *modelv1.Usage, stop modelv1.StopReason, modelErr *modelv1.ModelError, err error) { +func (c *Caller) doAttempt(ctx context.Context, req Request, attemptNum int) (out completion, modelErr *modelv1.ModelError, err error) { modelID := req.Model.Ref.ID ctx, span := c.cfg.Telemetry.StartModelAttempt(ctx, modelID, req.Model.Producer, attemptNum) var spanErr error @@ -166,12 +189,12 @@ func (c *Caller) doAttempt(ctx context.Context, req Request, attemptNum int) (me stream, dialErr := req.Model.Client.StreamCompletion(ctx, req.Request) if dialErr != nil { if isCancellation(dialErr) { - return nil, nil, modelv1.StopReason_STOP_REASON_UNSPECIFIED, nil, dialErr + return completion{}, nil, dialErr } modelErr = classifyTransportErr(dialErr) spanErr = dialErr c.cfg.Logger.WarnContext(ctx, "modelcall: transport failure establishing stream, applying fallback classification", "model_id", modelID, "attempt", attemptNum, "category", modelErr.GetCategory(), "err", dialErr) - return nil, nil, modelv1.StopReason_STOP_REASON_UNSPECIFIED, modelErr, nil + return completion{}, modelErr, nil } acc := streamaccum.New() @@ -182,17 +205,22 @@ func (c *Caller) doAttempt(ctx context.Context, req Request, attemptNum int) (me break } if isCancellation(recvErr) { - return nil, nil, modelv1.StopReason_STOP_REASON_UNSPECIFIED, nil, recvErr + return completion{}, nil, recvErr } modelErr = classifyTransportErr(recvErr) spanErr = recvErr c.cfg.Logger.WarnContext(ctx, "modelcall: transport failure mid-stream, applying fallback classification", "model_id", modelID, "attempt", attemptNum, "category", modelErr.GetCategory(), "err", recvErr) - return nil, nil, modelv1.StopReason_STOP_REASON_UNSPECIFIED, modelErr, nil + return completion{}, modelErr, nil } if obsErr := acc.Observe(ev); obsErr != nil { err = fmt.Errorf("modelcall: observe stream event: %w", obsErr) spanErr = err - return nil, nil, modelv1.StopReason_STOP_REASON_UNSPECIFIED, nil, err + return completion{}, nil, err + } + if c.cfg.OnTextDelta != nil { + if td := ev.GetTextDelta(); td != nil && td.GetText() != "" { + c.cfg.OnTextDelta(req.SessionID, req.MessageID, td.GetText()) + } } } @@ -200,13 +228,14 @@ func (c *Caller) doAttempt(ctx context.Context, req Request, attemptNum int) (me if !ok { err = errors.New("modelcall: stream ended before a terminal event") spanErr = err - return nil, nil, modelv1.StopReason_STOP_REASON_UNSPECIFIED, nil, err + return completion{}, nil, err } + done := completion{message: msg, usage: u, stop: stopReason, metadata: acc.Metadata()} if accErr := acc.Err(); accErr != nil { spanErr = modelErrToErr(accErr) - return msg, u, stopReason, accErr, nil + return done, accErr, nil } - return msg, u, stopReason, nil, nil + return done, nil, nil } // isCancellation reports whether err represents the kernel canceling the diff --git a/internal/modelcall/modelcall.go b/internal/modelcall/modelcall.go index dbe1c79..73e3365 100644 --- a/internal/modelcall/modelcall.go +++ b/internal/modelcall/modelcall.go @@ -67,6 +67,11 @@ type Config struct { Telemetry *telemetry.Provider // Logger is this Caller's structured logger. Logger *slog.Logger + // OnTextDelta is called for every text_delta StreamEvent after it is + // successfully Observed, for the live token fast path. MAY be nil. + // targetID is the completion's MessageID so frontends can correlate + // consecutive deltas into one growing block. + OnTextDelta func(sessionID, targetID, text string) } // Request is one StreamCompletion invocation: which model to call, the @@ -81,6 +86,10 @@ type Request struct { // Model is the resolved model handle to call — its Client is what // Complete invokes StreamCompletion on. Model providercatalog.ModelHandle + // SessionID is the session this completion belongs to — used only for + // OnTextDelta attribution on the live token fast path. MAY be empty + // when no delta fan-out is configured. + SessionID string // MessageID is the kernel-assigned id for the message this call will // produce, used as both contentv1.Message.Id and the persisted // statebackend.Event.ID. @@ -106,6 +115,15 @@ type Response struct { // call made, including the one that finally succeeded (1 if the // first attempt succeeded). Attempts int + // ActualModel is the model the vendor says actually served this + // completion, from a StreamMetadata event, when it differs from the + // requested id. Empty when the vendor served what was asked for or + // reported nothing. + // + // Surfaced up to the session so silent model substitution is + // attributable: a vendor rerouting for safety or capacity otherwise + // shows up only as answers that got worse for no visible reason. + ActualModel string } // Error carries a classified, non-retried (or retries-exhausted) model diff --git a/internal/modelcall/modelcall_test.go b/internal/modelcall/modelcall_test.go index 33eba67..e451638 100644 --- a/internal/modelcall/modelcall_test.go +++ b/internal/modelcall/modelcall_test.go @@ -115,6 +115,10 @@ func (f *fakeModelServiceClient) Describe(context.Context, *modelv1.DescribeRequ panic("fakeModelServiceClient: Describe not scripted for this test") } +func (f *fakeModelServiceClient) GetAccount(context.Context, *modelv1.GetAccountRequest, ...grpc.CallOption) (*modelv1.GetAccountResponse, error) { + panic("fakeModelServiceClient: GetAccount not scripted for this test") +} + // fakeSink is a hand-written MessageSink recording every AppendMessage // call for assertion. type fakeSink struct { @@ -597,6 +601,9 @@ func (c *cancelingClient) StreamCompletion(context.Context, *modelv1.StreamCompl func (c *cancelingClient) CountTokens(context.Context, *modelv1.CountTokensRequest, ...grpc.CallOption) (*modelv1.CountTokensResponse, error) { panic("not scripted") } +func (c *cancelingClient) GetAccount(context.Context, *modelv1.GetAccountRequest, ...grpc.CallOption) (*modelv1.GetAccountResponse, error) { + panic("not scripted") +} func (c *cancelingClient) Render(context.Context, *modelv1.RenderRequest, ...grpc.CallOption) (*modelv1.RenderResponse, error) { panic("not scripted") } @@ -930,7 +937,7 @@ func TestDoAttempt_streamEndsWithoutTerminalEvent(t *testing.T) { Logger: testLogger(&bytes.Buffer{}), }) - _, _, _, modelErr, err := caller.doAttempt(context.Background(), Request{Model: testModelHandle(client), MessageID: "m", Request: &modelv1.StreamCompletionRequest{}}, 1) + _, modelErr, err := caller.doAttempt(context.Background(), Request{Model: testModelHandle(client), MessageID: "m", Request: &modelv1.StreamCompletionRequest{}}, 1) if err == nil { t.Fatal("doAttempt returned nil err, want a structural error") } @@ -954,7 +961,7 @@ func TestDoAttempt_observeErrorIsUnclassified(t *testing.T) { Logger: testLogger(&bytes.Buffer{}), }) - _, _, _, modelErr, err := caller.doAttempt(context.Background(), Request{Model: testModelHandle(client), MessageID: "m", Request: &modelv1.StreamCompletionRequest{}}, 1) + _, modelErr, err := caller.doAttempt(context.Background(), Request{Model: testModelHandle(client), MessageID: "m", Request: &modelv1.StreamCompletionRequest{}}, 1) if err == nil { t.Fatal("doAttempt returned nil err, want the wrapped streamaccum error") } diff --git a/internal/pending/CLAUDE.md b/internal/pending/CLAUDE.md new file mode 100644 index 0000000..1cc56f9 --- /dev/null +++ b/internal/pending/CLAUDE.md @@ -0,0 +1,4 @@ +# pending + +- First Answer/Complete wins; second returns ErrAlreadyResolved or ErrNoWaiter. +- Resolve MUST honor ctx cancellation promptly (stalls the whole turn). diff --git a/internal/pending/README.md b/internal/pending/README.md new file mode 100644 index 0000000..a299562 --- /dev/null +++ b/internal/pending/README.md @@ -0,0 +1,3 @@ +# pending + +In-process waiter registries bridging frontend Resolve* RPCs to turn-loop Resolvers. diff --git a/internal/pending/doc.go b/internal/pending/doc.go new file mode 100644 index 0000000..dcb96d4 --- /dev/null +++ b/internal/pending/doc.go @@ -0,0 +1,9 @@ +// Package pending holds the in-process waiter registries that bridge +// operator-facing KernelCallbackService RPCs +// (ResolvePlanDecision, ResolveInteractive) to the turn loop's blocking +// Resolver interfaces (plandecision.Resolver, interactive.Resolver). +// +// A Resolve call parks on a channel keyed by session+id; the matching +// Answer/Complete call unblocks it. First-response-wins: a second Answer +// for an already-resolved id returns ErrAlreadyResolved. +package pending diff --git a/internal/pending/interactive.go b/internal/pending/interactive.go new file mode 100644 index 0000000..1f25593 --- /dev/null +++ b/internal/pending/interactive.go @@ -0,0 +1,89 @@ +package pending + +import ( + "context" + "fmt" + "sync" + + "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/internal/interactive" +) + +// InteractiveBridge implements interactive.Resolver by parking on Complete. +type InteractiveBridge struct { + mu sync.Mutex + waiters map[string]chan interactiveResult // key: sessionID + "\x00" + callID +} + +type interactiveResult struct { + resp interactive.Response + err error +} + +// NewInteractiveBridge returns an empty bridge. +func NewInteractiveBridge() *InteractiveBridge { + return &InteractiveBridge{waiters: make(map[string]chan interactiveResult)} +} + +// Resolve implements interactive.Resolver. +// +// sessionID is not on interactive.Request — the bridge keys only by CallID +// globally within a process (call ids are ULIDs and unique). For multi- +// session safety the Complete path still accepts sessionID for logging +// and future partitioning; waiters are keyed by callID alone today. +func (b *InteractiveBridge) Resolve(ctx context.Context, req interactive.Request) (interactive.Response, error) { + if req.CallID == "" { + return interactive.Response{}, fmt.Errorf("pending: interactive: call_id is required") + } + key := req.CallID + ch := make(chan interactiveResult, 1) + + b.mu.Lock() + if _, exists := b.waiters[key]; exists { + b.mu.Unlock() + return interactive.Response{}, fmt.Errorf("pending: interactive: duplicate waiter for %s", key) + } + b.waiters[key] = ch + b.mu.Unlock() + + defer func() { + b.mu.Lock() + delete(b.waiters, key) + b.mu.Unlock() + }() + + select { + case <-ctx.Done(): + return interactive.Response{}, ctx.Err() + case res := <-ch: + if res.err != nil { + return interactive.Response{}, res.err + } + return res.resp, nil + } +} + +// Complete unblocks one outstanding Resolve for callID. +func (b *InteractiveBridge) Complete(callID string, payload *structpb.Struct) error { + if callID == "" { + return fmt.Errorf("pending: interactive: call_id is required") + } + b.mu.Lock() + ch, ok := b.waiters[callID] + if !ok { + b.mu.Unlock() + return ErrNoWaiter + } + delete(b.waiters, callID) + b.mu.Unlock() + + select { + case ch <- interactiveResult{resp: interactive.Response{Payload: payload}}: + return nil + default: + return ErrAlreadyResolved + } +} + +var _ interactive.Resolver = (*InteractiveBridge)(nil) diff --git a/internal/pending/plan.go b/internal/pending/plan.go new file mode 100644 index 0000000..cb16544 --- /dev/null +++ b/internal/pending/plan.go @@ -0,0 +1,108 @@ +package pending + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/pluggableharness/agent/internal/plandecision" + planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" +) + +// ErrAlreadyResolved reports a second Answer for an id that was already +// completed, or a send that lost a race. +var ErrAlreadyResolved = errors.New("pending: already resolved") + +// ErrNoWaiter reports Answer for an id with no outstanding Resolve. +var ErrNoWaiter = errors.New("pending: no pending waiter") + +// PlanBridge implements plandecision.Resolver by parking on Answer. +type PlanBridge struct { + mu sync.Mutex + waiters map[string]chan planResult // key: sessionID + "\x00" + planItemID +} + +type planResult struct { + dec plandecision.Decision + err error +} + +// NewPlanBridge returns an empty bridge. +func NewPlanBridge() *PlanBridge { + return &PlanBridge{waiters: make(map[string]chan planResult)} +} + +func planKey(sessionID, planItemID string) string { + return sessionID + "\x00" + planItemID +} + +// Resolve implements plandecision.Resolver: blocks until Answer or ctx cancel. +func (b *PlanBridge) Resolve(ctx context.Context, req plandecision.Request) (plandecision.Decision, error) { + if err := req.Validate(); err != nil { + return plandecision.Decision{}, err + } + key := planKey(req.SessionID, req.Item.GetId()) + ch := make(chan planResult, 1) + + b.mu.Lock() + if _, exists := b.waiters[key]; exists { + b.mu.Unlock() + return plandecision.Decision{}, fmt.Errorf("pending: plan: duplicate waiter for %q", req.Item.GetId()) + } + b.waiters[key] = ch + b.mu.Unlock() + + defer func() { + b.mu.Lock() + delete(b.waiters, key) + b.mu.Unlock() + }() + + select { + case <-ctx.Done(): + return plandecision.Decision{}, ctx.Err() + case res := <-ch: + if res.err != nil { + return plandecision.Decision{}, res.err + } + if err := plandecision.ValidateDecision(req, res.dec); err != nil { + return plandecision.Decision{}, err + } + return res.dec, nil + } +} + +// Answer completes one outstanding Resolve. First call wins. +func (b *PlanBridge) Answer(sessionID, planItemID string, dec plandecision.Decision) error { + key := planKey(sessionID, planItemID) + b.mu.Lock() + ch, ok := b.waiters[key] + if !ok { + b.mu.Unlock() + return ErrNoWaiter + } + delete(b.waiters, key) + b.mu.Unlock() + + select { + case ch <- planResult{dec: dec}: + return nil + default: + return ErrAlreadyResolved + } +} + +// ClientDecisionToTerminal maps an operator allow/deny to a plan decision. +func ClientDecisionToTerminal(d planv1.ClientDecision) (planv1.PlanDecision, error) { + switch d { + case planv1.ClientDecision_CLIENT_DECISION_ALLOW: + return planv1.PlanDecision_PLAN_DECISION_ALLOW, nil + case planv1.ClientDecision_CLIENT_DECISION_DENY: + return planv1.PlanDecision_PLAN_DECISION_DENY, nil + default: + return planv1.PlanDecision_PLAN_DECISION_UNSPECIFIED, fmt.Errorf("pending: invalid client decision %v", d) + } +} + +var _ plandecision.Resolver = (*PlanBridge)(nil) diff --git a/internal/pending/plan_test.go b/internal/pending/plan_test.go new file mode 100644 index 0000000..40dce0d --- /dev/null +++ b/internal/pending/plan_test.go @@ -0,0 +1,86 @@ +package pending_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/pluggableharness/agent/internal/pending" + "github.com/pluggableharness/agent/internal/plandecision" + planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" +) + +func TestPlanBridge_ResolveAnswer(t *testing.T) { + t.Parallel() + + b := pending.NewPlanBridge() + req := plandecision.Request{ + SessionID: "s1", + TurnID: "t1", + Item: &planv1.PlanItem{Id: "item-1"}, + } + + done := make(chan plandecision.Decision, 1) + errCh := make(chan error, 1) + go func() { + dec, err := b.Resolve(context.Background(), req) + errCh <- err + done <- dec + }() + + // Wait until waiter is registered. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + err := b.Answer("s1", "item-1", plandecision.Decision{ + Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + DecidedBy: "operator", + }) + if err == nil { + break + } + if !errors.Is(err, pending.ErrNoWaiter) { + t.Fatalf("Answer: %v", err) + } + time.Sleep(time.Millisecond) + } + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("Resolve: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } + dec := <-done + if dec.Decision != planv1.PlanDecision_PLAN_DECISION_ALLOW { + t.Errorf("decision = %v", dec.Decision) + } +} + +func TestPlanBridge_Cancel(t *testing.T) { + t.Parallel() + + b := pending.NewPlanBridge() + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, err := b.Resolve(ctx, plandecision.Request{ + SessionID: "s", + Item: &planv1.PlanItem{Id: "i"}, + }) + errCh <- err + }() + time.Sleep(20 * time.Millisecond) + cancel() + select { + case err := <-errCh: + if err == nil { + t.Fatal("want cancel error") + } + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } +} diff --git a/internal/plandecision/drivers/autoallow/autoallow.go b/internal/plandecision/drivers/autoallow/autoallow.go index 75c86bf..a7acc3d 100644 --- a/internal/plandecision/drivers/autoallow/autoallow.go +++ b/internal/plandecision/drivers/autoallow/autoallow.go @@ -11,7 +11,6 @@ import ( "github.com/pluggableharness/agent/internal/plandecision" "github.com/pluggableharness/agent/internal/telemetry" "github.com/pluggableharness/agent/internal/telemetry/drivers/noop" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" ) @@ -138,7 +137,7 @@ func (r *resolver) Resolve(ctx context.Context, req plandecision.Request) (_ pla r.logger.DebugContext(ctx, "autoallow: resolved plan item", append(attrs, slog.String("decision", planv1.PlanDecision_PLAN_DECISION_ALLOW.String()), - slog.String("scope", frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE.String()), + slog.String("scope", planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE.String()), )...) return plandecision.Decision{ @@ -148,7 +147,7 @@ func (r *resolver) Resolve(ctx context.Context, req plandecision.Request) (_ pla // persisted policy rule) that the real frontend resolver would // later have to discover and reconcile. Auto-allow leaves zero // durable trace beyond the ordinary per-item audit row. - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, // Never a correction: this resolver blanket-approves the // model's original input, it does not propose alternatives. CorrectedInput: nil, diff --git a/internal/plandecision/drivers/autoallow/autoallow_test.go b/internal/plandecision/drivers/autoallow/autoallow_test.go index 88163e3..8f094c1 100644 --- a/internal/plandecision/drivers/autoallow/autoallow_test.go +++ b/internal/plandecision/drivers/autoallow/autoallow_test.go @@ -14,7 +14,6 @@ import ( "github.com/pluggableharness/agent/internal/plandecision/drivers/autoallow" "github.com/pluggableharness/agent/internal/telemetry" telemetryfake "github.com/pluggableharness/agent/internal/telemetry/drivers/fake" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" ) @@ -237,7 +236,7 @@ func TestResolve_alwaysScopeOnce(t *testing.T) { if err != nil { t.Fatalf("Resolve #%d: %v", i, err) } - if got.Scope != frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE { + if got.Scope != planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE { t.Fatalf("Resolve #%d: Scope = %v, want PLAN_DECISION_SCOPE_ONCE", i, got.Scope) } } diff --git a/internal/plandecision/drivers/fake/fake_test.go b/internal/plandecision/drivers/fake/fake_test.go index 96043e8..d2567c4 100644 --- a/internal/plandecision/drivers/fake/fake_test.go +++ b/internal/plandecision/drivers/fake/fake_test.go @@ -9,7 +9,6 @@ import ( "github.com/pluggableharness/agent/internal/plandecision" "github.com/pluggableharness/agent/internal/plandecision/drivers/fake" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" ) @@ -29,17 +28,17 @@ func TestResolver_scriptedQueue(t *testing.T) { responses := []fake.Response{ {Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, DecidedBy: "test", }}, {Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_DENY, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION, DecidedBy: "test", }}, {Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS, CorrectedInput: corrected, DecidedBy: "test", }}, @@ -84,7 +83,7 @@ func TestResolver_always(t *testing.T) { r := fake.NewAlways(fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_DENY, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, DecidedBy: "test", }}) diff --git a/internal/plandecision/plandecision.go b/internal/plandecision/plandecision.go index 1e91cfe..c1c41d7 100644 --- a/internal/plandecision/plandecision.go +++ b/internal/plandecision/plandecision.go @@ -8,7 +8,6 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/pluggableharness/agent/internal/schemavalidate" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" schemav1 "github.com/pluggableharness/agent/pkg/schema/proto/v1" ) @@ -79,7 +78,7 @@ type Decision struct { // in memory, ALWAYS persisted as policy. A Resolver that cannot // durably persist an ALWAYS verdict MUST fail with // ErrPolicyPersistenceUnavailable rather than downgrade the scope. - Scope frontendv1.PlanDecisionScope + Scope planv1.PlanDecisionScope // CorrectedInput, when non-nil, replaces the plan item's original // input: the operator supplied corrected arguments rather than a // binary accept/reject. It MUST be re-validated against the diff --git a/internal/plangate/decide.go b/internal/plangate/decide.go index 221f50b..cc51948 100644 --- a/internal/plangate/decide.go +++ b/internal/plangate/decide.go @@ -7,7 +7,6 @@ import ( contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" eventv1 "github.com/pluggableharness/agent/pkg/event/proto/v1" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" @@ -298,7 +297,7 @@ func (g *Gate) resolveAsk(ctx context.Context, turnID string, item *planv1.PlanI return fmt.Errorf("plangate: decide: %s.%s: %w", item.GetProvider(), item.GetOperationName(), err) } - if dec.Scope == frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS { + if dec.Scope == planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS { return fmt.Errorf("plangate: decide: %s.%s: %w", item.GetProvider(), item.GetOperationName(), plandecision.ErrPolicyPersistenceUnavailable) } @@ -308,7 +307,7 @@ func (g *Gate) resolveAsk(ctx context.Context, turnID string, item *planv1.PlanI if dec.CorrectedInput != nil { item.Input = dec.CorrectedInput } - if dec.Scope == frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION { + if dec.Scope == planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION { g.rememberScope(item.GetProvider(), item.GetOperationName(), sessionVerdict{ decision: dec.Decision, decidedBy: dec.DecidedBy, diff --git a/internal/plangate/decide_test.go b/internal/plangate/decide_test.go index 994b9a3..302e124 100644 --- a/internal/plangate/decide_test.go +++ b/internal/plangate/decide_test.go @@ -6,7 +6,6 @@ import ( "strings" "testing" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" @@ -44,7 +43,7 @@ func TestDecide_perItemPolicyEvaluation(t *testing.T) { // http.post matches nothing and falls through to the // resource default, which is ask. }, - Resolver: fake.NewAlways(allowDecision(frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE)), + Resolver: fake.NewAlways(allowDecision(planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE)), Events: sink, }) @@ -113,7 +112,7 @@ func TestDecide_askEscalatesToResolverWithCompositeDecidedBy(t *testing.T) { resolver := fake.New(fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, DecidedBy: "tui", }}) sink := &recordingSink{} @@ -147,7 +146,7 @@ func TestDecide_correctedInputReplacesTheItemInput(t *testing.T) { corrected := mustStruct(map[string]any{"path": "/tmp/safe"}) resolver := fake.NewAlways(fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, CorrectedInput: corrected, DecidedBy: "tui", }}) @@ -190,7 +189,7 @@ func TestDecide_invalidCorrectedInputIsRejected(t *testing.T) { bad := mustStruct(map[string]any{"path": 42}) resolver := fake.NewAlways(fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, CorrectedInput: bad, DecidedBy: "tui", }}) @@ -223,7 +222,7 @@ func TestDecide_invalidCorrectedInputIsRejected(t *testing.T) { func TestDecide_alwaysScopeIsRejected(t *testing.T) { t.Parallel() - resolver := fake.NewAlways(allowDecision(frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS)) + resolver := fake.NewAlways(allowDecision(planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS)) sink := &recordingSink{} g := newTestGate(t, Config{ Rules: []policy.Rule{ruleFor("confirm-writes", "fs", "write_file", policy.ActionAsk)}, @@ -251,7 +250,7 @@ func TestDecide_sessionScopeSuppressesTheSecondResolverCall(t *testing.T) { resolver := fake.New(fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION, DecidedBy: "tui", }}) g := newTestGate(t, Config{ @@ -292,7 +291,7 @@ func TestDecide_sessionScopeIsPerGate(t *testing.T) { newGate := func() (*Gate, *fake.Resolver) { r := fake.New(fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION, DecidedBy: "tui", }}) return newTestGate(t, Config{ @@ -326,7 +325,7 @@ func TestDecide_hookVetoDeniesTheWholePlan(t *testing.T) { t.Parallel() hooks := vetoHooks("guardrails") - resolver := fake.NewAlways(allowDecision(frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE)) + resolver := fake.NewAlways(allowDecision(planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE)) sink := &recordingSink{} g := newTestGate(t, Config{ Rules: []policy.Rule{ @@ -571,7 +570,7 @@ func TestDecide_correctedInputWithoutACatalogIsAccepted(t *testing.T) { Rules: []policy.Rule{ruleFor("confirm-writes", "fs", "write_file", policy.ActionAsk)}, Resolver: fake.NewAlways(fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, CorrectedInput: mustStruct(map[string]any{"path": 42}), DecidedBy: "tui", }}), @@ -593,7 +592,7 @@ func TestDecide_unknownOperationFallsBackToNoSchema(t *testing.T) { g := newTestGate(t, Config{ Rules: []policy.Rule{ruleFor("confirm-writes", "fs", "write_file", policy.ActionAsk)}, - Resolver: fake.NewAlways(allowDecision(frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE)), + Resolver: fake.NewAlways(allowDecision(planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE)), Tools: &fakeTools{handles: map[string]providercatalog.ToolHandle{}}, }) diff --git a/internal/plangate/plangate_test.go b/internal/plangate/plangate_test.go index c333667..efab63f 100644 --- a/internal/plangate/plangate_test.go +++ b/internal/plangate/plangate_test.go @@ -13,7 +13,6 @@ import ( "google.golang.org/protobuf/types/known/structpb" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" @@ -237,7 +236,7 @@ func newTestGate(t *testing.T, cfg Config, opts ...Option) *Gate { if cfg.Resolver == nil { cfg.Resolver = fake.NewAlways(fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, - Scope: frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, + Scope: planv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE, DecidedBy: "test", }}) } @@ -249,7 +248,7 @@ func newTestGate(t *testing.T, cfg Config, opts ...Option) *Gate { } // allowDecision is the resolver response most tests want. -func allowDecision(scope frontendv1.PlanDecisionScope) fake.Response { +func allowDecision(scope planv1.PlanDecisionScope) fake.Response { return fake.Response{Decision: plandecision.Decision{ Decision: planv1.PlanDecision_PLAN_DECISION_ALLOW, Scope: scope, diff --git a/internal/pluginhost/phase_test.go b/internal/pluginhost/phase_test.go new file mode 100644 index 0000000..eea1a6f --- /dev/null +++ b/internal/pluginhost/phase_test.go @@ -0,0 +1,165 @@ +package pluginhost + +// Unit tier: the Prepare/Configure split, driven through the same launch +// seam start_test.go uses. What is asserted here is ordering, not +// transport — that a provider can be launched, described, and registered +// without having been configured, and that a category predicate decides +// which providers a given Configure pass reaches. + +import ( + "context" + "testing" + + "google.golang.org/grpc" + + "github.com/pluggableharness/agent/internal/providerresolve" + commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" + toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" +) + +// phaseHarness wires a Supervisor over one tool provider and one +// frontend provider, each backed by its own in-process server so a test +// can tell which of the two a Configure pass actually reached. +// +// Both are dev overrides: this file is about phase ordering, and a dev +// override skips the checksum step that would otherwise need a real file +// on disk per provider. +func phaseHarness(t *testing.T) (s *Supervisor, toolCfg, frontendCfg *configured) { + t.Helper() + + toolCfg, frontendCfg = &configured{}, &configured{} + toolConn := dial(t, func(s *grpc.Server) { + toolv1.RegisterToolServiceServer(s, &fakeTool{cfg: toolCfg}) + }) + frontendConn := dial(t, func(s *grpc.Server) { + frontendv1.RegisterFrontendServiceServer(s, &fakeFrontend{cfg: frontendCfg}) + }) + clients := map[string]any{ + "toolp": toolv1.NewToolServiceClient(toolConn), + "frontendp": frontendv1.NewFrontendServiceClient(frontendConn), + } + + cfg := testDeps(t) + cfg.Resolved = []providerresolve.Resolved{ + { + LocalName: "toolp", + Source: "github.com/agentco/fake", + Category: commonv1.Category_CATEGORY_TOOL, + BinaryPath: "/dev/override/toolp", + ViaDevOverride: true, + }, + { + LocalName: "frontendp", + Source: "github.com/agentco/fake", + Category: commonv1.Category_CATEGORY_FRONTEND, + BinaryPath: "/dev/override/frontendp", + ViaDevOverride: true, + }, + } + + sup, err := NewSupervisor(cfg) + if err != nil { + t.Fatalf("NewSupervisor: %v", err) + } + sup.launch = func(_ context.Context, r providerresolve.Resolved, _ *callbackSlot) (*launchedPlugin, error) { + return &launchedPlugin{ + client: clients[r.LocalName], + close: func(context.Context) error { return nil }, + }, nil + } + return sup, toolCfg, frontendCfg +} + +// TestPrepare_registersWithoutConfiguring pins the window the split +// exists to create: after Prepare every provider is launched, described, +// and registered — so its real category is known and the provider +// catalog can be built over it — while none has been configured yet. +func TestPrepare_registersWithoutConfiguring(t *testing.T) { + t.Parallel() + + s, toolCfg, frontendCfg := phaseHarness(t) + + if err := s.Prepare(t.Context()); err != nil { + t.Fatalf("Prepare: %v", err) + } + for _, name := range []string{"toolp", "frontendp"} { + if _, ok := s.cfg.Registry.ByLocalName(name); !ok { + t.Errorf("ByLocalName(%q) reported ok = false after Prepare, want it registered", name) + } + } + if toolCfg.got != nil { + t.Error("Prepare configured the tool provider; Configure owns that step") + } + if frontendCfg.got != nil { + t.Error("Prepare configured the frontend provider; Configure owns that step") + } +} + +// TestConfigure_honorsTheCategoryPredicate walks the exact two-pass +// sequence internal/kernel makes. The frontend staying unconfigured +// through the first pass is the whole point: it calls CreateSession from +// inside its own Configure handler, and the host answering that call +// does not exist until the kernel has built a catalog over the providers +// the first pass configured. +func TestConfigure_honorsTheCategoryPredicate(t *testing.T) { + t.Parallel() + + s, toolCfg, frontendCfg := phaseHarness(t) + ctx := t.Context() + + if err := s.Prepare(ctx); err != nil { + t.Fatalf("Prepare: %v", err) + } + + notFrontend := func(c commonv1.Category) bool { return c != commonv1.Category_CATEGORY_FRONTEND } + if err := s.Configure(ctx, notFrontend); err != nil { + t.Fatalf("Configure(non-frontend): %v", err) + } + if toolCfg.got == nil { + t.Error("the non-frontend pass did not configure the tool provider") + } + if frontendCfg.got != nil { + t.Fatal("the non-frontend pass configured the frontend: it would call back into a kernel that does not exist yet") + } + + onlyFrontend := func(c commonv1.Category) bool { return c == commonv1.Category_CATEGORY_FRONTEND } + if err := s.Configure(ctx, onlyFrontend); err != nil { + t.Fatalf("Configure(frontend): %v", err) + } + if frontendCfg.got == nil { + t.Error("the frontend pass did not configure the frontend provider") + } +} + +// TestConfigure_skipsAlreadyConfigured asserts a provider is configured +// exactly once across passes. Without it, the kernel's second pass — or +// any later catch-all — would re-issue Configure to plugins that already +// have their config, which the category triple does not model as +// idempotent. +func TestConfigure_skipsAlreadyConfigured(t *testing.T) { + t.Parallel() + + s, toolCfg, frontendCfg := phaseHarness(t) + ctx := t.Context() + + if err := s.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + if toolCfg.got == nil || frontendCfg.got == nil { + t.Fatal("Start left a provider unconfigured") + } + + // Clearing the recorders makes a redundant Configure visible: a + // second call would write them again. + toolCfg.got, frontendCfg.got = nil, nil + if err := s.Configure(ctx, nil); err != nil { + t.Fatalf("Configure after Start: %v", err) + } + if toolCfg.got != nil { + t.Error("the tool provider was configured twice") + } + if frontendCfg.got != nil { + t.Error("the frontend provider was configured twice") + } +} diff --git a/internal/pluginhost/registry.go b/internal/pluginhost/registry.go index 8aae73c..a65668f 100644 --- a/internal/pluginhost/registry.go +++ b/internal/pluginhost/registry.go @@ -87,6 +87,20 @@ type Live struct { closeFn func(context.Context) error } +// Exited reports whether this plugin's subprocess has terminated. It +// forwards to the underlying handle rather than exposing it, keeping the +// "its lifecycle belongs to Supervisor" rule above intact: a caller can +// observe that a plugin is gone without being able to end one. +// +// A Live built by a test with no real subprocess reports false — there is +// nothing that could have exited. +func (l *Live) Exited() bool { + if l.plugin == nil { + return false + } + return l.plugin.Exited() +} + // ModelClient returns this plugin's ModelService client, or ok=false if // it is not a model plugin. func (l *Live) ModelClient() (modelv1.ModelServiceClient, bool) { diff --git a/internal/pluginhost/slot.go b/internal/pluginhost/slot.go index 00d2029..929ad3f 100644 --- a/internal/pluginhost/slot.go +++ b/internal/pluginhost/slot.go @@ -128,3 +128,83 @@ func (s *callbackSlot) ReadEvents(req *kernelv1.ReadEventsRequest, stream grpc.S func (s *callbackSlot) GetSession(ctx context.Context, req *kernelv1.GetSessionRequest) (*kernelv1.GetSessionResult, error) { return s.server().GetSession(ctx, req) } + +// CreateSession forwards to the installed server. +func (s *callbackSlot) CreateSession(ctx context.Context, req *kernelv1.CreateSessionRequest) (*kernelv1.CreateSessionResult, error) { + return s.server().CreateSession(ctx, req) +} + +// AttachSession forwards to the installed server. +func (s *callbackSlot) AttachSession(ctx context.Context, req *kernelv1.AttachSessionRequest) (*kernelv1.AttachSessionResult, error) { + return s.server().AttachSession(ctx, req) +} + +// ResumeSession forwards to the installed server. +func (s *callbackSlot) ResumeSession(ctx context.Context, req *kernelv1.ResumeSessionRequest) (*kernelv1.ResumeSessionResult, error) { + return s.server().ResumeSession(ctx, req) +} + +// DetachSession forwards to the installed server. +func (s *callbackSlot) DetachSession(ctx context.Context, req *kernelv1.DetachSessionRequest) (*kernelv1.DetachSessionResult, error) { + return s.server().DetachSession(ctx, req) +} + +// ListSessions forwards to the installed server. +func (s *callbackSlot) ListSessions(ctx context.Context, req *kernelv1.ListSessionsRequest) (*kernelv1.ListSessionsResult, error) { + return s.server().ListSessions(ctx, req) +} + +// GetSessionState forwards to the installed server. +func (s *callbackSlot) GetSessionState(ctx context.Context, req *kernelv1.GetSessionStateRequest) (*kernelv1.GetSessionStateResult, error) { + return s.server().GetSessionState(ctx, req) +} + +// SubmitInput forwards to the installed server. +func (s *callbackSlot) SubmitInput(ctx context.Context, req *kernelv1.SubmitInputRequest) (*kernelv1.SubmitInputResult, error) { + return s.server().SubmitInput(ctx, req) +} + +// Interrupt forwards to the installed server. +func (s *callbackSlot) Interrupt(ctx context.Context, req *kernelv1.InterruptRequest) (*kernelv1.InterruptResult, error) { + return s.server().Interrupt(ctx, req) +} + +// PublishMetadata forwards to the installed server. +func (s *callbackSlot) PublishMetadata(ctx context.Context, req *kernelv1.PublishMetadataRequest) (*kernelv1.PublishMetadataResult, error) { + return s.server().PublishMetadata(ctx, req) +} + +// RetractMetadata forwards to the installed server. +func (s *callbackSlot) RetractMetadata(ctx context.Context, req *kernelv1.RetractMetadataRequest) (*kernelv1.RetractMetadataResult, error) { + return s.server().RetractMetadata(ctx, req) +} + +// ListMetadata forwards to the installed server. +func (s *callbackSlot) ListMetadata(ctx context.Context, req *kernelv1.ListMetadataRequest) (*kernelv1.ListMetadataResult, error) { + return s.server().ListMetadata(ctx, req) +} + +// ResolvePlanDecision forwards to the installed server. +func (s *callbackSlot) ResolvePlanDecision(ctx context.Context, req *kernelv1.ResolvePlanDecisionRequest) (*kernelv1.ResolvePlanDecisionResult, error) { + return s.server().ResolvePlanDecision(ctx, req) +} + +// ResolveInteractive forwards to the installed server. +func (s *callbackSlot) ResolveInteractive(ctx context.Context, req *kernelv1.ResolveInteractiveRequest) (*kernelv1.ResolveInteractiveResult, error) { + return s.server().ResolveInteractive(ctx, req) +} + +// InvokeSlashCommand forwards to the installed server. +func (s *callbackSlot) InvokeSlashCommand(ctx context.Context, req *kernelv1.InvokeSlashCommandRequest) (*kernelv1.InvokeSlashCommandResult, error) { + return s.server().InvokeSlashCommand(ctx, req) +} + +// TriggerAction forwards to the installed server. +func (s *callbackSlot) TriggerAction(ctx context.Context, req *kernelv1.TriggerActionRequest) (*kernelv1.TriggerActionResult, error) { + return s.server().TriggerAction(ctx, req) +} + +// StreamDeltas forwards to the installed server. +func (s *callbackSlot) StreamDeltas(req *kernelv1.StreamDeltasRequest, stream kernelv1.KernelCallbackService_StreamDeltasServer) error { + return s.server().StreamDeltas(req, stream) +} diff --git a/internal/pluginhost/slot_coverage_test.go b/internal/pluginhost/slot_coverage_test.go new file mode 100644 index 0000000..2a774a6 --- /dev/null +++ b/internal/pluginhost/slot_coverage_test.go @@ -0,0 +1,77 @@ +package pluginhost + +// Unit tier: the structural guard that callbackSlot forwards every RPC +// the kernel-callback service declares. +// +// This is deliberately separate from TestCallbackSlot_forwardsEveryRPC, +// which proves forwarding *works* for a hand-picked set. That test cannot +// catch a newly added RPC nobody remembered to list in it — and did not: +// the frontend state-surface RPCs (CreateSession and fifteen others) went +// in unforwarded, so a frontend calling CreateSession from its Configure +// handler got the generated "method CreateSession not implemented" stub +// instead of the kernel. Enumerating from the generated descriptor is +// what makes the check exhaustive by construction. + +import ( + "os" + "regexp" + "testing" + + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" +) + +// declaredForwards returns every RPC name slot.go declares a callbackSlot +// method for. +// +// It reads the source rather than reflecting over the type because +// reflection cannot tell a declared method from one promoted out of the +// embedded UnimplementedKernelCallbackServiceServer: Go names the +// promoted wrapper after the outer type, so both appear as +// (*callbackSlot).Name. The distinction is exactly what this test exists +// to make, and only the source has it. +func declaredForwards(t *testing.T) map[string]bool { + t.Helper() + + src, err := os.ReadFile("slot.go") + if err != nil { + t.Fatalf("read slot.go: %v", err) + } + re := regexp.MustCompile(`func \(s \*callbackSlot\) (\w+)\(`) + out := make(map[string]bool) + for _, m := range re.FindAllStringSubmatch(string(src), -1) { + out[m[1]] = true + } + return out +} + +// TestCallbackSlot_declaresEveryServiceMethod fails with the exact list +// of RPCs missing a forward, so the fix is mechanical. +func TestCallbackSlot_declaresEveryServiceMethod(t *testing.T) { + t.Parallel() + + declared := declaredForwards(t) + sd := kernelv1.KernelCallbackService_ServiceDesc + + var missing []string + for _, m := range sd.Methods { + if !declared[m.MethodName] { + missing = append(missing, m.MethodName) + } + } + for _, s := range sd.Streams { + if !declared[s.StreamName] { + missing = append(missing, s.StreamName) + } + } + + if len(missing) > 0 { + t.Errorf("slot.go declares no forward for %d RPC(s): %v\n"+ + "Each would silently answer the generated \"method X not implemented\" "+ + "stub instead of reaching internal/kernelcallback. Add a forwarding "+ + "method to slot.go for each.", len(missing), missing) + } + + if want := len(sd.Methods) + len(sd.Streams); len(declared) < want { + t.Errorf("slot.go declares %d callbackSlot methods, want at least %d (one per RPC)", len(declared), want) + } +} diff --git a/internal/pluginhost/supervisor.go b/internal/pluginhost/supervisor.go index 8ddd819..4eec297 100644 --- a/internal/pluginhost/supervisor.go +++ b/internal/pluginhost/supervisor.go @@ -15,6 +15,7 @@ import ( "github.com/pluggableharness/agent/internal/eventbus" "github.com/pluggableharness/agent/internal/kernelcallback" "github.com/pluggableharness/agent/internal/log" + "github.com/pluggableharness/agent/internal/metadata" "github.com/pluggableharness/agent/internal/pluginruntime" "github.com/pluggableharness/agent/internal/providerresolve" "github.com/pluggableharness/agent/internal/registry" @@ -109,6 +110,15 @@ type Config struct { // server for CountTokens. MUST be set. Tokens *tokencount.Counter + // Metadata is the process-wide MetadataBlock store. MAY be nil. + Metadata *metadata.Store + + // Deltas is the live TokenDelta hub for StreamDeltas. MAY be nil. + Deltas *kernelcallback.DeltaHub + + // HostSlot is the late-bound frontend host. MAY be nil. + HostSlot *kernelcallback.HostSlot + // ProviderBodies is config.Config.ProviderBodies — each provider{} // block's raw, undecoded HCL body, keyed by local name. A local name // with no entry is configured with an empty body, which is the @@ -187,6 +197,23 @@ type Supervisor struct { mu sync.Mutex launched []*Live shutDown bool + + // prepped carries the state Prepare hands to Configure, in launch + // order. Deliberately not guarded by mu: unlike launched, nothing + // outside the single bring-up goroutine that drives Prepare and + // Configure ever touches it — Shutdown works from launched. + prepped []*preparedPlugin +} + +// preparedPlugin is one provider that Prepare took all the way to +// registration, carrying forward the two things Configure still needs: +// the decoded provider{} config to send, and the Live whose category +// decides whether a given Configure pass admits it. +type preparedPlugin struct { + resolved providerresolve.Resolved + live *Live + decoded *structpb.Struct + configured bool } // launched is one successfully launched subprocess, in the terms the @@ -217,38 +244,104 @@ func NewSupervisor(cfg Config) (*Supervisor, error) { return s, nil } -// Start brings up every resolved provider, in order, all-or-nothing. -// -// Each provider runs the same sequence: build its late-bound callback -// slot, launch the subprocess, Describe it and reconcile that identity -// against the lock file, verify the binary's checksum, fetch its -// capability advertisement, decode its provider{} block against the -// ConfigSchema that advertisement carried, install the real identity and -// decoded config into the slot, Configure, and register. +// Start brings up every resolved provider, in order, all-or-nothing: +// Prepare, then Configure over every category. // // A failure anywhere tears down every provider already launched, in // reverse order, before returning — a half-started kernel is never // handed to a session. func (s *Supervisor) Start(ctx context.Context) error { + if err := s.Prepare(ctx); err != nil { + return err + } + return s.Configure(ctx, nil) +} + +// Prepare runs every provider's bring-up sequence short of Configure: +// build its late-bound callback slot, launch the subprocess, Describe it +// and reconcile that identity against the lock file, verify the binary's +// checksum, fetch its capability advertisement, decode its provider{} +// block against the ConfigSchema that advertisement carried, install the +// real identity and decoded config into the slot, and register. +// +// Prepare and Configure are separable so the kernel can stand up +// everything a frontend consumes — the provider catalog, the hook +// chains, the session runner, the frontend host — before any frontend's +// Configure handler runs. kernel-callbacks.md permits a plugin to call +// back from inside Configure, and a frontend's first act is to create a +// session, so a single-phase bring-up answers that call against a kernel +// that does not exist yet. +// +// The split falls exactly here because Describe is what reveals a dev +// override's category (its lock file has none). Before Prepare there is +// nothing to sequence on; after it, every category is known. +func (s *Supervisor) Prepare(ctx context.Context) error { for i, resolved := range s.cfg.Resolved { - if err := s.startOne(ctx, i, resolved); err != nil { - // The teardown error is deliberately swallowed rather than - // joined: the caller needs to act on why startup failed, and - // go-style.md forbids logging and returning the same error, - // so the one that IS swallowed is the one logged. - if teardownErr := s.Shutdown(ctx); teardownErr != nil { - s.logger.ErrorContext(ctx, "pluginhost: teardown after failed start", - "provider", resolved.LocalName, "error", teardownErr) - } + if err := s.prepareOne(ctx, i, resolved); err != nil { + s.teardownAfterFailure(ctx, resolved.LocalName) + return err + } + } + s.logger.InfoContext(ctx, "pluginhost: all providers prepared", "count", len(s.cfg.Resolved)) + return nil +} + +// Configure issues Configure to every prepared provider whose category +// want admits, in launch order, skipping any already configured. A nil +// want admits every category. +// +// Two calls with complementary predicates configure each provider +// exactly once, which is how the kernel sequences frontends last. +func (s *Supervisor) Configure(ctx context.Context, want func(commonv1.Category) bool) error { + for _, p := range s.prepped { + if p.configured || (want != nil && !want(p.live.Producer.GetCategory())) { + continue + } + if err := s.configureOne(ctx, p); err != nil { + s.teardownAfterFailure(ctx, p.resolved.LocalName) return err } } - s.logger.InfoContext(ctx, "pluginhost: all providers started", "count", len(s.cfg.Resolved)) return nil } -// startOne runs one provider's whole bring-up sequence. -func (s *Supervisor) startOne(ctx context.Context, index int, resolved providerresolve.Resolved) (err error) { +// teardownAfterFailure tears down every provider already launched after +// a bring-up failure. +// +// The teardown error is deliberately swallowed rather than joined: the +// caller needs to act on why bring-up failed, and go-style.md forbids +// logging and returning the same error, so the one that IS swallowed is +// the one logged. +func (s *Supervisor) teardownAfterFailure(ctx context.Context, provider string) { + if teardownErr := s.Shutdown(ctx); teardownErr != nil { + s.logger.ErrorContext(ctx, "pluginhost: teardown after failed start", + "provider", provider, "error", teardownErr) + } +} + +// configureOne issues one prepared provider's Configure — the step +// Prepare deliberately leaves undone. +func (s *Supervisor) configureOne(ctx context.Context, p *preparedPlugin) (err error) { + ctx, span := s.cfg.Telemetry.StartProviderBringUp(ctx, p.resolved.LocalName, categoryText(p.live.Producer.GetCategory())) + defer func() { telemetry.EndSpan(span, err) }() + + if err = configurePlugin(ctx, p.live.Client, p.decoded); err != nil { + return fmt.Errorf("pluginhost: %s: %w", p.resolved.LocalName, err) + } + p.configured = true + + s.logger.InfoContext(ctx, "pluginhost: provider started", + "provider", p.resolved.LocalName, + "producer_category", p.live.Producer.GetCategory().String(), + "producer_name", p.live.Producer.GetName(), + "producer_version", p.live.Producer.GetVersion(), + "launch_index", p.live.LaunchIndex) + return nil +} + +// prepareOne runs one provider's bring-up sequence up to, but not +// including, Configure. +func (s *Supervisor) prepareOne(ctx context.Context, index int, resolved providerresolve.Resolved) (err error) { ctx, span := s.cfg.Telemetry.StartProviderBringUp(ctx, resolved.LocalName, categoryText(resolved.Category)) defer func() { telemetry.EndSpan(span, err) }() @@ -310,11 +403,7 @@ func (s *Supervisor) startOne(ctx context.Context, index int, resolved providerr // not tidiness. slot.set(s.newCallbackServer(producer, decoded)) - // Step 8: Configure, then register. - if err = configurePlugin(ctx, client, decoded); err != nil { - return fmt.Errorf("pluginhost: %s: %w", resolved.LocalName, err) - } - + // Step 8: register. Configure is Configure's job — see Prepare. live := &Live{ LocalName: resolved.LocalName, Producer: producer, @@ -333,7 +422,13 @@ func (s *Supervisor) startOne(ctx context.Context, index int, resolved providerr s.launched = append(s.launched, live) s.mu.Unlock() - s.logger.InfoContext(ctx, "pluginhost: provider started", + s.prepped = append(s.prepped, &preparedPlugin{ + resolved: resolved, + live: live, + decoded: decoded, + }) + + s.logger.DebugContext(ctx, "pluginhost: provider prepared", "provider", resolved.LocalName, "producer_category", producer.GetCategory().String(), "producer_name", producer.GetName(), @@ -424,6 +519,9 @@ func (s *Supervisor) newCallbackServer(producer *commonv1.ProducerRef, resolvedC Scopes: s.cfg.Scopes, Sessions: s.cfg.Sessions, Tokens: s.cfg.Tokens, + Metadata: s.cfg.Metadata, + Deltas: s.cfg.Deltas, + HostSlot: s.cfg.HostSlot, Logger: s.logger, }) } diff --git a/internal/pluginhost/testdata/plugin/main.go b/internal/pluginhost/testdata/plugin/main.go index 83c910c..c99a978 100644 --- a/internal/pluginhost/testdata/plugin/main.go +++ b/internal/pluginhost/testdata/plugin/main.go @@ -28,6 +28,7 @@ import ( "context" "fmt" "log/slog" + "os" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" "github.com/pluggableharness/agent/pkg/config" @@ -116,13 +117,23 @@ func (p *fixtureProvider) Configure(ctx context.Context, cfg map[string]any) err return nil } -// Schema satisfies tool.Provider with one trivial operation. -func (p *fixtureProvider) Schema(context.Context) ([]*tool.Schema, error) { +// Tools satisfies tool.Provider with one trivial operation. +func (p *fixtureProvider) Tools() []tool.Tool { + return []tool.Tool{&echoTool{}} +} + +// echoTool is the single operation this fixture exposes: it echoes its +// arguments straight back. +type echoTool struct{} + +var _ tool.Tool = (*echoTool)(nil) + +func (*echoTool) Schema() (*tool.Schema, error) { empty, err := schema.Object(nil) if err != nil { return nil, err } - return []*tool.Schema{{ + return &tool.Schema{ Name: "fixture_echo", Kind: tool.KindResource, Risk: tool.RiskClassLow, @@ -131,12 +142,12 @@ func (p *fixtureProvider) Schema(context.Context) ([]*tool.Schema, error) { OutputSchema: empty, Concurrency: &tool.ConcurrencySpec{Safe: true}, Idempotent: true, - }}, nil + }, nil } // Invoke is never called by this fixture's tests but must exist to -// satisfy tool.Provider. -func (p *fixtureProvider) Invoke(_ context.Context, call *tool.Call, stream *tool.Stream) error { +// satisfy tool.Tool. +func (*echoTool) Invoke(_ context.Context, call *tool.Call, stream *tool.Stream) error { return stream.Send(tool.NewResultEvent(map[string]any{"echo": call.Arguments})) } @@ -149,10 +160,16 @@ func main() { Source: fixtureSource, } + svc, err := tool.NewService(provider, id, callback) + if err != nil { + slog.Error("fixture: tool.NewService", "err", err) + os.Exit(1) + } + plugin.Serve(plugin.Config{ Identity: id, Category: commonv1.Category_CATEGORY_TOOL, Callback: callback, - Services: []plugin.Service{tool.NewService(provider, id, callback)}, + Services: []plugin.Service{svc}, }) } diff --git a/internal/pluginruntime/launch.go b/internal/pluginruntime/launch.go index 8cf01b6..c6f9953 100644 --- a/internal/pluginruntime/launch.go +++ b/internal/pluginruntime/launch.go @@ -162,6 +162,25 @@ func (p *Plugin) Producer() *commonv1.ProducerRef { return p.producer } +// Exited reports whether this plugin's subprocess has terminated. +// +// Read-only on purpose, and deliberately not an exposed *plugin.Client: +// observing that a subprocess is gone is a different capability from +// being able to kill one, and only the caller that launched it should +// have the latter (the same reasoning that keeps Plugin.client +// unexported). +// +// go-plugin offers no completion channel, so a caller that needs to wait +// on this polls it. That is the intended use: a frontend-hosting kernel +// has nothing else to tell it the operator closed the UI, because +// deleting Attach removed the stream whose closure used to say so. +func (p *Plugin) Exited() bool { + if p.client == nil { + return true + } + return p.client.Exited() +} + // preflightVersionCheck implements launch step 1: a no-op today, since // nothing populates a real protocol version anywhere yet (no registry/ // lockfile field carries one) — operator decision #2. Once something diff --git a/internal/pluginruntime/launch_integration_test.go b/internal/pluginruntime/launch_integration_test.go index 26f6223..d2974da 100644 --- a/internal/pluginruntime/launch_integration_test.go +++ b/internal/pluginruntime/launch_integration_test.go @@ -190,10 +190,16 @@ func TestLaunch_realSubprocess(t *testing.T) { t.Fatalf("GetSchema: tools = %v, want exactly one fixture_echo", tools) } - // The fixture's Log callback fires from a background goroutine on its - // side (see testdata/plugin/main.go's GRPCServer) — poll for it - // rather than assuming synchronous delivery, bounded well inside the - // 5s integration-test budget. + // The fixture logs back through the kernel callback from inside its + // Configure handler — the schema path can't, because tool.Tool.Schema + // is a static declaration with no context to call back on. + if _, err := client.Configure(ctx, &toolv1.ConfigureRequest{}); err != nil { + t.Fatalf("Configure: %v", err) + } + + // That Log callback is delivered from a background goroutine on the + // fixture's side — poll for it rather than assuming synchronous + // delivery, bounded well inside the 5s integration-test budget. deadline := time.Now().Add(3 * time.Second) for !h.hasFixtureLog(producer) { if time.Now().After(deadline) { diff --git a/internal/pluginruntime/testdata/plugin/main.go b/internal/pluginruntime/testdata/plugin/main.go index 920ee85..cc17bb7 100644 --- a/internal/pluginruntime/testdata/plugin/main.go +++ b/internal/pluginruntime/testdata/plugin/main.go @@ -4,8 +4,9 @@ // integration tier (launch_integration_test.go) builds and launches as a // real subprocess, to exercise a genuine hashicorp/go-plugin round-trip: // one canned ToolService.GetSchema RPC, plus one callback into -// KernelCallbackService.Log over the fixed callback broker ID -// (pkg/common.CallbackBrokerID), proving the reverse channel. +// KernelCallbackService.Log — issued from its Configure handler, the +// nearest RPC with a context to call back on — over the fixed callback +// broker ID (pkg/common.CallbackBrokerID), proving the reverse channel. // // Built on pkg/plugin and pkg/tool — the plugin-author SDK a real // third-party plugin also imports — rather than hand-rolling the @@ -29,6 +30,7 @@ package main import ( "context" "log/slog" + "os" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" "github.com/pluggableharness/agent/pkg/hook" @@ -70,44 +72,54 @@ var ( _ hook.Observer = (*fixtureProvider)(nil) ) -// Schema returns a single, fixed tool.Schema — the "one canned RPC" this -// fixture exists to round-trip — and, on its way out, calls back into -// KernelCallbackService.Log via the SDK's own NewSlogHandler. This is the -// sanctioned call site for callback.Client per pkg/plugin's "callback- -// timing trap" doc comment: an RPC handler, invoked only once go-plugin -// has already begun dispensing this process's client to the kernel, never -// eagerly from a background goroutine at process start. -func (p *fixtureProvider) Schema(ctx context.Context) ([]*tool.Schema, error) { +// Tools returns the single fixed operation this fixture exposes — the "one +// canned RPC" it exists to round-trip. +func (p *fixtureProvider) Tools() []tool.Tool { + return []tool.Tool{&echoTool{}} +} + +// Configure accepts any config; this fixture takes none. On its way out it +// calls back into KernelCallbackService.Log via the SDK's own +// NewSlogHandler. This is the sanctioned call site for callback.Client per +// pkg/plugin's "callback-timing trap" doc comment: an RPC handler, invoked +// only once go-plugin has already begun dispensing this process's client to +// the kernel, never eagerly from a background goroutine at process start. +// It sits on Configure rather than on the schema path because a Tool's +// Schema is a static declaration with no context to call back on — the +// point of that signature. +func (p *fixtureProvider) Configure(ctx context.Context, _ map[string]any) error { if client, err := p.callback.Client(ctx); err == nil { slog.New(client.NewSlogHandler()).Info("fixture plugin started") } + return nil +} +// echoTool is the single operation this fixture exposes: it echoes its +// arguments straight back. +type echoTool struct{} + +var _ tool.Tool = (*echoTool)(nil) + +func (*echoTool) Schema() (*tool.Schema, error) { empty, err := schema.Object(nil) if err != nil { return nil, err } - return []*tool.Schema{ - { - Name: fixtureToolName, - Kind: tool.KindResource, - Risk: tool.RiskClassLow, - Description: "internal/pluginruntime integration fixture", - InputSchema: empty, - OutputSchema: empty, - Concurrency: &tool.ConcurrencySpec{Safe: true}, - Idempotent: true, - }, + return &tool.Schema{ + Name: fixtureToolName, + Kind: tool.KindResource, + Risk: tool.RiskClassLow, + Description: "internal/pluginruntime integration fixture", + InputSchema: empty, + OutputSchema: empty, + Concurrency: &tool.ConcurrencySpec{Safe: true}, + Idempotent: true, }, nil } -// Configure accepts any config; this fixture takes none. -func (p *fixtureProvider) Configure(context.Context, map[string]any) error { - return nil -} - // Invoke is never called by this fixture's test but must exist to satisfy -// tool.Provider. -func (p *fixtureProvider) Invoke(_ context.Context, call *tool.Call, stream *tool.Stream) error { +// tool.Tool. +func (*echoTool) Invoke(_ context.Context, call *tool.Call, stream *tool.Stream) error { return stream.Send(tool.NewResultEvent(map[string]any{"echo": call.Arguments})) } @@ -128,12 +140,18 @@ func main() { callback := plugin.NewCallback() provider := &fixtureProvider{callback: callback} + toolSvc, err := tool.NewService(provider, fixtureIdentity, callback) + if err != nil { + slog.Error("fixture: tool.NewService", "err", err) + os.Exit(1) + } + plugin.Serve(plugin.Config{ Identity: fixtureIdentity, Category: commonv1.Category_CATEGORY_TOOL, Callback: callback, Services: []plugin.Service{ - tool.NewService(provider, fixtureIdentity, callback), + toolSvc, hook.NewService(provider), }, }) diff --git a/internal/providercatalog/drivers/plugin/extract.go b/internal/providercatalog/drivers/plugin/extract.go index a4fc861..ead621a 100644 --- a/internal/providercatalog/drivers/plugin/extract.go +++ b/internal/providercatalog/drivers/plugin/extract.go @@ -49,12 +49,37 @@ func buildModels(ctx context.Context, logger *slog.Logger, reg *pluginhost.Regis client, _ := live.ModelClient() for _, spec := range resp.GetCapabilities().GetModels() { ref := agentprofile.ModelRef{Provider: live.LocalName, ID: spec.GetId()} - models[ref] = providercatalog.ModelHandle{ + handle := providercatalog.ModelHandle{ Ref: ref, Producer: live.Producer, Spec: spec, Client: client, } + models[ref] = handle + + // Every alias resolves to this same handle, so a profile + // naming `grok-4` reaches the model published as `grok-4.3`. + // + // The alias entry keeps the canonical Ref rather than its own: + // the ref is what the kernel bills, logs, and records against, + // and minting a second identity per alias is exactly the + // duplicate-model problem CatalogMetadata.aliases exists to + // retire. A canonical id already in the map wins — a vendor + // publishing an id that is also another model's alias means + // the real model, not the alias, is what the operator asked + // for. + for _, alias := range spec.GetCatalog().GetAliases() { + if alias == "" || alias == spec.GetId() { + continue + } + aliasRef := agentprofile.ModelRef{Provider: live.LocalName, ID: alias} + if _, taken := models[aliasRef]; taken { + logger.DebugContext(ctx, "providercatalog/plugin: alias shadowed by a real model id", + "provider", live.LocalName, "alias", alias, "canonical", spec.GetId()) + continue + } + models[aliasRef] = handle + } } } return models diff --git a/internal/providercatalog/drivers/plugin/extract_test.go b/internal/providercatalog/drivers/plugin/extract_test.go index 506888c..33e4b62 100644 --- a/internal/providercatalog/drivers/plugin/extract_test.go +++ b/internal/providercatalog/drivers/plugin/extract_test.go @@ -4,6 +4,8 @@ import ( "slices" "testing" + "github.com/pluggableharness/agent/internal/agentprofile" + "github.com/pluggableharness/agent/internal/pluginhost" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" contextv1 "github.com/pluggableharness/agent/pkg/context/proto/v1" modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" @@ -176,3 +178,83 @@ func TestDedupeSchemas_noDuplicatesIsIdentity(t *testing.T) { } } } + +// TestBuildModels_aliasesResolveToTheCanonicalHandle asserts a model +// published with aliases is reachable under every one of them, and that +// each alias keeps the canonical Ref. +// +// The Ref matters as much as the reachability: it is what the kernel +// bills, logs, and records against, so an alias minting its own identity +// would split one model's cost and history across several names — the +// duplicate-model problem CatalogMetadata.aliases exists to retire. +func TestBuildModels_aliasesResolveToTheCanonicalHandle(t *testing.T) { + t.Parallel() + + reg := pluginhost.NewRegistry() + if err := reg.Add(live("xai", commonv1.Category_CATEGORY_MODEL, "xai", 0, nilModelClient(), + &modelv1.GetCapabilitiesResponse{Capabilities: &modelv1.Capabilities{Models: []*modelv1.ModelSpec{{ + Id: "grok-4.3", + ContextWindow: 256_000, + Catalog: &modelv1.CatalogMetadata{Aliases: []string{"grok-4", "grok-latest"}}, + }}}})); err != nil { + t.Fatalf("Add: %v", err) + } + + models := buildModels(t.Context(), testLogger(), reg) + + canonical := agentprofile.ModelRef{Provider: "xai", ID: "grok-4.3"} + for _, id := range []string{"grok-4.3", "grok-4", "grok-latest"} { + got, ok := models[agentprofile.ModelRef{Provider: "xai", ID: id}] + if !ok { + t.Errorf("model %q is not reachable", id) + continue + } + if got.Ref != canonical { + t.Errorf("model %q: Ref = %+v, want the canonical %+v", id, got.Ref, canonical) + } + if got.Spec.GetId() != "grok-4.3" { + t.Errorf("model %q: Spec.Id = %q, want grok-4.3", id, got.Spec.GetId()) + } + } +} + +// TestBuildModels_aRealIDBeatsAnAlias covers the collision: when one +// model's alias is another model's real id, the real model wins. Order +// must not decide it, so both orderings are asserted. +func TestBuildModels_aRealIDBeatsAnAlias(t *testing.T) { + t.Parallel() + + aliasing := &modelv1.ModelSpec{ + Id: "big", + Catalog: &modelv1.CatalogMetadata{Aliases: []string{"small"}}, + } + realModel := &modelv1.ModelSpec{Id: "small"} + + for _, tc := range []struct { + name string + specs []*modelv1.ModelSpec + }{ + {"alias declared first", []*modelv1.ModelSpec{aliasing, realModel}}, + {"real model declared first", []*modelv1.ModelSpec{realModel, aliasing}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + reg := pluginhost.NewRegistry() + if err := reg.Add(live("v", commonv1.Category_CATEGORY_MODEL, "v", 0, nilModelClient(), + &modelv1.GetCapabilitiesResponse{Capabilities: &modelv1.Capabilities{Models: tc.specs}})); err != nil { + t.Fatalf("Add: %v", err) + } + + models := buildModels(t.Context(), testLogger(), reg) + + got, ok := models[agentprofile.ModelRef{Provider: "v", ID: "small"}] + if !ok { + t.Fatal(`model "small" is not reachable`) + } + if got.Spec.GetId() != "small" { + t.Errorf(`"small" resolved to %q, want the real model regardless of declaration order`, got.Spec.GetId()) + } + }) + } +} diff --git a/internal/session/handle.go b/internal/session/handle.go new file mode 100644 index 0000000..2cd4666 --- /dev/null +++ b/internal/session/handle.go @@ -0,0 +1,366 @@ +package session + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + "sync" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" + + "github.com/pluggableharness/agent/internal/bounds" + "github.com/pluggableharness/agent/internal/eventbus" + "github.com/pluggableharness/agent/internal/sessionstate" + "github.com/pluggableharness/agent/internal/statebackend" +) + +// TopicState is the event-bus topic for SessionState republish. +const TopicState = "kernel.state" + +// ErrHandleClosed reports use of a Handle after Close. +var ErrHandleClosed = errors.New("session: handle closed") + +// ErrSessionBusy reports Submit while another Submit is in flight. +var ErrSessionBusy = errors.New("session: session is busy") + +// Handle is one long-lived interactive session: Open then zero or more +// Submit calls. Unlike Runner.Run, Handle does not complete the session +// when the model finishes a response — the session stays RUNNING for the +// next operator input until Close or a bound/cancel terminates it. +type Handle struct { + r *Runner + st *run + sess *statebackend.Session + live *sessionstate.Live + releases []func() + + mu sync.Mutex + closed bool + busy bool + cancel context.CancelFunc +} + +// Open creates a session file, registers the live session, and returns a +// Handle without running any turns. Spec.Prompt, when non-empty, seeds +// history as the first user message but does not start a turn. +func (r *Runner) Open(ctx context.Context, spec Spec) (*Handle, error) { + res, err := r.resolve(spec) + if err != nil { + return nil, err + } + startedAt := r.clock() + sessionID := statebackend.NewSessionID(startedAt) + + sess, err := r.store.Create(ctx, statebackend.SessionMeta{ + SessionID: sessionID, + Profile: res.profileName, + Status: sessionv1.SessionStatus_SESSION_STATUS_RUNNING, + StartedAt: startedAt, + }) + if err != nil { + return nil, fmt.Errorf("session: open: create: %w", err) + } + + live := sessionstate.NewLive(sess, r.bus, res.limits, nil, r.clock, r.telem, r.logger) //nolint:contextcheck + r.sessions.Put(sessionID, live) + + releases := make([]func(), 0, len(res.keys)) + for _, key := range res.keys { + releases = append(releases, r.scopes.Grant(key, sessionID)) + } + + history := []*contentv1.Message{} + if spec.Prompt != "" { + history = append(history, userMessage(r.clock(), spec.Prompt)) + } + + st := &run{ + sessionID: sessionID, + spec: spec, + res: res, + budget: live.Budget(), + doom: r.newDetector(), + startedAt: startedAt, + history: history, + } + + h := &Handle{r: r, st: st, sess: sess, live: live, releases: releases} + r.logger.InfoContext(ctx, "session: opened", + "session_id", sessionID, + "profile", res.profileName, + "model_id", res.model.Ref.ID) + r.dispatchSessionStart(ctx, st) + _ = h.publishState(ctx) + return h, nil +} + +// SessionID returns this handle's session id. +func (h *Handle) SessionID() string { return h.st.sessionID } + +// WorkingDirectory returns the session's working directory. +func (h *Handle) WorkingDirectory() string { return h.st.spec.WorkingDirectory } + +// Submit appends operator content as a user message and runs turns until +// the model finishes (Done), a bound/doom/breaker fires, or ctx is +// canceled. Returns the first turn id of this submission. +func (h *Handle) Submit(ctx context.Context, content []*contentv1.ContentBlock) (turnID string, err error) { + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return "", ErrHandleClosed + } + if h.busy { + h.mu.Unlock() + return "", ErrSessionBusy + } + h.busy = true + runCtx, cancel := context.WithCancel(ctx) + h.cancel = cancel + h.mu.Unlock() + + defer func() { + h.mu.Lock() + h.busy = false + h.cancel = nil + h.mu.Unlock() + cancel() + }() + + if len(content) == 0 { + return "", fmt.Errorf("session: submit: content is required") + } + msg := &contentv1.Message{ + Id: statebackend.NewEventID(h.r.clock()), + Role: contentv1.Role_ROLE_USER, + Content: content, + } + h.st.history = append(h.st.history, msg) + + firstReq := h.r.request(h.st, false, "") + turnID = firstReq.TurnID + + for { + req := h.r.request(h.st, false, "") + if turnID == "" { + turnID = req.TurnID + } + result, turnErr := h.r.runTurn(runCtx, h.st, req) + if turnErr != nil { + status, _, failErr := h.r.turnFailure(runCtx, h.st, turnErr) + _ = h.markTerminal(context.WithoutCancel(runCtx), status) + return turnID, failErr + } + h.st.absorb(result) + h.st.doom.Observe(result.CallHashes) + _ = h.publishState(runCtx) + + if result.Done { + return turnID, nil + } + if h.st.doom.Tripped() { + h.r.telem.Instruments().DoomLoops.Add(runCtx, 1) + status, _, lerr := h.r.limitReached(runCtx, h.st, sessionv1.SessionStatus_SESSION_STATUS_COMPLETED, ReasonDoomLoop) + _ = h.markTerminal(context.WithoutCancel(runCtx), status) + return turnID, lerr + } + if fired := h.st.budget.Check(h.r.clock().Sub(h.st.startedAt)); fired != bounds.FiredNone { + reason := boundReason(fired) + status, _, lerr := h.r.limitReached(runCtx, h.st, fired.Status(), reason) + _ = h.markTerminal(context.WithoutCancel(runCtx), status) + return turnID, lerr + } + if len(result.TrippedProviders) > 0 { + status, _, lerr := h.r.limitReached(runCtx, h.st, sessionv1.SessionStatus_SESSION_STATUS_COMPLETED, ReasonCircuitBreaker) + _ = h.markTerminal(context.WithoutCancel(runCtx), status) + return turnID, lerr + } + h.st.turnIndex++ + } +} + +// Interrupt cancels the in-flight Submit, if any. +func (h *Handle) Interrupt() { + h.mu.Lock() + defer h.mu.Unlock() + if h.cancel != nil { + h.cancel() + } +} + +// Close finalizes the session as COMPLETED (if still RUNNING) and releases +// grants. Safe to call once. +func (h *Handle) Close(ctx context.Context) error { + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return nil + } + h.closed = true + if h.cancel != nil { + h.cancel() + } + h.mu.Unlock() + + finCtx := context.WithoutCancel(ctx) + meta, err := h.sess.Meta(finCtx) + if err == nil && meta.Status == sessionv1.SessionStatus_SESSION_STATUS_RUNNING { + _ = h.markTerminal(finCtx, sessionv1.SessionStatus_SESSION_STATUS_COMPLETED) + } + h.r.dispatchSessionEnd(finCtx, h.st, metaStatusOr(meta, sessionv1.SessionStatus_SESSION_STATUS_COMPLETED)) + // teardown deliberately takes no context: statebackend.Session.Close + // derives its own, precisely so a canceled caller context cannot prevent + // a session file from being checkpointed and closed. Same rationale as + // Runner.Run's own deferred teardown. + h.r.teardown(h.st.sessionID, h.live, h.releases) //nolint:contextcheck // see comment above + return nil +} + +func metaStatusOr(meta statebackend.SessionMeta, fallback sessionv1.SessionStatus) sessionv1.SessionStatus { + if meta.Status != sessionv1.SessionStatus_SESSION_STATUS_UNSPECIFIED { + return meta.Status + } + return fallback +} + +// State builds the fixed-schema SessionState snapshot. +func (h *Handle) State(ctx context.Context) (*sessionv1.SessionState, error) { + meta, err := h.sess.Meta(ctx) + if err != nil { + return nil, err + } + info := &sessionv1.SessionInfo{ + SessionId: meta.SessionID, + Profile: meta.Profile, + Status: meta.Status, + Depth: int32(meta.Depth), // #nosec G115 + StartedAt: timestamppb.New(meta.StartedAt), + } + if meta.ParentSessionID != "" { + info.ParentSessionId = &meta.ParentSessionID + } + if meta.EndedAt != nil { + info.EndedAt = timestamppb.New(*meta.EndedAt) + } + if cost := h.st.budget.TotalCostUSD(); cost != 0 { + info.CostUsd = &cost + } + + elapsed := h.r.clock().Sub(h.st.startedAt) + if meta.EndedAt != nil { + elapsed = meta.EndedAt.Sub(meta.StartedAt) + } + + state := &sessionv1.SessionState{ + Info: info, + WorkingDirectory: h.st.spec.WorkingDirectory, + TurnCount: int32(h.st.turnIndex + 1), // #nosec G115 — turns completed-ish + Elapsed: durationpb.New(elapsed), + TotalTokens: h.st.inTokens + h.st.outTokens, + } + if h.st.res.model.Ref.ID != "" { + state.Model = &sessionv1.ModelState{ + Id: h.st.res.model.Ref.ID, + Provider: h.st.res.model.Ref.Provider, + } + } + if h.st.res.target != nil && h.st.res.target.GetEffectiveCeiling() > 0 { + state.Context = &sessionv1.ContextState{ + UsedTokens: h.st.assembled, + WindowTokens: h.st.res.target.GetEffectiveCeiling(), + } + } + // Vendor-reported state from the most recent completion. Each is left + // absent rather than zero-filled when the vendor said nothing — + // "no reading" and "a reading of zero" are different facts to a + // status bar, and conflating them is how a usage meter starts lying. + state.Quotas = h.st.quotas + state.VendorCost = h.st.vendorCost + if h.st.actualModel != "" { + state.ActualModel = &h.st.actualModel + } + if wd := h.st.spec.WorkingDirectory; wd != "" { + if vcs := probeVCS(ctx, wd); vcs != nil { + state.Vcs = vcs + } + } + return state, nil +} + +// Info returns SessionInfo for lifecycle RPCs. +func (h *Handle) Info(ctx context.Context) (*sessionv1.SessionInfo, error) { + state, err := h.State(ctx) + if err != nil { + return nil, err + } + return state.GetInfo(), nil +} + +func (h *Handle) publishState(ctx context.Context) error { + state, err := h.State(ctx) + if err != nil { + return err + } + payload, err := proto.Marshal(state) + if err != nil { + return err + } + return h.r.bus.Publish(ctx, eventbus.Event{Topic: TopicState, Payload: payload}) +} + +func (h *Handle) markTerminal(ctx context.Context, status sessionv1.SessionStatus) error { + now := h.r.clock() + if err := h.sess.SetStatus(ctx, status, &now); err != nil { + h.r.logger.ErrorContext(ctx, "session: set status", "err", err) + return err + } + return h.publishState(ctx) +} + +// probeVCS best-effort reads git status for SessionState.Vcs. Every call is +// context-bound: `git status --porcelain` on a large working tree is not +// instant, and a SessionState snapshot must not outlive the request that +// asked for it. +// +// The three commands are the literal "git"; only the -C path varies, and git +// treats it as a path argument, never a shell fragment. +func probeVCS(ctx context.Context, dir string) *sessionv1.VcsState { + git := func(args ...string) ([]byte, error) { + // Both suppressions are load-bearing and neither is redundant: + // golangci-lint reads //nolint, while the standalone gosec the + // security workflow runs reads only #nosec, and #nosec binds only + // when it sits on the flagged line itself here — on a preceding + // line it does not attach to a node inside this closure. Carrying + // just the //nolint passed locally and failed CI. + //nolint:gosec // G204: constant command, path-only variable argument (see doc comment) + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) // #nosec G204 -- constant "git"; only the -C path varies + return cmd.Output() + } + + branchOut, err := git("rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return nil + } + branch := strings.TrimSpace(string(branchOut)) + remoteOut, _ := git("remote", "get-url", "origin") + remote := strings.TrimSpace(string(remoteOut)) + dirty := false + if statusOut, err := git("status", "--porcelain"); err == nil { + dirty = len(strings.TrimSpace(string(statusOut))) > 0 + } + vcs := &sessionv1.VcsState{} + if branch != "" { + vcs.Branch = &branch + } + if remote != "" { + vcs.Remote = &remote + } + vcs.Dirty = &dirty + return vcs +} diff --git a/internal/session/handle_test.go b/internal/session/handle_test.go new file mode 100644 index 0000000..de93084 --- /dev/null +++ b/internal/session/handle_test.go @@ -0,0 +1,61 @@ +package session + +import ( + "context" + "testing" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" + + "github.com/pluggableharness/agent/internal/agentprofile" +) + +func TestHandle_OpenSubmitStaysRunning(t *testing.T) { + t.Parallel() + + h := newHarness(t, profileWith(func(p *agentprofile.AgentProfile) { + p.Tools = []string{"filesystem.*"} + }), []step{done(0.10)}, true) + + handle, err := h.runner.Open(context.Background(), Spec{ + Prompt: "", + WorkingDirectory: "/work", + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = handle.Close(context.Background()) }) + + if _, ok := h.table.Get(handle.SessionID()); !ok { + t.Fatal("session not registered in live table") + } + + turnID, err := handle.Submit(context.Background(), []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: "hi"}}}, + }) + if err != nil { + t.Fatalf("Submit: %v", err) + } + if turnID == "" { + t.Fatal("turn_id empty") + } + + state, err := handle.State(context.Background()) + if err != nil { + t.Fatalf("State: %v", err) + } + if state.GetInfo().GetStatus() != sessionv1.SessionStatus_SESSION_STATUS_RUNNING { + t.Fatalf("status after submit = %v, want RUNNING", state.GetInfo().GetStatus()) + } + if state.GetWorkingDirectory() != "/work" { + t.Fatalf("working_directory = %q", state.GetWorkingDirectory()) + } + + // Second submit should also work (session stays open; scripted turn + // repeats its last done step). + if _, err := handle.Submit(context.Background(), []*contentv1.ContentBlock{ + {Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: "again"}}}, + }); err != nil { + t.Fatalf("second Submit: %v", err) + } +} diff --git a/internal/session/run.go b/internal/session/run.go index b7b6dd8..14882eb 100644 --- a/internal/session/run.go +++ b/internal/session/run.go @@ -9,6 +9,7 @@ import ( contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" hookv1 "github.com/pluggableharness/agent/pkg/hook/proto/v1" + modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" "github.com/pluggableharness/agent/internal/bounds" @@ -36,6 +37,18 @@ type run struct { final *contentv1.Message inTokens int64 outTokens int64 + + // Vendor-reported state from the most recent completion, surfaced on + // SessionState. Last-write-wins rather than accumulated: each is a + // reading of "how is the vendor serving me right now", and an older + // reading is not additive with a newer one the way token counts are. + // + // quotas is left untouched by a turn that reported none, so a + // mid-session completion whose headers omitted budgets does not blank + // a figure the operator was watching. + quotas []*modelv1.RateLimitSnapshot + vendorCost *modelv1.VendorCost + actualModel string } // Run executes one whole session per turn-algorithm.md and returns its @@ -294,6 +307,19 @@ func (st *run) absorb(result turn.Result) { st.inTokens += result.Usage.GetInputTokens() st.outTokens += result.Usage.GetOutputTokens() + // Only overwrite what this turn actually reported. A vendor that + // publishes budgets on some responses and not others would otherwise + // blank the operator's meter on every quiet turn. + if limits := result.Usage.GetRateLimits(); len(limits) > 0 { + st.quotas = limits + } + if vc := result.Usage.GetVendorCost(); vc != nil { + st.vendorCost = vc + } + if m := result.ActualModel; m != "" { + st.actualModel = m + } + st.budget.ObserveTurn() st.budget.Debit(result.CostUSD) } diff --git a/internal/streamaccum/streamaccum.go b/internal/streamaccum/streamaccum.go index 79502a0..c7f1974 100644 --- a/internal/streamaccum/streamaccum.go +++ b/internal/streamaccum/streamaccum.go @@ -4,7 +4,9 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" @@ -81,13 +83,41 @@ type Accumulator struct { openKind blockKind openText *contentv1.TextBlock openThinking *contentv1.ThinkingBlock - pendingSig []byte - tools map[string]*toolCallState + // openThinkingChannel is which reasoning stream the open thinking + // block belongs to, so a summary run and a raw-reasoning run stay + // separate blocks even when adjacent. + openThinkingChannel modelv1.StreamEvent_ThinkingChannel + pendingSig []byte + tools map[string]*toolCallState usage *modelv1.Usage stopReason modelv1.StopReason modelErr *modelv1.ModelError terminal bool + + // providerRequestID is the vendor's own identifier for this request, + // from a StreamStart event. Empty when the provider published none — + // events.proto marks StreamStart as MAY-omit. + providerRequestID string + + // correlationIDs are the other handles this request is known by, + // from StreamStart. Nil until one arrives. + correlationIDs map[string]string + + // metadata is every StreamMetadata event observed so far, merged + // field by field in arrival order. Nil until one arrives. + // + // Merged rather than replaced because events.proto specifies a later + // event as superseding an earlier one per field, with an absent field + // meaning "no new information" rather than "cleared" — keeping only + // the newest event would silently drop an actual_model reported once + // at the top of a stream. + metadata *modelv1.StreamEvent_StreamMetadata + + // safety are the vendor interventions reported during this stream, in + // arrival order. Accumulated rather than replaced: a request can be + // buffered and then moderated, and the sequence is the explanation. + safety []*modelv1.StreamEvent_SafetyNotice } // New returns a ready Accumulator with no content observed yet. @@ -113,11 +143,31 @@ func (a *Accumulator) Observe(ev *modelv1.StreamEvent) error { } switch e := ev.GetEvent().(type) { + case *modelv1.StreamEvent_StreamStart_: + // Purely informational, and deliberately not a block boundary: + // it carries no content and normally arrives before any, so + // closing an open block here would split a run of deltas that a + // vendor happened to precede with a late StreamStart. + a.observeStreamStart(e.StreamStart) + return nil + case *modelv1.StreamEvent_SafetyNotice_: + // Not a block boundary: it carries no content, and a vendor may + // interpose mid-completion, so closing an open block here would + // split a text run around a moderation notice. + a.safety = append(a.safety, e.SafetyNotice) + return nil + case *modelv1.StreamEvent_Metadata: + // Not a block boundary either, and for a stronger reason: + // events.proto explicitly permits this mid-stream, so closing an + // open block here would split a text run whenever a vendor + // revised its headers partway through a completion. + a.observeMetadata(e.Metadata) + return nil case *modelv1.StreamEvent_TextDelta_: a.observeTextDelta(e.TextDelta.GetText()) return nil case *modelv1.StreamEvent_ThinkingDelta_: - a.observeThinkingDelta(e.ThinkingDelta.GetText()) + a.observeThinkingDelta(e.ThinkingDelta.GetText(), e.ThinkingDelta.GetChannel()) return nil case *modelv1.StreamEvent_ThinkingSignature_: return a.observeThinkingSignature(e.ThinkingSignature.GetSignature()) @@ -175,6 +225,109 @@ func (a *Accumulator) Err() *modelv1.ModelError { return a.modelErr } +// observeStreamStart records the vendor's handles for this request. +// +// A second StreamStart overwrites rather than merges: the event means +// "the vendor accepted this request and named it", and two different +// names for one request is a provider bug, not a merge to reconcile. +func (a *Accumulator) observeStreamStart(ev *modelv1.StreamEvent_StreamStart) { + a.providerRequestID = ev.GetProviderRequestId() + if ids := ev.GetCorrelationIds(); len(ids) > 0 { + a.correlationIDs = maps.Clone(ids) + } +} + +// observeMetadata merges one StreamMetadata event into the accumulated +// view, field by field. +// +// Absent means "no new information", never "cleared" — so every field +// below is copied only when the incoming event actually set it. The +// repeated and map fields follow the same rule: a non-empty value +// replaces (rate_limits is a whole snapshot of every budget, so merging +// entry by entry would resurrect a budget the vendor stopped reporting), +// while attrs merges by key (it is an open bag of independent facts). +func (a *Accumulator) observeMetadata(ev *modelv1.StreamEvent_StreamMetadata) { + if a.metadata == nil { + a.metadata = &modelv1.StreamEvent_StreamMetadata{} + } + m := a.metadata + + if v := ev.ActualModel; v != nil { + m.ActualModel = proto.String(*v) + } + if v := ev.SystemFingerprint; v != nil { + m.SystemFingerprint = proto.String(*v) + } + if v := ev.ServiceTier; v != nil { + m.ServiceTier = proto.String(*v) + } + if v := ev.LiveContextWindow; v != nil { + m.LiveContextWindow = proto.Int64(*v) + } + if v := ev.LiveMaxOutputTokens; v != nil { + m.LiveMaxOutputTokens = proto.Int64(*v) + } + if v := ev.CatalogEtag; v != nil { + m.CatalogEtag = proto.String(*v) + } + if len(ev.GetRateLimits()) > 0 { + m.RateLimits = ev.GetRateLimits() + } + for k, v := range ev.GetAttrs() { + if m.Attrs == nil { + m.Attrs = make(map[string]string, len(ev.GetAttrs())) + } + m.Attrs[k] = v + } +} + +// Metadata returns every StreamMetadata event observed so far, merged +// into one value, or nil if the provider reported none. +// +// Readable mid-stream by design: actual_model and the rate-limit +// snapshots it carries are most useful while a completion is still +// running, which is the reason the event exists separately from the +// terminal usage payload. +// +// The returned message aliases the accumulator's own state — treat it as +// read-only, exactly as Result's Message and Usage are. +func (a *Accumulator) Metadata() *modelv1.StreamEvent_StreamMetadata { + return a.metadata +} + +// CorrelationIDs returns the additional vendor handles for this request +// from StreamStart, or nil if none were reported. The returned map is a +// copy: a caller stashing it for a support ticket must not be able to +// mutate what a later Result reports. +func (a *Accumulator) CorrelationIDs() map[string]string { + return maps.Clone(a.correlationIDs) +} + +// SafetyNotices returns the vendor interventions reported during this +// stream, in arrival order, or nil if there were none. +// +// Readable mid-stream deliberately: a BUFFERING notice explains a stall +// while the stall is still happening, which is the only time the +// explanation is worth anything. +// +// The returned slice aliases the accumulator's own state — read-only, +// like Result's Message and Usage. +func (a *Accumulator) SafetyNotices() []*modelv1.StreamEvent_SafetyNotice { + return a.safety +} + +// ProviderRequestID returns the vendor's own identifier for this request, +// as carried by a StreamStart event, or "" if the provider published +// none. It is opaque: surfaced so a failure can be correlated against the +// vendor's logs, never parsed. +// +// Unlike Result, this is readable mid-stream — the id arrives early +// precisely so it is available when the stream fails rather than only +// when it succeeds. +func (a *Accumulator) ProviderRequestID() string { + return a.providerRequestID +} + // closeOpenBlock finalizes whatever implicit text/thinking block is // currently open, per data-types.md#streamevent: a thinking block's // signature is "attached... once, at that block's own terminal point," @@ -210,13 +363,20 @@ func (a *Accumulator) observeTextDelta(text string) { // thinking block, opening a new one first if the previous event wasn't // itself a ThinkingDelta (or a ThinkingSignature belonging to the same // block) continuing it. -func (a *Accumulator) observeThinkingDelta(text string) { - if a.openKind != blockKindThinking { +func (a *Accumulator) observeThinkingDelta(text string, channel modelv1.StreamEvent_ThinkingChannel) { + // A channel switch closes the open block even though both sides are + // thinking. A vendor-written summary and the raw reasoning it + // summarizes are different text; concatenating them because they are + // adjacent and both "thinking" would produce one block that reads as + // neither. Providers that set no channel leave this UNSPECIFIED + // throughout, so their runs coalesce exactly as before. + if a.openKind != blockKindThinking || a.openThinkingChannel != channel { a.closeOpenBlock() th := &contentv1.ThinkingBlock{} a.blocks = append(a.blocks, &contentv1.ContentBlock{Block: &contentv1.ContentBlock_Thinking{Thinking: th}}) a.openThinking = th a.openKind = blockKindThinking + a.openThinkingChannel = channel } a.openThinking.Text += text } diff --git a/internal/streamaccum/streamaccum_test.go b/internal/streamaccum/streamaccum_test.go index 8561a0b..ef13474 100644 --- a/internal/streamaccum/streamaccum_test.go +++ b/internal/streamaccum/streamaccum_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" @@ -28,6 +29,10 @@ func textDelta(text string) *modelv1.StreamEvent { return &modelv1.StreamEvent{Event: &modelv1.StreamEvent_TextDelta_{TextDelta: &modelv1.StreamEvent_TextDelta{Text: text}}} } +func streamStart(id string) *modelv1.StreamEvent { + return &modelv1.StreamEvent{Event: &modelv1.StreamEvent_StreamStart_{StreamStart: &modelv1.StreamEvent_StreamStart{ProviderRequestId: id}}} +} + func thinkingDelta(text string) *modelv1.StreamEvent { return &modelv1.StreamEvent{Event: &modelv1.StreamEvent_ThinkingDelta_{ThinkingDelta: &modelv1.StreamEvent_ThinkingDelta{Text: text}}} } @@ -651,3 +656,316 @@ func structDiff(got, want *structpb.Struct) string { } return "" } + +// TestStreamStart_recordedWithoutAffectingContent pins that a real +// provider's opening event is accepted rather than rejected as an +// unhandled variant, and that it contributes nothing to the message. +func TestStreamStart_recordedWithoutAffectingContent(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + streamStart("req-abc123"), + textDelta("Hello World"), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + if got := a.ProviderRequestID(); got != "req-abc123" { + t.Errorf("ProviderRequestID = %q, want req-abc123", got) + } + msg, _, _, ok := a.Result() + if !ok { + t.Fatal("Result reported ok = false after a Stop event") + } + if len(msg.GetContent()) != 1 { + t.Fatalf("Content has %d blocks, want 1 — StreamStart must not create one", len(msg.GetContent())) + } + if got := msg.GetContent()[0].GetText().GetText(); got != "Hello World" { + t.Errorf("text = %q, want Hello World", got) + } +} + +// TestStreamStart_isNotABlockBoundary asserts a StreamStart arriving +// between two text deltas leaves the open block open. It carries no +// content, so splitting on it would fabricate a second block. +func TestStreamStart_isNotABlockBoundary(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + textDelta("Hello "), + streamStart("req-late"), + textDelta("World"), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + msg, _, _, ok := a.Result() + if !ok { + t.Fatal("Result reported ok = false after a Stop event") + } + if len(msg.GetContent()) != 1 { + t.Fatalf("Content has %d blocks, want 1 unsplit block", len(msg.GetContent())) + } + if got := msg.GetContent()[0].GetText().GetText(); got != "Hello World" { + t.Errorf("text = %q, want Hello World", got) + } +} + +// TestStreamStart_absentLeavesTheIDEmpty covers events.proto's MAY-omit: +// a provider that publishes no request id is not an error. +func TestStreamStart_absentLeavesTheIDEmpty(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + textDelta("hi"), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + if got := a.ProviderRequestID(); got != "" { + t.Errorf("ProviderRequestID = %q, want empty when no StreamStart was observed", got) + } +} + +// metaEvent wraps a StreamMetadata into a StreamEvent. +func metaEvent(m *modelv1.StreamEvent_StreamMetadata) *modelv1.StreamEvent { + return &modelv1.StreamEvent{Event: &modelv1.StreamEvent_Metadata{Metadata: m}} +} + +// TestStreamMetadata_isNotABlockBoundary is the property events.proto +// states explicitly: metadata may arrive mid-stream, so splitting a text +// run on it would corrupt any completion whose vendor revised its +// headers partway through. +func TestStreamMetadata_isNotABlockBoundary(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + textDelta("Hello "), + metaEvent(&modelv1.StreamEvent_StreamMetadata{ActualModel: proto.String("grok-4.3")}), + textDelta("World"), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + msg, _, _, ok := a.Result() + if !ok { + t.Fatal("Result reported ok = false after a Stop event") + } + if len(msg.GetContent()) != 1 { + t.Fatalf("Content has %d blocks, want 1 unsplit block", len(msg.GetContent())) + } + if got := msg.GetContent()[0].GetText().GetText(); got != "Hello World" { + t.Errorf("text = %q, want Hello World", got) + } + if got := a.Metadata().GetActualModel(); got != "grok-4.3" { + t.Errorf("ActualModel = %q, want grok-4.3", got) + } +} + +// TestStreamMetadata_mergesFieldByField pins the supersede rule: a later +// event overwrites the fields it sets and leaves the rest alone. Keeping +// only the newest event instead would drop an actual_model reported once +// at the top of a stream. +func TestStreamMetadata_mergesFieldByField(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + metaEvent(&modelv1.StreamEvent_StreamMetadata{ + ActualModel: proto.String("grok-4.3"), + SystemFingerprint: proto.String("fp_1"), + Attrs: map[string]string{"a": "1"}, + }), + metaEvent(&modelv1.StreamEvent_StreamMetadata{ + SystemFingerprint: proto.String("fp_2"), + ServiceTier: proto.String("fast"), + Attrs: map[string]string{"b": "2"}, + }), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + m := a.Metadata() + if got := m.GetActualModel(); got != "grok-4.3" { + t.Errorf("ActualModel = %q, want grok-4.3 carried forward from the first event", got) + } + if got := m.GetSystemFingerprint(); got != "fp_2" { + t.Errorf("SystemFingerprint = %q, want fp_2 (later event supersedes)", got) + } + if got := m.GetServiceTier(); got != "fast" { + t.Errorf("ServiceTier = %q, want fast", got) + } + if got := m.GetAttrs(); got["a"] != "1" || got["b"] != "2" { + t.Errorf("Attrs = %v, want both keys merged", got) + } +} + +// TestStreamMetadata_rateLimitsReplaceWholesale asserts rate_limits is +// snapshot-replaced rather than merged entry by entry. Each event +// carries the vendor's complete budget picture, so merging would +// resurrect a budget the vendor stopped reporting. +func TestStreamMetadata_rateLimitsReplaceWholesale(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + metaEvent(&modelv1.StreamEvent_StreamMetadata{RateLimits: []*modelv1.RateLimitSnapshot{ + {Kind: modelv1.RateLimitKind_RATE_LIMIT_KIND_REQUESTS}, + {Kind: modelv1.RateLimitKind_RATE_LIMIT_KIND_TOKENS}, + }}), + metaEvent(&modelv1.StreamEvent_StreamMetadata{RateLimits: []*modelv1.RateLimitSnapshot{ + {Kind: modelv1.RateLimitKind_RATE_LIMIT_KIND_CREDITS}, + }}), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + got := a.Metadata().GetRateLimits() + if len(got) != 1 { + t.Fatalf("RateLimits has %d entries, want 1 — the later snapshot replaces, not merges", len(got)) + } + if got[0].GetKind() != modelv1.RateLimitKind_RATE_LIMIT_KIND_CREDITS { + t.Errorf("Kind = %v, want CREDITS", got[0].GetKind()) + } +} + +// TestStreamMetadata_absentMeansNoNewInformation covers the one reading +// that would lose data: an event that sets nothing must not blank what +// an earlier event established. +func TestStreamMetadata_absentMeansNoNewInformation(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + metaEvent(&modelv1.StreamEvent_StreamMetadata{ActualModel: proto.String("grok-4.3")}), + metaEvent(&modelv1.StreamEvent_StreamMetadata{}), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + if got := a.Metadata().GetActualModel(); got != "grok-4.3" { + t.Errorf("ActualModel = %q, want it preserved across an empty metadata event", got) + } +} + +// TestStreamMetadata_absentEntirelyIsNil keeps the no-metadata case +// distinguishable from an empty one, so a caller can tell "the vendor +// said nothing" from "the vendor said nothing new". +func TestStreamMetadata_absentEntirelyIsNil(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + textDelta("hi"), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + if a.Metadata() != nil { + t.Errorf("Metadata = %v, want nil when no metadata event was observed", a.Metadata()) + } +} + +// TestStreamStart_correlationIDsAreCopied checks both that the extra +// vendor handles survive and that the accumulator does not hand out a +// map a caller could mutate underneath it. +func TestStreamStart_correlationIDsAreCopied(t *testing.T) { + t.Parallel() + + ev := &modelv1.StreamEvent{Event: &modelv1.StreamEvent_StreamStart_{ + StreamStart: &modelv1.StreamEvent_StreamStart{ + ProviderRequestId: "req-1", + CorrelationIds: map[string]string{"cf-ray": "abc", "response_id": "resp_9"}, + }, + }} + a := observeAll(t, []*modelv1.StreamEvent{ev, stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, "")}) + + got := a.CorrelationIDs() + if got["cf-ray"] != "abc" || got["response_id"] != "resp_9" { + t.Fatalf("CorrelationIDs = %v, want both handles", got) + } + got["cf-ray"] = "mutated" + if a.CorrelationIDs()["cf-ray"] != "abc" { + t.Error("mutating the returned map changed the accumulator's own state") + } +} + +// thinkingOn builds a channel-tagged thinking delta. +func thinkingOn(text string, ch modelv1.StreamEvent_ThinkingChannel) *modelv1.StreamEvent { + return &modelv1.StreamEvent{Event: &modelv1.StreamEvent_ThinkingDelta_{ + ThinkingDelta: &modelv1.StreamEvent_ThinkingDelta{Text: text, Channel: ch}, + }} +} + +// TestThinkingChannel_switchClosesTheBlock asserts a summary run and a +// raw-reasoning run stay separate blocks even when adjacent. Merging them +// on adjacency alone would produce one block that reads as neither. +func TestThinkingChannel_switchClosesTheBlock(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + thinkingOn("raw ", modelv1.StreamEvent_THINKING_CHANNEL_CONTENT), + thinkingOn("reasoning", modelv1.StreamEvent_THINKING_CHANNEL_CONTENT), + thinkingOn("a summary", modelv1.StreamEvent_THINKING_CHANNEL_SUMMARY), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + msg, _, _, ok := a.Result() + if !ok { + t.Fatal("Result reported ok = false after a Stop event") + } + if len(msg.GetContent()) != 2 { + t.Fatalf("Content has %d blocks, want 2 (one per channel)", len(msg.GetContent())) + } + if got := msg.GetContent()[0].GetThinking().GetText(); got != "raw reasoning" { + t.Errorf("block 0 = %q, want the coalesced content run", got) + } + if got := msg.GetContent()[1].GetThinking().GetText(); got != "a summary" { + t.Errorf("block 1 = %q, want the summary run", got) + } +} + +// TestThinkingChannel_unspecifiedStillCoalesces is the compatibility +// guarantee: a provider that sets no channel behaves exactly as before +// this field existed. +func TestThinkingChannel_unspecifiedStillCoalesces(t *testing.T) { + t.Parallel() + + a := observeAll(t, []*modelv1.StreamEvent{ + thinkingDelta("one "), + thinkingDelta("block"), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + msg, _, _, _ := a.Result() + if len(msg.GetContent()) != 1 { + t.Fatalf("Content has %d blocks, want 1 unsplit block", len(msg.GetContent())) + } + if got := msg.GetContent()[0].GetThinking().GetText(); got != "one block" { + t.Errorf("text = %q, want %q", got, "one block") + } +} + +// TestSafetyNotice_recordedInOrderAndNotABlockBoundary covers both +// properties at once: the sequence is the explanation an operator needs, +// and interposing on a text run must not split it. +func TestSafetyNotice_recordedInOrderAndNotABlockBoundary(t *testing.T) { + t.Parallel() + + notice := func(k modelv1.StreamEvent_SafetyKind) *modelv1.StreamEvent { + return &modelv1.StreamEvent{Event: &modelv1.StreamEvent_SafetyNotice_{ + SafetyNotice: &modelv1.StreamEvent_SafetyNotice{Kind: k}, + }} + } + a := observeAll(t, []*modelv1.StreamEvent{ + textDelta("Hello "), + notice(modelv1.StreamEvent_SAFETY_KIND_BUFFERING), + notice(modelv1.StreamEvent_SAFETY_KIND_MODERATION), + textDelta("World"), + stopEvent(modelv1.StopReason_STOP_REASON_END_TURN, ""), + }) + + msg, _, _, _ := a.Result() + if len(msg.GetContent()) != 1 { + t.Fatalf("Content has %d blocks, want 1 unsplit block", len(msg.GetContent())) + } + if got := msg.GetContent()[0].GetText().GetText(); got != "Hello World" { + t.Errorf("text = %q, want Hello World", got) + } + + got := a.SafetyNotices() + if len(got) != 2 { + t.Fatalf("SafetyNotices has %d entries, want 2", len(got)) + } + if got[0].GetKind() != modelv1.StreamEvent_SAFETY_KIND_BUFFERING || + got[1].GetKind() != modelv1.StreamEvent_SAFETY_KIND_MODERATION { + t.Errorf("notices = %v/%v, want BUFFERING then MODERATION in arrival order", + got[0].GetKind(), got[1].GetKind()) + } +} diff --git a/internal/tokencount/helpers_test.go b/internal/tokencount/helpers_test.go index c18200d..519294b 100644 --- a/internal/tokencount/helpers_test.go +++ b/internal/tokencount/helpers_test.go @@ -143,6 +143,10 @@ func (f *fakeModelClient) Describe(context.Context, *modelv1.DescribeRequest, .. panic("fakeModelClient: Describe unexpectedly called") } +func (f *fakeModelClient) GetAccount(context.Context, *modelv1.GetAccountRequest, ...grpc.CallOption) (*modelv1.GetAccountResponse, error) { + panic("fakeModelClient: GetAccount unexpectedly called") +} + // panickingClient is a modelv1.ModelServiceClient that panics on // CountTokens unconditionally — used to prove memoization actually // short-circuits the round trip (a provider marked unimplemented must @@ -175,6 +179,10 @@ func (panickingClient) Describe(context.Context, *modelv1.DescribeRequest, ...gr panic("panickingClient: Describe unexpectedly called") } +func (panickingClient) GetAccount(context.Context, *modelv1.GetAccountRequest, ...grpc.CallOption) (*modelv1.GetAccountResponse, error) { + panic("panickingClient: GetAccount unexpectedly called") +} + // fakeLookup is a hand-written tokencount.ModelLookup fake. type fakeLookup struct { clients map[string]modelv1.ModelServiceClient diff --git a/internal/tui/paint/CLAUDE.md b/internal/tui/paint/CLAUDE.md deleted file mode 100644 index f945565..0000000 --- a/internal/tui/paint/CLAUDE.md +++ /dev/null @@ -1,23 +0,0 @@ -# internal/tui/paint — agent notes - -## The `default:` branch in `Painter.Node` is load-bearing - -It handles a `RenderNode` variant added to the enum after this build shipped, and it delegates to `pkg/frontend.FallbackText`. The protocol states as a MUST that a frontend render such a node gracefully rather than erroring or dropping it. Do not replace this with a panic, an error return, or an empty string, and do not reimplement the traversal locally — `pkg/frontend` already owns it. - -## `Targets` must mirror the painter's traversal exactly - -`walk.go` and `paint.go` walk the same tree with the same path scheme (`parent + "." + index`, rooted at the caller-supplied path). If they diverge, the action cursor highlights one node and activates another — a bug that no compiler catches. Change them together, and keep `TargetsAt`/`TreeAt` root paths in sync. - -Collapsed children are deliberately excluded from `Targets`: a cursor must not move over content the operator cannot see. - -## `GroupNode` adds no chrome, on purpose - -The spec defines it as a transparent container. A test asserts that a grouped pair of nodes renders byte-identically to the same nodes rendered separately and joined. Adding a border or indent "for readability" breaks the node's stated meaning. - -## Assertions must strip ANSI - -Lip Gloss emits SGR escapes per character for some styles (underline, notably), so `strings.Contains(got, "text")` fails on styled output. The tests use a `plain()` helper; use it for any new assertion on rendered content. - -## This package is pure domain - -No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. diff --git a/internal/tui/paint/README.md b/internal/tui/paint/README.md deleted file mode 100644 index 0c94343..0000000 --- a/internal/tui/paint/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# internal/tui/paint - -Renders a `RenderTree` into styled terminal text, and enumerates the keyboard-reachable elements inside one. - -## What lives here - -- `Painter` — one method per node type, dispatching off the `RenderNode` oneof. Pure: no terminal state, no I/O, no clock. -- `Opts` — the per-paint state the shell owns: width, which action is under the cursor, and which collapsible paths are toggled open. -- `Targets` / `TargetsAt` — the focusable elements in a tree, in the same order the painter emits them, so a cursor index and a rendered node always name the same element. - -## Node treatments - -| Node | Treatment | -|---|---| -| `TextNode` | Styled per `TextStyle`; unset resolves to the theme default | -| `CodeBlockNode` | Indented block with an optional language label | -| `DiffNode` | Dim hunk headers, `+`/`-` gutters, truncated rather than wrapped | -| `TableNode` | Column-aligned, truncated rather than wrapped | -| `LinkNode` | Label plus dimmed URL | -| `ListNode` | Bulleted or numbered, with hanging indent | -| `GroupNode` | Transparent — no border, indent, or label | -| `CollapsibleNode` | Disclosure marker honoring `collapsed_by_default` | -| `SubSessionNode` | A one-line pointer, never inlined | -| `ActionNode` | Button-styled, highlighted under the cursor | - -Diffs and tables truncate instead of wrapping because wrapping destroys the column alignment those node types exist to convey. - -## Why it is pure - -Every protocol obligation about rendering is testable without a terminal: graceful fallback for unrecognized node types, styles with no visual distinction still showing their text, and a bad node degrading rather than crashing the process. Keeping the painter free of terminal state is what lets the whole node vocabulary be covered on every CI platform, Windows included. - -## Related - -- [`docs/specifications/frontend/render-tree.md`](../../../docs/specifications/frontend/render-tree.md) — the node vocabulary and the graceful-fallback rule. -- `pkg/frontend.FallbackText` — the fallback traversal this package delegates to rather than reimplementing. diff --git a/internal/tui/paint/doc.go b/internal/tui/paint/doc.go deleted file mode 100644 index 51ae097..0000000 --- a/internal/tui/paint/doc.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package paint renders a RenderTree into styled terminal text. -// -// The painter is a pure function of (node, theme, options): it holds no -// terminal state, opens no files, and never consults the clock. That is what -// lets the entire node vocabulary — including the fallback behavior for node -// types this build does not recognize — be tested headlessly on every CI -// platform, Windows included. -// -// Two protocol obligations shape the implementation. First, a frontend MUST -// render every node type gracefully, including a variant added to the enum -// after the frontend shipped, rather than erroring or dropping content; the -// painter delegates that case to pkg/frontend.FallbackText rather than -// reimplementing the traversal. Second, a render failure on one node MUST NOT -// crash the frontend process, so the painter degrades a bad subtree to -// fallback text and keeps going. Both rules are stated in -// docs/specifications/frontend/render-tree.md and the error taxonomy in -// docs/specifications/frontend/frontend-protocol.md. -package paint diff --git a/internal/tui/paint/paint.go b/internal/tui/paint/paint.go deleted file mode 100644 index 766267e..0000000 --- a/internal/tui/paint/paint.go +++ /dev/null @@ -1,330 +0,0 @@ -package paint - -import ( - "fmt" - "strconv" - "strings" - - "charm.land/lipgloss/v2" - - "github.com/pluggableharness/agent/internal/tui/theme" - "github.com/pluggableharness/agent/internal/tui/ui" - "github.com/pluggableharness/agent/pkg/frontend" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -// minWidth is the narrowest column count the painter will lay out against. -// Below this, wrapping produces more line breaks than content, so the painter -// clamps rather than degenerating. -const minWidth = 8 - -// Opts carries the per-paint state the shell owns: how wide to lay out, which -// action is under the cursor, and which collapsible paths the operator has -// toggled open. -type Opts struct { - // Width is the available column count. - Width int - // FocusedAction is the ActionNode ID currently under the action cursor. - // Empty means no action is focused in this region. - FocusedAction string - // Expanded maps a node path to a forced expansion state, overriding a - // CollapsibleNode's collapsed_by_default. Paths are assigned by Walk and - // are stable for a given tree shape. - Expanded map[string]bool -} - -func (o Opts) width() int { - if o.Width < minWidth { - return minWidth - } - - return o.Width -} - -// Painter renders nodes using one theme. -type Painter struct { - th theme.Theme -} - -// New returns a Painter that renders with th. -func New(th theme.Theme) *Painter { return &Painter{th: th} } - -// Theme returns the theme this painter renders with. -func (p *Painter) Theme() theme.Theme { return p.th } - -// Tree renders a whole tree. A nil tree or a tree with no root paints as empty -// rather than as an error — an absent tree is a legitimate state, not a fault. -func (p *Painter) Tree(t *renderv1.RenderTree, o Opts) string { - return p.TreeAt(t, o, "0") -} - -// TreeAt renders a tree rooted at an explicit path. A region holding several -// producers' trees gives each one a distinct root path, so the paths that -// Opts.Expanded and Targets key against stay unique across the whole region. -func (p *Painter) TreeAt(t *renderv1.RenderTree, o Opts, rootPath string) string { - if t == nil || t.GetRoot() == nil { - return "" - } - - return p.Node(t.GetRoot(), o, rootPath) -} - -// Node renders a single node and its descendants. The path identifies this -// node's position in the tree and is what Opts.Expanded keys against. -func (p *Painter) Node(n *renderv1.RenderNode, o Opts, path string) string { - if n == nil { - return "" - } - - switch node := n.GetNode().(type) { - case *renderv1.RenderNode_Text: - return p.text(node.Text, o) - case *renderv1.RenderNode_CodeBlock: - return p.codeBlock(node.CodeBlock, o) - case *renderv1.RenderNode_Diff: - return p.diff(node.Diff, o) - case *renderv1.RenderNode_Table: - return p.table(node.Table, o) - case *renderv1.RenderNode_Link: - return p.link(node.Link) - case *renderv1.RenderNode_List: - return p.list(node.List, o, path) - case *renderv1.RenderNode_Group: - return p.group(node.Group, o, path) - case *renderv1.RenderNode_Collapsible: - return p.collapsible(node.Collapsible, o, path) - case *renderv1.RenderNode_SubSession: - return p.subSession(node.SubSession, o) - case *renderv1.RenderNode_Action: - return p.action(node.Action, o) - default: - // A variant added to the enum after this build shipped. The protocol - // requires graceful rendering rather than an error or a silent drop, - // and pkg/frontend already implements exactly that traversal. - return p.th.Dim.Width(o.width()).Render(frontend.FallbackText(n)) - } -} - -func (p *Painter) text(n *renderv1.TextNode, o Opts) string { - return p.th.TextStyle(n.Style).Width(o.width()).Render(ui.ExpandTabs(n.GetContent())) -} - -func (p *Painter) codeBlock(n *renderv1.CodeBlockNode, o Opts) string { - var b strings.Builder - - if lang := n.GetLanguage(); lang != "" { - b.WriteString(p.th.Dim.Render(lang)) - b.WriteString("\n") - } - - // The block is indented rather than boxed so that a code block nested in a - // list or collapsible does not fight the parent's own indentation. - body := p.th.CodeBlock.Width(o.width() - 2).Render(ui.ExpandTabs(n.GetContent())) - b.WriteString(indent(body, " ")) - - return b.String() -} - -func (p *Painter) diff(n *renderv1.DiffNode, o Opts) string { - lines := make([]string, 0, len(n.GetHunks())) - - for _, h := range n.GetHunks() { - header := fmt.Sprintf("@@ -%d,%d +%d,%d @@", - h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines()) - lines = append(lines, p.th.DiffHeader.Render(header)) - - for _, l := range h.GetLines() { - lines = append(lines, p.diffLine(l)) - } - } - - // Diff lines are truncated rather than wrapped: a wrapped diff line loses - // the column alignment that makes the +/- gutter readable. - return lipgloss.NewStyle().MaxWidth(o.width()).Render(strings.Join(lines, "\n")) -} - -func (p *Painter) diffLine(l *renderv1.DiffLine) string { - switch l.GetOp() { - case renderv1.DiffLineOp_DIFF_LINE_OP_ADD: - return p.th.DiffAdd.Render("+" + ui.ExpandTabs(l.GetText())) - case renderv1.DiffLineOp_DIFF_LINE_OP_REMOVE: - return p.th.DiffRemove.Render("-" + ui.ExpandTabs(l.GetText())) - case renderv1.DiffLineOp_DIFF_LINE_OP_CONTEXT, renderv1.DiffLineOp_DIFF_LINE_OP_UNSPECIFIED: - return p.th.Default.Render(" " + ui.ExpandTabs(l.GetText())) - default: - return p.th.Default.Render(" " + ui.ExpandTabs(l.GetText())) - } -} - -func (p *Painter) table(n *renderv1.TableNode, o Opts) string { - headers := n.GetHeaders() - rows := n.GetRows() - - widths := make([]int, len(headers)) - for i, h := range headers { - widths[i] = lipgloss.Width(ui.ExpandTabs(h)) - } - - for _, r := range rows { - for i, c := range r.GetCells() { - if w := lipgloss.Width(ui.ExpandTabs(c)); i < len(widths) && w > widths[i] { - widths[i] = w - } - } - } - - out := make([]string, 0, len(rows)+1) - if len(headers) > 0 { - out = append(out, p.th.TableHeader.Render(joinCells(headers, widths))) - } - - for _, r := range rows { - out = append(out, p.th.Default.Render(joinCells(r.GetCells(), widths))) - } - - // Columns are truncated, not wrapped, for the same alignment reason diffs - // are: a wrapped cell breaks the grid the table exists to convey. - return lipgloss.NewStyle().MaxWidth(o.width()).Render(strings.Join(out, "\n")) -} - -func joinCells(cells []string, widths []int) string { - parts := make([]string, 0, len(cells)) - - for i, c := range cells { - c = ui.ExpandTabs(c) - w := lipgloss.Width(c) - if i < len(widths) && widths[i] > w { - c += strings.Repeat(" ", widths[i]-w) - } - - parts = append(parts, c) - } - - return strings.Join(parts, " ") -} - -func (p *Painter) link(n *renderv1.LinkNode) string { - // OSC 8 hyperlinks are emitted unconditionally: terminals that do not - // understand the sequence ignore it and show the label, so there is no - // capability check to get wrong. The URL is appended dimmed so the target - // stays visible in a terminal that swallowed the escape. - label := p.th.Link.Render(ui.ExpandTabs(n.GetText())) - if n.GetUrl() == "" { - return label - } - - return label + p.th.Dim.Render(" ("+n.GetUrl()+")") -} - -func (p *Painter) list(n *renderv1.ListNode, o Opts, path string) string { - items := n.GetItems() - out := make([]string, 0, len(items)) - - inner := o - inner.Width = o.width() - 3 - - for i, item := range items { - marker := "• " - if n.GetOrdered() { - marker = strconv.Itoa(i+1) + ". " - } - - body := p.Node(item, inner, path+"."+strconv.Itoa(i)) - out = append(out, hangingIndent(body, marker)) - } - - return strings.Join(out, "\n") -} - -func (p *Painter) group(n *renderv1.GroupNode, o Opts, path string) string { - children := n.GetChildren() - out := make([]string, 0, len(children)) - - // A group is a transparent container: no border, no indent, no label. - // Adding chrome here would contradict what the node type means. - for i, c := range children { - out = append(out, p.Node(c, o, path+"."+strconv.Itoa(i))) - } - - return strings.Join(out, "\n") -} - -func (p *Painter) collapsible(n *renderv1.CollapsibleNode, o Opts, path string) string { - expanded := !n.GetCollapsedByDefault() - if forced, ok := o.Expanded[path]; ok { - expanded = forced - } - - marker := "▸ " - if expanded { - marker = "▾ " - } - - head := p.th.RegionTitle.Render(marker + ui.ExpandTabs(n.GetSummary())) - if !expanded { - return head - } - - children := n.GetChildren() - out := make([]string, 0, len(children)+1) - out = append(out, head) - - inner := o - inner.Width = o.width() - 2 - - for i, c := range children { - out = append(out, indent(p.Node(c, inner, path+"."+strconv.Itoa(i)), " ")) - } - - return strings.Join(out, "\n") -} - -func (p *Painter) subSession(n *renderv1.SubSessionNode, o Opts) string { - // Deliberately a pointer, never inlined: the protocol defines this node as - // a reference to a nested transcript, not a place to expand one. - label := n.GetSummary() - if label == "" { - label = "sub-session" - } - - return p.th.SubSession.Width(o.width()).Render("⤷ " + label + " (" + n.GetSessionId() + ")") -} - -func (p *Painter) action(n *renderv1.ActionNode, o Opts) string { - style := p.th.Action - if n.GetId() != "" && n.GetId() == o.FocusedAction { - style = p.th.ActionFocused - } - - return style.Render("[ " + ui.ExpandTabs(n.GetLabel()) + " ]") -} - -// indent prefixes every line of s with pad. -func indent(s, pad string) string { - lines := strings.Split(s, "\n") - for i := range lines { - lines[i] = pad + lines[i] - } - - return strings.Join(lines, "\n") -} - -// hangingIndent prefixes the first line with marker and subsequent lines with -// an equivalent run of spaces, so wrapped list items stay aligned under their -// own text rather than under the bullet. -func hangingIndent(s, marker string) string { - lines := strings.Split(s, "\n") - pad := strings.Repeat(" ", lipgloss.Width(marker)) - - for i := range lines { - if i == 0 { - lines[i] = marker + lines[i] - - continue - } - - lines[i] = pad + lines[i] - } - - return strings.Join(lines, "\n") -} diff --git a/internal/tui/paint/paint_test.go b/internal/tui/paint/paint_test.go deleted file mode 100644 index 9b7d0d7..0000000 --- a/internal/tui/paint/paint_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package paint_test - -import ( - "regexp" - "strings" - "testing" - - "github.com/pluggableharness/agent/internal/tui/paint" - "github.com/pluggableharness/agent/internal/tui/theme" - "github.com/pluggableharness/agent/pkg/render" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -func newPainter() *paint.Painter { return paint.New(theme.Dark()) } - -func wide() paint.Opts { return paint.Opts{Width: 60} } - -// ansiPattern matches SGR escape sequences. Lip Gloss may emit them per -// character (underlined text does), so assertions strip them before matching. -var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) - -func plain(s string) string { return ansiPattern.ReplaceAllString(s, "") } - -// contains reports whether the rendered output contains want, ignoring the -// styling escapes Lip Gloss may have wrapped it in. -func contains(t *testing.T, got, want string) { - t.Helper() - - if !strings.Contains(plain(got), want) { - t.Fatalf("rendered output missing %q\ngot: %q", want, plain(got)) - } -} - -// Every node type must produce visible output. A frontend MUST render every -// node type gracefully rather than dropping content it has no special -// treatment for. -func TestEveryNodeTypeRendersItsContent(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - node *renderv1.RenderNode - want string - }{ - {"text", render.Text("plain words"), "plain words"}, - {"styled text", render.TextStyled("loud", renderv1.TextStyle_TEXT_STYLE_BOLD), "loud"}, - {"code block", render.Code("go", "package main"), "package main"}, - {"code block language", render.Code("go", "x"), "go"}, - {"link text", render.Link("Anthropic", "https://example.test"), "Anthropic"}, - {"link url", render.Link("Anthropic", "https://example.test"), "example.test"}, - {"table header", render.Table([]string{"name"}, [][]string{{"row"}}), "name"}, - {"table cell", render.Table([]string{"name"}, [][]string{{"row"}}), "row"}, - {"list item", render.List(render.Text("only")), "only"}, - {"group child", render.Group(render.Text("inside")), "inside"}, - {"collapsible summary", render.Collapsible("summary", render.Text("child")), "summary"}, - {"sub-session summary", render.SubSession("session-1", "child work"), "child work"}, - {"sub-session id", render.SubSession("session-1", "child work"), "session-1"}, - {"action label", render.Action("a1", "Compact", "compact", nil, "builtin"), "Compact"}, - } - - p := newPainter() - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - contains(t, p.Tree(render.Tree(tc.node), wide()), tc.want) - }) - } -} - -func TestDiffRendersEveryLineOpAndHunkHeader(t *testing.T) { - t.Parallel() - - node := render.Diff(render.Hunk(1, 2, 1, 3, - render.DiffContextLine("context line"), - render.DiffAddLine("added line"), - render.DiffRemoveLine("removed line"), - )) - - got := newPainter().Tree(render.Tree(node), wide()) - - for _, want := range []string{"@@ -1,2 +1,3 @@", "context line", "added line", "removed line", "+", "-"} { - contains(t, got, want) - } -} - -// A node whose variant this build does not recognize must still render rather -// than erroring or crashing the process. -func TestUnknownNodeVariantDoesNotPanicOrError(t *testing.T) { - t.Parallel() - - got := newPainter().Tree(render.Tree(&renderv1.RenderNode{}), wide()) - - if strings.Contains(got, "panic") { - t.Fatalf("unexpected output for unknown variant: %q", got) - } -} - -func TestNilTreeAndNilNodeRenderEmpty(t *testing.T) { - t.Parallel() - - p := newPainter() - - if got := p.Tree(nil, wide()); got != "" { - t.Fatalf("nil tree rendered %q, want empty", got) - } - - if got := p.Tree(&renderv1.RenderTree{}, wide()); got != "" { - t.Fatalf("rootless tree rendered %q, want empty", got) - } - - if got := p.Node(nil, wide(), "0"); got != "" { - t.Fatalf("nil node rendered %q, want empty", got) - } -} - -func TestOrderedListNumbersItems(t *testing.T) { - t.Parallel() - - node := render.OrderedList(render.Text("alpha"), render.Text("beta")) - got := newPainter().Tree(render.Tree(node), wide()) - - contains(t, got, "1. ") - contains(t, got, "2. ") - contains(t, got, "alpha") - contains(t, got, "beta") -} - -func TestUnorderedListUsesBullets(t *testing.T) { - t.Parallel() - - got := newPainter().Tree(render.Tree(render.List(render.Text("alpha"))), wide()) - contains(t, got, "•") -} - -// A group is a transparent container: no border, no indent, no label. Adding -// chrome would contradict what the node type means. -func TestGroupAddsNoChrome(t *testing.T) { - t.Parallel() - - p := newPainter() - - grouped := p.Tree(render.Tree(render.Group(render.Text("a"), render.Text("b"))), wide()) - separate := p.Tree(render.Tree(render.Text("a")), wide()) + "\n" + - p.Tree(render.Tree(render.Text("b")), wide()) - - if grouped != separate { - t.Fatalf("group added chrome\ngrouped: %q\nseparate: %q", grouped, separate) - } -} - -func TestCollapsibleRespectsDefaultAndOverride(t *testing.T) { - t.Parallel() - - collapsed := render.CollapsedByDefault("summary", render.Text("hidden child")) - expanded := render.Collapsible("summary", render.Text("shown child")) - p := newPainter() - - if got := p.Tree(render.Tree(collapsed), wide()); strings.Contains(plain(got), "hidden child") { - t.Fatalf("collapsed_by_default node showed its children: %q", got) - } - - if got := p.Tree(render.Tree(expanded), wide()); !strings.Contains(plain(got), "shown child") { - t.Fatalf("expanded node hid its children: %q", got) - } - - // An explicit override wins over the node's own default, in both directions. - o := wide() - o.Expanded = map[string]bool{"0": true} - - contains(t, p.Tree(render.Tree(collapsed), o), "hidden child") - - o.Expanded = map[string]bool{"0": false} - - if got := p.Tree(render.Tree(expanded), o); strings.Contains(plain(got), "shown child") { - t.Fatalf("override failed to collapse an expanded-by-default node: %q", got) - } -} - -func TestActionIsStyledDifferentlyWhenFocused(t *testing.T) { - t.Parallel() - - node := render.Action("act_1", "Compact", "compact", nil, "builtin") - p := newPainter() - - unfocused := p.Tree(render.Tree(node), wide()) - - focused := wide() - focused.FocusedAction = "act_1" - got := p.Tree(render.Tree(node), focused) - - if got == unfocused { - t.Fatal("focused action rendered identically to unfocused; the cursor would be invisible") - } -} - -func TestWidthIsClampedRatherThanDegenerating(t *testing.T) { - t.Parallel() - - // A pathological width must not produce one character per line or panic. - got := newPainter().Tree(render.Tree(render.Text("some words here")), paint.Opts{Width: -5}) - if got == "" { - t.Fatal("clamped width dropped content") - } -} - -func TestThemeAccessor(t *testing.T) { - t.Parallel() - - if got := paint.New(theme.Light()).Theme().Name; got != "light" { - t.Fatalf("Theme() = %q, want light", got) - } -} diff --git a/internal/tui/paint/walk.go b/internal/tui/paint/walk.go deleted file mode 100644 index 9d96cad..0000000 --- a/internal/tui/paint/walk.go +++ /dev/null @@ -1,96 +0,0 @@ -package paint - -import ( - "strconv" - - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -// TargetKind distinguishes what activating a focus target does. -type TargetKind int - -const ( - // TargetAction is an ActionNode. Activating it dispatches an - // action_trigger ClientEvent carrying the node's tool_name, args, and - // provider unchanged, which the protocol states as a MUST. - TargetAction TargetKind = iota - // TargetCollapsible is a CollapsibleNode. Activating it toggles expansion - // locally and sends nothing to the kernel. - TargetCollapsible -) - -// Target is one keyboard-reachable element inside a rendered tree. -type Target struct { - // Path is the node's position in the tree, matching the path scheme the - // painter uses for Opts.Expanded. - Path string - Kind TargetKind - // Action is set only when Kind is TargetAction. - Action *renderv1.ActionNode - // Summary is set only when Kind is TargetCollapsible. - Summary string -} - -// Targets returns every keyboard-reachable element in a tree, in the same -// order the painter emits them, so a cursor index means the same thing to both. -// -// Elements inside a collapsed CollapsibleNode are omitted: content the operator -// cannot see must not be reachable by a cursor that appears to move over -// nothing. The expanded map has the same meaning as Opts.Expanded. -func Targets(t *renderv1.RenderTree, expanded map[string]bool) []Target { - return TargetsAt(t, expanded, "0") -} - -// TargetsAt is Targets rooted at an explicit path, matching Painter.TreeAt so -// a cursor index and a rendered node agree on which element they name. -func TargetsAt(t *renderv1.RenderTree, expanded map[string]bool, rootPath string) []Target { - if t == nil || t.GetRoot() == nil { - return nil - } - - var out []Target - collect(t.GetRoot(), rootPath, expanded, &out) - - return out -} - -func collect(n *renderv1.RenderNode, path string, expanded map[string]bool, out *[]Target) { - if n == nil { - return - } - - switch node := n.GetNode().(type) { - case *renderv1.RenderNode_Action: - *out = append(*out, Target{Path: path, Kind: TargetAction, Action: node.Action}) - case *renderv1.RenderNode_List: - collectChildren(node.List.GetItems(), path, expanded, out) - case *renderv1.RenderNode_Group: - collectChildren(node.Group.GetChildren(), path, expanded, out) - case *renderv1.RenderNode_Collapsible: - *out = append(*out, Target{ - Path: path, - Kind: TargetCollapsible, - Summary: node.Collapsible.GetSummary(), - }) - - if isExpanded(node.Collapsible, path, expanded) { - collectChildren(node.Collapsible.GetChildren(), path, expanded, out) - } - default: - // Every other variant is a leaf with nothing to focus. - } -} - -func collectChildren(children []*renderv1.RenderNode, path string, expanded map[string]bool, out *[]Target) { - for i, c := range children { - collect(c, path+"."+strconv.Itoa(i), expanded, out) - } -} - -func isExpanded(n *renderv1.CollapsibleNode, path string, expanded map[string]bool) bool { - if forced, ok := expanded[path]; ok { - return forced - } - - return !n.GetCollapsedByDefault() -} diff --git a/internal/tui/paint/walk_test.go b/internal/tui/paint/walk_test.go deleted file mode 100644 index ea265a1..0000000 --- a/internal/tui/paint/walk_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package paint_test - -import ( - "testing" - - "github.com/pluggableharness/agent/internal/tui/paint" - "github.com/pluggableharness/agent/pkg/render" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -func paths(in []paint.Target) []string { - out := make([]string, 0, len(in)) - for _, t := range in { - out = append(out, t.Path) - } - - return out -} - -func samePaths(a, b []string) bool { - if len(a) != len(b) { - return false - } - - for i := range a { - if a[i] != b[i] { - return false - } - } - - return true -} - -func TestTargetsFindsActionsInPaintOrder(t *testing.T) { - t.Parallel() - - tree := render.Tree(render.Group( - render.Text("not focusable"), - render.Action("a1", "First", "tool_a", nil, "p"), - render.List( - render.Action("a2", "Second", "tool_b", nil, "p"), - render.Text("also not focusable"), - render.Action("a3", "Third", "tool_c", nil, "p"), - ), - )) - - got := paint.Targets(tree, nil) - if len(got) != 3 { - t.Fatalf("got %d targets, want 3: %+v", len(got), got) - } - - for i, want := range []string{"a1", "a2", "a3"} { - if got[i].Kind != paint.TargetAction { - t.Fatalf("target %d kind = %v, want action", i, got[i].Kind) - } - - if got[i].Action.GetId() != want { - t.Errorf("target %d id = %q, want %q", i, got[i].Action.GetId(), want) - } - } - - // Paths must be unique and match the painter's scheme so a cursor index - // and a rendered node agree on which element they name. - if want := []string{"0.1", "0.2.0", "0.2.2"}; !samePaths(paths(got), want) { - t.Fatalf("paths = %v, want %v", paths(got), want) - } -} - -// Content the operator cannot see must not be reachable by a cursor that -// appears to move over nothing. -func TestTargetsSkipsCollapsedChildren(t *testing.T) { - t.Parallel() - - tree := render.Tree(render.CollapsedByDefault("hidden", - render.Action("buried", "Buried", "tool", nil, "p"), - )) - - got := paint.Targets(tree, nil) - if len(got) != 1 { - t.Fatalf("got %d targets, want only the collapsible itself: %+v", len(got), got) - } - - if got[0].Kind != paint.TargetCollapsible || got[0].Summary != "hidden" { - t.Fatalf("expected the collapsible as the sole target, got %+v", got[0]) - } - - // Expanding it exposes the child. - expanded := paint.Targets(tree, map[string]bool{"0": true}) - if len(expanded) != 2 { - t.Fatalf("expanded: got %d targets, want 2: %+v", len(expanded), expanded) - } - - if expanded[1].Action.GetId() != "buried" { - t.Fatalf("expanded child = %+v, want the buried action", expanded[1]) - } -} - -// An expanded-by-default collapsible can be forced closed, which must also -// withdraw its children from the target list. -func TestTargetsHonorsForcedCollapse(t *testing.T) { - t.Parallel() - - tree := render.Tree(render.Collapsible("shown", - render.Action("child", "Child", "tool", nil, "p"), - )) - - if got := paint.Targets(tree, nil); len(got) != 2 { - t.Fatalf("expanded by default: got %d targets, want 2", len(got)) - } - - if got := paint.Targets(tree, map[string]bool{"0": false}); len(got) != 1 { - t.Fatalf("forced collapsed: got %d targets, want 1", len(got)) - } -} - -func TestTargetsAtUsesTheGivenRootPath(t *testing.T) { - t.Parallel() - - tree := render.Tree(render.Group(render.Action("a", "A", "tool", nil, "p"))) - - got := paint.TargetsAt(tree, nil, "7") - if len(got) != 1 || got[0].Path != "7.0" { - t.Fatalf("TargetsAt rooted wrong: %v", paths(got)) - } -} - -func TestTargetsOnEmptyTrees(t *testing.T) { - t.Parallel() - - if got := paint.Targets(nil, nil); got != nil { - t.Fatalf("nil tree = %v, want nil", got) - } - - if got := paint.Targets(&renderv1.RenderTree{}, nil); got != nil { - t.Fatalf("rootless tree = %v, want nil", got) - } - - if got := paint.Targets(render.Tree(render.Text("leaf")), nil); len(got) != 0 { - t.Fatalf("leaf-only tree = %v, want no targets", got) - } -} - -func TestTargetsNestedCollapsibles(t *testing.T) { - t.Parallel() - - tree := render.Tree(render.Collapsible("outer", - render.Collapsible("inner", - render.Action("deep", "Deep", "tool", nil, "p"), - ), - )) - - got := paint.Targets(tree, nil) - if want := []string{"0", "0.0", "0.0.0"}; !samePaths(paths(got), want) { - t.Fatalf("nested paths = %v, want %v", paths(got), want) - } -} diff --git a/internal/tui/region/CLAUDE.md b/internal/tui/region/CLAUDE.md deleted file mode 100644 index 35e0bf5..0000000 --- a/internal/tui/region/CLAUDE.md +++ /dev/null @@ -1,21 +0,0 @@ -# internal/tui/region — agent notes - -## Never iterate a map to produce paint order - -Regions are held in a fixed-length array indexed by the enum, and `Contents` sorts a copied slice. Introducing a `map[Region][]Placement` and ranging over it would reintroduce exactly the nondeterminism `.claude/rules/determinism.md` forbids, and the failure mode is a frame that reorders between runs rather than a test failure. - -## `replace` is producer-scoped, not region-scoped - -`Place` with `replace: true` deletes only that producer's prior entries. A change that clears the whole region would break widget coexistence — two widgets sharing the sidebar would evict each other. There is a test named for this; if it fails, the semantics regressed. - -## Unset priority is not zero - -`Placement.Ranked` carries whether `PlacedContent.priority` was present. A zero priority is a *ranked* placement that sorts first; an absent priority sorts last. Collapsing these into `int32(0)` silently reorders every unranked producer to the front. - -## `Store` is deliberately not safe for concurrent use - -The shell owns one per session and mutates it only from the Bubble Tea update goroutine. That single-goroutine ownership is what makes the absence of locking correct. If something ever needs to write from another goroutine, route it through a `tea.Msg` rather than adding a mutex here. - -## This package is pure domain - -No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. It is 100%-covered; keep it there. diff --git a/internal/tui/region/README.md b/internal/tui/region/README.md deleted file mode 100644 index 345b43f..0000000 --- a/internal/tui/region/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# internal/tui/region - -The reference TUI shell's content store: the per-region set of placements every producer has contributed, and the ordering rule that decides what paints first. - -## What lives here - -- `Store` — one session's placed content, indexed by region. -- `Placement` — one producer's contribution: its tree, its priority (and whether priority was set at all), and the kernel sequence it arrived with. -- `Stream` — an in-progress streamed text block, correlated by `target_id`. -- `Normalize` — folds `REGION_UNSPECIFIED` and any unrecognized region value onto `REGION_MAIN_CHAT`. - -## The model - -A region is **not** a single-writer slot. The protocol's default is coexistence: several producers may target one region, and the frontend arbitrates by priority rather than evicting. `PlacedContent.replace` therefore supersedes only the placements of the producer that sent it, never another producer's. - -Ordering is `(ranked, priority, sequence)` ascending: - -- A placement with priority set sorts ahead of every placement without one. -- Unset priority means "declaration order", which is `sequence` order. -- `sequence` is the only tiebreak. - -## Determinism - -Wall clock is never an input to ordering, and regions live in a fixed-length array rather than a map, so paint order cannot vary with Go's map iteration. Both are required by [`.claude/rules/determinism.md`](../../../.claude/rules/determinism.md). The consequence that matters: two shells replaying one session compose identical frames. - -Rendered output is derived state — recomputed from this store, never persisted, never cached to disk. - -## Related - -- [`docs/specifications/frontend/render-tree.md`](../../../docs/specifications/frontend/render-tree.md) — the `Region`/`PlacedContent` vocabulary. -- [`docs/first-party/frontends/tui.md`](../../../docs/first-party/frontends/tui.md) — how the shell lays these regions out. diff --git a/internal/tui/region/doc.go b/internal/tui/region/doc.go deleted file mode 100644 index e54a572..0000000 --- a/internal/tui/region/doc.go +++ /dev/null @@ -1,21 +0,0 @@ -// Package region owns the reference TUI shell's content store: the per-region -// set of placements contributed by every producer, and the ordering rule that -// decides what paints first. -// -// The store models the protocol's coexistence default -// (docs/specifications/frontend/render-tree.md): a region is not a -// single-writer slot, so several producers may target one region and the -// frontend arbitrates by priority rather than evicting. PlacedContent.replace -// supersedes only the placements of the producer that sent it, never another -// producer's. -// -// Ordering is (ranked, priority, sequence) ascending, with unset priority -// sorting after every ranked entry and sequence as the sole tiebreak. Wall -// clock is never an input and regions are held in a fixed-length array rather -// than a map, so paint order cannot vary with Go's map iteration — both -// required by .claude/rules/determinism.md. The practical consequence is that -// two shells replaying one session compose identical frames. -// -// Nothing in this package performs I/O or touches a terminal, so the whole -// ordering contract is testable headlessly. -package region diff --git a/internal/tui/region/region.go b/internal/tui/region/region.go deleted file mode 100644 index 0121167..0000000 --- a/internal/tui/region/region.go +++ /dev/null @@ -1,218 +0,0 @@ -package region - -import ( - "math" - "sort" - - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -// count is the number of values in the Region enum, including -// REGION_UNSPECIFIED at zero. The store indexes a fixed-length array by enum -// value so paint order never depends on map iteration. -const count = 7 - -// Producer identifies the plugin that contributed a placement. Identity is -// always server-derived from the authenticated connection — a plugin cannot -// declare an identity other than its own — so the shell treats this as -// trustworthy for the purpose of scoping replace semantics. -type Producer struct { - Category string - Name string -} - -// Placement is one producer's contribution to one region. -type Placement struct { - // Producer is who contributed this content. - Producer Producer - // Priority is the producer's ordering hint. Ranked reports whether it was - // set at all: an unset priority is not zero, it sorts after every ranked - // placement. - Priority int32 - Ranked bool - // Sequence is the kernel's event sequence, the sole ordering tiebreak. - Sequence uint64 - // Tree is the content to paint. - Tree *renderv1.RenderTree -} - -// Stream is an in-progress streamed text block, correlated by the target ID -// the kernel's stream_delta events carry. Streams paint at the tail of -// REGION_MAIN_CHAT, after every settled placement, because they are by -// definition the live edge of the transcript. -type Stream struct { - TargetID string - Text string - // first is the arrival index, used to keep multiple concurrent streams in - // a stable order without consulting the clock. - first uint64 -} - -// Store holds all placed content for a single session. -// -// A Store is not safe for concurrent use. The shell owns one per attached -// session and mutates it only from the Bubble Tea update goroutine, which is -// what makes the absence of locking correct rather than merely convenient. -type Store struct { - regions [count][]Placement - streams []Stream - arrival uint64 -} - -// NewStore returns an empty Store. -func NewStore() *Store { return &Store{} } - -// inRange reports whether r is a region value this build knows how to index. -// A value outside the range is one added to the enum after this shell shipped; -// callers fold it into REGION_MAIN_CHAT rather than dropping the content. -func inRange(r renderv1.Region) bool { - return r >= 0 && int(r) < count -} - -// Normalize maps a wire region value onto the region this shell will actually -// use for it. REGION_UNSPECIFIED means "the producer did not choose", which the -// protocol defines as REGION_MAIN_CHAT; an unrecognized value from a newer -// protocol build folds to the same place, since the alternative is silently -// dropping content the frontend is required to render gracefully. -func Normalize(r renderv1.Region) renderv1.Region { - if !inRange(r) || r == renderv1.Region_REGION_UNSPECIFIED { - return renderv1.Region_REGION_MAIN_CHAT - } - - return r -} - -// Place adds content to the store. When pc.Replace is set, the placement -// supersedes that producer's prior placements in the same region and leaves -// every other producer's untouched; otherwise it is appended alongside them. -// -// Place is a no-op for nil content, so a malformed event degrades to "nothing -// shown" rather than a panic in the paint path. -func (s *Store) Place(pc *renderv1.PlacedContent, p Producer, sequence uint64) { - if pc == nil || pc.GetContent() == nil { - return - } - - r := Normalize(pc.GetRegion()) - next := Placement{ - Producer: p, - Sequence: sequence, - Tree: pc.GetContent(), - } - - if pc.Priority != nil { - next.Priority = pc.GetPriority() - next.Ranked = true - } - - if pc.GetReplace() { - s.regions[r] = deleteProducer(s.regions[r], p) - } - - s.regions[r] = append(s.regions[r], next) -} - -// deleteProducer removes every placement contributed by p, preserving the -// relative order of the rest. -func deleteProducer(in []Placement, p Producer) []Placement { - out := in[:0] - - for _, pl := range in { - if pl.Producer != p { - out = append(out, pl) - } - } - - return out -} - -// Contents returns the placements for a region in paint order. The returned -// slice is a fresh copy, so a caller may hold or reorder it without disturbing -// the store. -func (s *Store) Contents(r renderv1.Region) []Placement { - if !inRange(r) { - return nil - } - - out := make([]Placement, len(s.regions[r])) - copy(out, s.regions[r]) - - sort.SliceStable(out, func(i, j int) bool { - li, lj := rank(out[i]), rank(out[j]) - if li != lj { - return li < lj - } - - return out[i].Sequence < out[j].Sequence - }) - - return out -} - -// rank projects a placement's priority onto a total order. An unset priority -// sorts after every ranked placement, which is what "unset = declaration -// order" means once ranked entries are allowed to jump the queue. -func rank(p Placement) int64 { - if !p.Ranked { - return math.MaxInt64 - } - - return int64(p.Priority) -} - -// Delta appends streamed text to the buffer for targetID, creating it on first -// sight. Consecutive deltas for one target accumulate into a single growing -// block rather than becoming separate lines. -func (s *Store) Delta(targetID, text string) { - for i := range s.streams { - if s.streams[i].TargetID == targetID { - s.streams[i].Text += text - - return - } - } - - s.arrival++ - s.streams = append(s.streams, Stream{TargetID: targetID, Text: text, first: s.arrival}) -} - -// ClearStream drops the buffer for targetID. The shell calls this when the -// finished render for a streamed block arrives, so the completed content -// replaces the live buffer instead of appearing twice. -func (s *Store) ClearStream(targetID string) { - out := s.streams[:0] - - for _, st := range s.streams { - if st.TargetID != targetID { - out = append(out, st) - } - } - - s.streams = out -} - -// ClearProducerStreams drops every live buffer, which is the coarse form of -// ClearStream used when a producer settles content and the shell cannot -// correlate it to a specific target ID. -func (s *Store) ClearProducerStreams() { s.streams = nil } - -// Streams returns the live streamed blocks in arrival order. -func (s *Store) Streams() []Stream { - out := make([]Stream, len(s.streams)) - copy(out, s.streams) - - sort.SliceStable(out, func(i, j int) bool { return out[i].first < out[j].first }) - - return out -} - -// Reset empties the store, used when a session is detached or re-backfilled so -// replayed history does not stack on top of what was already painted. -func (s *Store) Reset() { - for i := range s.regions { - s.regions[i] = nil - } - - s.streams = nil - s.arrival = 0 -} diff --git a/internal/tui/region/region_test.go b/internal/tui/region/region_test.go deleted file mode 100644 index 0dcd77b..0000000 --- a/internal/tui/region/region_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package region_test - -import ( - "testing" - - "github.com/pluggableharness/agent/internal/tui/region" - "github.com/pluggableharness/agent/pkg/render" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -func placed(r renderv1.Region, text string, replace bool, priority *int32) *renderv1.PlacedContent { - return &renderv1.PlacedContent{ - Region: r, - Content: render.Tree(render.Text(text)), - Replace: replace, - Priority: priority, - } -} - -func texts(t *testing.T, in []region.Placement) []string { - t.Helper() - - out := make([]string, 0, len(in)) - for _, p := range in { - out = append(out, p.Tree.GetRoot().GetText().GetContent()) - } - - return out -} - -func equal(a, b []string) bool { - if len(a) != len(b) { - return false - } - - for i := range a { - if a[i] != b[i] { - return false - } - } - - return true -} - -func TestNormalize(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in renderv1.Region - want renderv1.Region - }{ - {"unspecified folds to main chat", renderv1.Region_REGION_UNSPECIFIED, renderv1.Region_REGION_MAIN_CHAT}, - {"known region is preserved", renderv1.Region_REGION_SIDEBAR, renderv1.Region_REGION_SIDEBAR}, - {"future region folds to main chat", renderv1.Region(42), renderv1.Region_REGION_MAIN_CHAT}, - {"negative region folds to main chat", renderv1.Region(-1), renderv1.Region_REGION_MAIN_CHAT}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := region.Normalize(tc.in); got != tc.want { - t.Fatalf("Normalize(%v) = %v, want %v", tc.in, got, tc.want) - } - }) - } -} - -func TestPlaceAppendsByDefault(t *testing.T) { - t.Parallel() - - s := region.NewStore() - p := region.Producer{Category: "tool", Name: "fs"} - - s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "first", false, nil), p, 1) - s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "second", false, nil), p, 2) - - got := texts(t, s.Contents(renderv1.Region_REGION_MAIN_CHAT)) - if want := []string{"first", "second"}; !equal(got, want) { - t.Fatalf("append semantics broken: got %v, want %v", got, want) - } -} - -// Replace is scoped to the producer that sent it. The protocol's default is -// coexistence, not exclusivity, so one producer replacing its own content must -// never evict another's from the same region. -func TestReplaceIsScopedToOneProducer(t *testing.T) { - t.Parallel() - - s := region.NewStore() - git := region.Producer{Category: "widget", Name: "git"} - ctx := region.Producer{Category: "widget", Name: "context"} - - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "git v1", true, nil), git, 1) - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "context", true, nil), ctx, 2) - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "git v2", true, nil), git, 3) - - got := texts(t, s.Contents(renderv1.Region_REGION_SIDEBAR)) - if want := []string{"context", "git v2"}; !equal(got, want) { - t.Fatalf("replace evicted the wrong producer: got %v, want %v", got, want) - } -} - -// Unset priority sorts after every ranked placement, and sequence is the only -// tiebreak. Wall clock is never consulted. -func TestContentsOrdering(t *testing.T) { - t.Parallel() - - s := region.NewStore() - p := region.Producer{Category: "widget", Name: "w"} - - lo, hi := int32(1), int32(50) - - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "unranked-early", false, nil), p, 1) - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "ranked-high", false, &hi), p, 2) - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "unranked-late", false, nil), p, 3) - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "ranked-low", false, &lo), p, 4) - - got := texts(t, s.Contents(renderv1.Region_REGION_SIDEBAR)) - want := []string{"ranked-low", "ranked-high", "unranked-early", "unranked-late"} - - if !equal(got, want) { - t.Fatalf("ordering wrong\ngot: %v\nwant: %v", got, want) - } -} - -// A zero priority is a ranked placement, not an unset one — the distinction the -// optional field exists to carry. -func TestZeroPriorityIsRanked(t *testing.T) { - t.Parallel() - - s := region.NewStore() - p := region.Producer{Category: "widget", Name: "w"} - zero := int32(0) - - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "unranked", false, nil), p, 1) - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "ranked-zero", false, &zero), p, 2) - - got := texts(t, s.Contents(renderv1.Region_REGION_SIDEBAR)) - if want := []string{"ranked-zero", "unranked"}; !equal(got, want) { - t.Fatalf("zero priority treated as unset: got %v, want %v", got, want) - } -} - -func TestContentsIsDeterministic(t *testing.T) { - t.Parallel() - - s := region.NewStore() - - for i := range 20 { - p := region.Producer{Category: "widget", Name: string(rune('a' + i%5))} - s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "n", false, nil), p, uint64(i)) - } - - first := texts(t, s.Contents(renderv1.Region_REGION_MAIN_CHAT)) - - for range 25 { - if got := texts(t, s.Contents(renderv1.Region_REGION_MAIN_CHAT)); !equal(got, first) { - t.Fatal("Contents returned a different order across calls; paint order is not deterministic") - } - } -} - -func TestContentsReturnsACopy(t *testing.T) { - t.Parallel() - - s := region.NewStore() - p := region.Producer{Category: "tool", Name: "fs"} - - s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "a", false, nil), p, 1) - s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "b", false, nil), p, 2) - - got := s.Contents(renderv1.Region_REGION_MAIN_CHAT) - got[0], got[1] = got[1], got[0] - - after := texts(t, s.Contents(renderv1.Region_REGION_MAIN_CHAT)) - if want := []string{"a", "b"}; !equal(after, want) { - t.Fatalf("mutating the returned slice disturbed the store: got %v", after) - } -} - -func TestPlaceIgnoresNilContent(t *testing.T) { - t.Parallel() - - s := region.NewStore() - p := region.Producer{Category: "tool", Name: "fs"} - - s.Place(nil, p, 1) - s.Place(&renderv1.PlacedContent{Region: renderv1.Region_REGION_MAIN_CHAT}, p, 2) - - if got := s.Contents(renderv1.Region_REGION_MAIN_CHAT); len(got) != 0 { - t.Fatalf("nil content was stored: %d placements", len(got)) - } -} - -func TestContentsOfUnknownRegionIsEmpty(t *testing.T) { - t.Parallel() - - if got := region.NewStore().Contents(renderv1.Region(99)); got != nil { - t.Fatalf("expected nil for out-of-range region, got %v", got) - } -} - -func TestStreamsAccumulateByTargetID(t *testing.T) { - t.Parallel() - - s := region.NewStore() - - s.Delta("a", "hello ") - s.Delta("b", "other") - s.Delta("a", "world") - - got := s.Streams() - if len(got) != 2 { - t.Fatalf("got %d streams, want 2", len(got)) - } - - // Arrival order, not map order. - if got[0].TargetID != "a" || got[0].Text != "hello world" { - t.Fatalf("stream a = %+v, want accumulated text in arrival position 0", got[0]) - } - - if got[1].TargetID != "b" || got[1].Text != "other" { - t.Fatalf("stream b = %+v", got[1]) - } -} - -func TestClearStream(t *testing.T) { - t.Parallel() - - s := region.NewStore() - s.Delta("a", "x") - s.Delta("b", "y") - s.ClearStream("a") - - got := s.Streams() - if len(got) != 1 || got[0].TargetID != "b" { - t.Fatalf("ClearStream removed the wrong buffer: %+v", got) - } - - s.ClearProducerStreams() - - if len(s.Streams()) != 0 { - t.Fatal("ClearProducerStreams left buffers behind") - } -} - -func TestReset(t *testing.T) { - t.Parallel() - - s := region.NewStore() - p := region.Producer{Category: "tool", Name: "fs"} - - s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "a", false, nil), p, 1) - s.Place(placed(renderv1.Region_REGION_SIDEBAR, "b", false, nil), p, 2) - s.Delta("t", "streaming") - - s.Reset() - - if len(s.Contents(renderv1.Region_REGION_MAIN_CHAT)) != 0 || - len(s.Contents(renderv1.Region_REGION_SIDEBAR)) != 0 || - len(s.Streams()) != 0 { - t.Fatal("Reset left content behind; a re-backfill would stack on stale state") - } -} diff --git a/internal/tui/shell/CLAUDE.md b/internal/tui/shell/CLAUDE.md deleted file mode 100644 index 6344997..0000000 --- a/internal/tui/shell/CLAUDE.md +++ /dev/null @@ -1,52 +0,0 @@ -# internal/tui/shell — agent notes - -## The shell must never write to stdout or read stdin - -As a `hashicorp/go-plugin` subprocess the shell's stdout carries the handshake and is piped into the host's logger. Painting there corrupts the handshake; reading stdin competes with the plugin transport. `cmd/tui` opens the controlling terminal and passes it as both `tea.WithInput` and `tea.WithOutput`. Never add a `fmt.Println` anywhere in this package, and never remove those program options. - -## Bubble Tea v2 specifics that differ from v1 - -- The module path is `charm.land/bubbletea/v2`, not `github.com/charmbracelet/...`. -- `Model.View()` returns a `tea.View`, not a string, and **alt-screen is a property of that View** — there is no `tea.WithAltScreen()` program option. Every returned View sets `AltScreen`, including the empty one painted while quitting. -- Keys arrive as `tea.KeyPressMsg`. Its `String()` returns the literal text for printable keys and the keystroke name otherwise, which is why space arrives as `"space"` and is handled by its own case rather than by the printable check. -- Full-screen takeover is entirely `View` properties: `AltScreen`, `BackgroundColor`/`ForegroundColor`, `WindowTitle`, `Cursor`, and `MouseMode`. There are no matching program options. - -## Takeover is only complete if the mouse is claimed - -`View.MouseMode = tea.MouseModeCellMotion` is what makes the wheel scroll the transcript. Drop it and the wheel silently scrolls the terminal's buffer behind the alt screen — the gesture appears to work while doing something else entirely, which is worse than not handling it. `TestViewClaimsTheTerminal` asserts every takeover property; the cost of the mode is that drag-selection needs the terminal's shift+drag override. - -Scroll offset tracks the tail while pinned (see `window`), so the first scroll away from the live edge starts where the operator is looking rather than jumping to the top. - -## shift+enter needs key disambiguation - -A bare terminal sends CR for both `enter` and `shift+enter`. Bubble Tea negotiates the Kitty keyboard protocol and `modifyOtherKeys` level 2 at startup, which makes them distinguishable where supported. `alt+enter` and `ctrl+j` are bound to the same action as fallbacks — `ctrl+j` is literally line feed and always works. Do not drop them. - -## The cursor's row comes from Layout, never from local arithmetic - -`Layout.ComposerTop()` is the single place that knows how the frame's bands stack. `cursorScreenPos` derives from it rather than re-deriving the sum, because the two versions already drifted once: the header grew from a single line into a bordered box, `frame()` accounted for it and the cursor did not, and the caret sat two rows above the text it belonged to. - -`TestCursorLandsOnTheComposerRow` asserts against the *painted frame* — it finds the row containing the prompt and checks the cursor is on it — rather than against the arithmetic, which is what makes that class of mistake fail loudly instead of silently. - -## Keep constants in sync with the design doc - -`layout.go`'s breakpoints are documented in `docs/first-party/frontends/tui.md`. They are the same numbers stated twice; change both or the doc becomes a lie. - -## Overlay modality is a correctness property, not styling - -While an overlay is up it captures the keyboard entirely and the focus ring is suspended. `esc` explicitly does **not** resolve a pending decision — turning "go away" into an allow or a deny on the operator's behalf would be indefensible, and there is a test named for it. The protocol also requires overlay content be visually distinct from ambient content. - -## Action nodes dispatch unchanged - -`activate` passes `tool_name`, `args`, and `provider` through verbatim. The protocol states this as a MUST. Do not normalize, default, or reinterpret them on the way out. - -## `regionKey` returning false is what makes layering work - -A region handler that returns `false` lets the key fall through to the global layer. A handler that swallows everything would make `ctrl+c` unreachable from the composer. Keep the `default: return false` branches. - -## This package is pure domain - -No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. Logging belongs in `cmd/tui`, which is where the process boundary actually is. - -## `demo.go` is a fixture with an expiry date - -It exists only because the kernel-side attach path does not. Delete it when the real bridge lands rather than growing it into a second implementation. diff --git a/internal/tui/shell/README.md b/internal/tui/shell/README.md deleted file mode 100644 index aa16d3e..0000000 --- a/internal/tui/shell/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# internal/tui/shell - -The reference TUI's frame: layout, focus, keymap, and the composition of every region into one painted view. - -## What lives here - -| File | Owns | -|---|---| -| `layout.go` | Geometry: which regions fit, how much room each gets, and the fixed order they are dropped in as space runs out | -| `focus.go` | The focus ring and its cycling rules | -| `keymap.go` | Bindings, the three-layer precedence stack, and the hint line those layers generate | -| `agent.go` | The selectable agent roster and the ring `shift+tab` cycles | -| `format.go` | Status-bar value formatting: tokens, cost, duration, percentages | -| `input.go` | The composer buffer: editing, multi-line, and prompt history | -| `event.go` | The message vocabulary and the `EventSource` seam | -| `model.go` | The Bubble Tea model that ties them together | -| `demo.go` | A scripted source that runs the shell without a kernel | - -## The four gaps this package fills - -The frontend protocol defines what content arrives and where it is placed, and deliberately stops there. It specifies no focus model, no keybinding schema, no resize semantics, and no scrollback behavior. This package answers all four for the terminal: - -- **Focus** targets regions, not nodes. Within a focused region an action cursor selects among reachable elements. Overlay is modal and captures the keyboard outright. -- **Keybindings** resolve overlay → focused region → global, with a handled key stopping propagation. -- **Resize** drops regions in a documented order — sidebar, then hints, then top bar — with `main_chat` and `input_bar` never dropped. -- **Scrollback** pins to the live tail unless the operator scrolls away from it, and the mouse wheel is claimed so it scrolls the transcript rather than the terminal behind the alt screen. - -Session data is placed by **how fast it changes**: where the work is (directory, repository) in the top bar, settings that move with the agent (agent, model, thinking, effort) in the composer title, detail (`usage`) in a sidebar panel the shell contributes itself, and only volatile state — context, cache, cost, elapsed — on the single line beneath the composer. Version-control detail is left to a git widget rather than duplicated in shell chrome. Space beside the input is the most-looked-at part of the screen and goes to what actually moves. - -The transcript is bottom-anchored for the same reason: the newest message belongs next to the composer, with empty space above rather than between them. - -Only some of these fields have a protocol source; the rest arrive as messages (`WorkspaceMsg`, `EditStatsMsg`) precisely because the shell performs no I/O and so cannot discover them. See `format.go` for rendering rules and the design doc for the full provenance table. - -It also carries the active **agent** (`Code`/`Plan`/`Chat` by default), cycled with `shift+tab`. An agent is a name plus a theme *tone*, so a roster can come from configuration without naming colors; see `agent.go` and the design doc. - -The operator-facing version of these decisions is [`docs/first-party/frontends/tui.md`](../../../docs/first-party/frontends/tui.md). The constants here are what that document describes; changing one means changing both. - -## Testability - -`Model` performs no I/O and never touches a terminal. An `EventSource` translates whatever it is attached to into the message vocabulary in `event.go`, and operator actions leave through an emitter callback. Every behavior — key routing, focus cycling, responsive dropping, overlay modality — is exercised by calling `Update` directly, with no TTY and no kernel. - -`EventSource` is declared here rather than beside an implementation because this is where it is consumed and the shell needs exactly one method of it. - -## Not built yet - -No kernel-side code launches a frontend plugin and drives its `Attach` stream, so `DemoSource` is currently the only source. It is a fixture, not a shipping path. diff --git a/internal/tui/shell/agent.go b/internal/tui/shell/agent.go deleted file mode 100644 index 409cc4c..0000000 --- a/internal/tui/shell/agent.go +++ /dev/null @@ -1,65 +0,0 @@ -package shell - -import "github.com/pluggableharness/agent/internal/tui/theme" - -// Agent is one selectable agent profile: what it is called and how it is -// colored. -// -// This is deliberately plain data rather than behavior. The roster is expected -// to come from configuration — an `agent_profile` block naming a tone — so -// nothing here may depend on the three built-in entries existing. -// -// Tone is a role, not a color: config says `color = "accent"` and the active -// theme decides what that means, which is what keeps a custom theme able to -// recolor agents along with everything else. -type Agent struct { - Name string - Tone theme.Tone - // Description is shown when the roster is presented as a list. It is - // optional; an empty value simply renders nothing. - Description string -} - -// DefaultAgents is the roster the shell falls back to when configuration -// supplies none. -// -// The three entries are the demo set, not a protocol-defined vocabulary. The -// tones are chosen to be distinguishable at a glance rather than decorative: -// building is the ordinary mode, planning is the careful read-only one and -// borrows the color the rest of the UI already uses for "worth your attention", -// and chat is the one that changes nothing. -var DefaultAgents = []Agent{ - {Name: "Code", Tone: theme.TonePrimary, Description: "build and edit"}, - {Name: "Plan", Tone: theme.ToneWarning, Description: "read-only, no tools applied"}, - {Name: "Chat", Tone: theme.ToneInfo, Description: "conversation only"}, -} - -// agentRing holds the selectable roster and which entry is active. -type agentRing struct { - agents []Agent - active int -} - -func newAgentRing(agents []Agent) agentRing { - if len(agents) == 0 { - agents = DefaultAgents - } - - return agentRing{agents: agents} -} - -// Current returns the active agent. The roster is never empty — a caller that -// supplies none gets the default set — so this always has something to return. -func (r agentRing) Current() Agent { return r.agents[r.active] } - -// cycle advances the selection, wrapping at either end. -func (r *agentRing) cycle(back bool) Agent { - step := 1 - if back { - step = -1 - } - - r.active = (r.active + step + len(r.agents)) % len(r.agents) - - return r.Current() -} diff --git a/internal/tui/shell/agent_test.go b/internal/tui/shell/agent_test.go deleted file mode 100644 index e28d3d8..0000000 --- a/internal/tui/shell/agent_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package shell - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/pluggableharness/agent/internal/tui/theme" -) - -func TestDefaultRosterIsDistinguishable(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - seenName := map[string]bool{} - seenColor := map[string]bool{} - - for _, a := range DefaultAgents { - if seenName[a.Name] { - t.Errorf("duplicate agent name %q", a.Name) - } - - seenName[a.Name] = true - - // Two agents sharing a color would make the badge meaningless. - key := th.Tone(a.Tone) - r, g, b, _ := key.RGBA() - id := string(rune(r)) + string(rune(g)) + string(rune(b)) - - if seenColor[id] { - t.Errorf("agent %q shares a color with another agent", a.Name) - } - - seenColor[id] = true - } - - if len(DefaultAgents) != 3 { - t.Fatalf("expected the Code/Plan/Chat demo roster, got %d entries", len(DefaultAgents)) - } -} - -func TestAgentRingCyclesAndWraps(t *testing.T) { - t.Parallel() - - r := newAgentRing(DefaultAgents) - - if got := r.Current().Name; got != "Code" { - t.Fatalf("initial agent = %q, want Code", got) - } - - want := []string{"Plan", "Chat", "Code"} - for i, w := range want { - if got := r.cycle(false).Name; got != w { - t.Fatalf("forward step %d = %q, want %q", i, got, w) - } - } - - // Backward wraps too, even though nothing binds it today. - if got := r.cycle(true).Name; got != "Chat" { - t.Fatalf("backward step = %q, want Chat", got) - } -} - -// An empty roster keeps the defaults: the shell always needs something to -// display as active, and Current must never index an empty slice. -func TestEmptyRosterFallsBackToDefaults(t *testing.T) { - t.Parallel() - - if got := newAgentRing(nil).Current().Name; got != DefaultAgents[0].Name { - t.Fatalf("nil roster gave %q", got) - } - - if got := newAgentRing([]Agent{}).Current().Name; got != DefaultAgents[0].Name { - t.Fatalf("empty roster gave %q", got) - } -} - -func TestShiftTabCyclesAgentAndAnnounces(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - - if got := m.Agent().Name; got != "Code" { - t.Fatalf("startup agent = %q, want Code", got) - } - - press(t, m, "shift+tab") - - if got := m.Agent().Name; got != "Plan" { - t.Fatalf("after shift+tab = %q, want Plan", got) - } - - got, ok := rec.last(t).(AgentSelected) - if !ok { - t.Fatalf("emitted %T, want AgentSelected", rec.last(t)) - } - - if got.Name != "Plan" { - t.Fatalf("AgentSelected.Name = %q, want Plan", got.Name) - } -} - -// shift+tab must no longer move focus: the agent switcher owns it. -func TestShiftTabDoesNotMoveFocus(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "shift+tab") - - if m.Focus() != FocusInput { - t.Fatalf("shift+tab changed focus to %v", m.Focus()) - } -} - -// Cycling is a global binding, so it works from any focused region rather than -// only from the composer. -func TestAgentCyclesFromAnyRegion(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "tab") // focus main_chat - - press(t, m, "shift+tab") - - if got := m.Agent().Name; got != "Plan" { - t.Fatalf("agent = %q, want Plan", got) - } - - if m.Focus() != FocusMain { - t.Fatalf("cycling the agent disturbed focus: %v", m.Focus()) - } -} - -func TestActiveAgentIsVisibleInTheFrame(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "shift+tab") // -> Plan - press(t, m, "x") // clear the transient notice - - got := plain(m.View().Content) - if !strings.Contains(got, "Plan") { - t.Fatalf("active agent not shown in the frame:\n%s", got) - } - - // The composer titles itself with the agent once no notice is pending. - if strings.Contains(got, "Code") { - t.Errorf("previous agent still visible:\n%s", got) - } -} - -// Switching agents changes the composer accent, which is the ambient signal -// that the mode changed. -func TestAgentColorDrivesTheComposerAccent(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "x") // clear any notice so the accent is the agent's - - code := m.agentColor() - press(t, m, "shift+tab") - press(t, m, "x") - - if m.agentColor() == code { - t.Fatal("agent color did not change with the selection") - } -} - -func TestWithAgentsOverridesTheRoster(t *testing.T) { - t.Parallel() - - custom := []Agent{ - {Name: "Review", Tone: theme.ToneDanger}, - {Name: "Ship", Tone: theme.ToneSuccess}, - } - - m := New(WithAgents(custom)) - m.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) - - if got := m.Agent().Name; got != "Review" { - t.Fatalf("custom roster active agent = %q, want Review", got) - } - - press(t, m, "shift+tab") - - if got := m.Agent().Name; got != "Ship" { - t.Fatalf("after cycle = %q, want Ship", got) - } -} - -// A notice occupies the composer title, so it must not stick around and hide -// the active agent forever. -func TestNoticeClearsOnNextKeystroke(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(NoticeMsg{Text: "something happened", Level: NoticeWarn}) - - if !strings.Contains(plain(m.View().Content), "something happened") { - t.Fatal("notice was not shown") - } - - press(t, m, "x") - - if strings.Contains(plain(m.View().Content), "something happened") { - t.Fatal("notice survived a keystroke and would hide the agent title") - } -} diff --git a/internal/tui/shell/demo.go b/internal/tui/shell/demo.go deleted file mode 100644 index dcd1fb7..0000000 --- a/internal/tui/shell/demo.go +++ /dev/null @@ -1,164 +0,0 @@ -package shell - -import ( - "context" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/pluggableharness/agent/internal/tui/region" - "github.com/pluggableharness/agent/pkg/render" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -// DemoSource is a scripted EventSource that exercises every region and node -// type without a kernel. -// -// It exists because no kernel-side code launches a frontend plugin and drives -// its Attach stream yet: cmd/agent is non-interactive and the frontend-backed -// interactive driver is still pending. Until that lands this is what makes the -// shell runnable and reviewable. It is a fixture, not a shipping path, and it -// should be deleted the moment the real bridge exists. -type DemoSource struct { - // Step paces the script. Zero means emit everything immediately, which is - // what tests want. - Step time.Duration -} - -// Run implements EventSource. -func (d DemoSource) Run(ctx context.Context, send func(tea.Msg)) error { - for _, msg := range demoScript() { - select { - case <-ctx.Done(): - // Cancellation is ordinary control flow for a stream, never an - // error to report. - return nil - default: - } - - send(msg) - - if d.Step > 0 { - select { - case <-ctx.Done(): - return nil - case <-time.After(d.Step): - } - } - } - - <-ctx.Done() - - return nil -} - -// demoScript is the fixed sequence of messages the demo emits. It is a pure -// function so tests can assert against it without running the source. -func demoScript() []tea.Msg { - kernel := region.Producer{Category: "kernel", Name: "demo"} - // Two distinct widget producers, so the sidebar exercises coexistence and - // priority ordering rather than one widget replacing the other. - gitWidget := region.Producer{Category: "widget", Name: "git"} - jobsWidget := region.Producer{Category: "widget", Name: "jobs"} - - return []tea.Msg{ - StatusMsg{ - Session: "session-01DEMO", - Model: "claude-opus-5", - Status: "ready", - Thinking: "extended", - Effort: "high", - Elapsed: 22 * time.Minute, - }, - - WorkspaceMsg{ - Directory: "~/code/aiagent", - Repository: "pluggableharness/agent", - }, - - EditStatsMsg{LinesRead: 4820, LinesAdded: 612, LinesRemoved: 148}, - - place(kernel, 1, renderv1.Region_REGION_MAIN_CHAT, false, nil, render.Tree(render.Group( - render.TextStyled("Reference TUI shell", renderv1.TextStyle_TEXT_STYLE_BOLD), - render.Text("Every region below is plugin-contributable. This content is a fixture."), - ))), - - place(kernel, 2, renderv1.Region_REGION_MAIN_CHAT, false, nil, render.Tree(render.Collapsible( - "read_file(internal/tui/shell/model.go)", - render.Code("go", "func (m *Model) View() tea.View {\n\t// ...\n}"), - ))), - - place(kernel, 3, renderv1.Region_REGION_MAIN_CHAT, false, nil, render.Tree(render.Diff( - render.Hunk(12, 3, 12, 4, - render.DiffContextLine("func Solve(width, height int) Layout {"), - render.DiffRemoveLine("\treturn Layout{}"), - render.DiffAddLine("\tl := Layout{Width: width}"), - render.DiffAddLine("\treturn l"), - ), - ))), - - place(kernel, 4, renderv1.Region_REGION_MAIN_CHAT, false, nil, render.Tree(render.Group( - render.TextStyled("Interactive content", renderv1.TextStyle_TEXT_STYLE_DIM), - render.Action("act_compact", "Compact context", "compact_context", nil, "builtin"), - ))), - - place(kernel, 5, renderv1.Region_REGION_MAIN_CHAT, false, nil, - render.Tree(render.SubSession("session-01CHILD", "search the codebase"))), - - // No repeated heading: the panel title already names the producer. - // With workspace detail out of shell chrome, the git widget is the only - // source of VCS state — which is the point: one truth, contributed by - // the plugin that owns it. - place(gitWidget, 6, renderv1.Region_REGION_SIDEBAR, true, new(int32(10)), render.Tree(render.Group( - render.Text("feat/tui-shell"), - render.TextStyled("3 modified", renderv1.TextStyle_TEXT_STYLE_WARNING), - render.Text("pr #11"), - render.Action("act_diff", "Review diff", "git_diff", nil, "git"), - ))), - - // A second widget of a different kind: the shell already reports context - // and cost itself, so a fixture that repeated them would demonstrate - // duplication rather than what widgets are for. - place(jobsWidget, 7, renderv1.Region_REGION_SIDEBAR, true, new(int32(20)), render.Tree(render.Group( - render.TextStyled("build ✓ 2.1s", renderv1.TextStyle_TEXT_STYLE_SUCCESS), - render.TextStyled("tests running", renderv1.TextStyle_TEXT_STYLE_WARNING), - ))), - - UsageMsg{ - UsedTokens: 51_204, - EffectiveCeiling: 200_000, - CumulativeCostUSD: 0.42, - InputTokens: 18_400, - OutputTokens: 9_120, - CacheReadTokens: 146_800, - CacheWriteTokens: 22_050, - }, - - DeltaMsg{TargetID: "msg_1", Text: "Streaming text arrives token "}, - DeltaMsg{TargetID: "msg_1", Text: "by token on the fast path."}, - - PermissionMsg{ - ItemID: "item_1", - Title: "Allow write_file(internal/tui/shell/model.go)?", - Preview: render.Tree(render.Diff( - render.Hunk(1, 1, 1, 2, - render.DiffContextLine("package shell"), - render.DiffAddLine("// added by the plan"), - ), - )), - }, - } -} - -func place(p region.Producer, seq uint64, r renderv1.Region, replace bool, priority *int32, tree *renderv1.RenderTree) PlaceMsg { - return PlaceMsg{ - Producer: p, - Sequence: seq, - Content: &renderv1.PlacedContent{ - Region: r, - Content: tree, - Replace: replace, - Priority: priority, - }, - } -} diff --git a/internal/tui/shell/doc.go b/internal/tui/shell/doc.go deleted file mode 100644 index c969e52..0000000 --- a/internal/tui/shell/doc.go +++ /dev/null @@ -1,21 +0,0 @@ -// Package shell is the reference TUI's frame: layout, focus, keymap, and the -// composition of every region into one painted view. -// -// The shell fills the four gaps the frontend protocol deliberately leaves to -// the implementation — focus, keybindings, terminal resize, and scrollback — -// none of which appear anywhere in docs/specifications/frontend/. Those -// decisions are documented for operators in -// docs/first-party/frontends/tui.md, and the constants and tables here are the -// authority that document describes; changing one means changing both. -// -// The design is message-driven and I/O-free. Model performs no reads or writes -// and never touches a terminal: an EventSource translates whatever it is -// attached to into the message vocabulary in event.go, and operator actions -// leave through an emitter callback. That is what allows the whole shell — -// including key routing, focus cycling, and responsive region dropping — to be -// exercised by calling Update directly, with no TTY and no kernel. -// -// EventSource is declared in this package rather than beside an implementation -// because this is where it is consumed, and the shell needs exactly one method -// of it. -package shell diff --git a/internal/tui/shell/event.go b/internal/tui/shell/event.go deleted file mode 100644 index 2d2092a..0000000 --- a/internal/tui/shell/event.go +++ /dev/null @@ -1,227 +0,0 @@ -package shell - -import ( - "context" - "time" - - tea "charm.land/bubbletea/v2" - "google.golang.org/protobuf/types/known/structpb" - - "github.com/pluggableharness/agent/internal/tui/region" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -// EventSource feeds the shell. It is declared here, in the package that -// consumes it, rather than beside an implementation — the shell needs exactly -// one method and should not depend on a transport to say so. -// -// Run blocks until ctx is canceled or the source is exhausted, delivering -// messages through send. Implementations translate their own inputs into the -// message vocabulary below; no wire type reaches the model unconverted. -type EventSource interface { - Run(ctx context.Context, send func(tea.Msg)) error -} - -// PlaceMsg delivers content for a region. It is the translated form of a -// ServerEvent.render carrying PlacedContent. -type PlaceMsg struct { - Content *renderv1.PlacedContent - Producer region.Producer - Sequence uint64 -} - -// DeltaMsg is streamed model text. Consecutive deltas sharing a TargetID -// accumulate into one growing block rather than separate lines. -type DeltaMsg struct { - TargetID string - Text string -} - -// SettledMsg reports that a streamed block finished, so its live buffer can be -// dropped in favor of the finished render that replaces it. -type SettledMsg struct{ TargetID string } - -// PermissionMsg asks the operator to decide one plan item. The protocol -// requires this be presented in the overlay region with a visual treatment -// distinct from ambient content, and the decision unit is always one item — -// never one answer for a whole plan. -type PermissionMsg struct { - ItemID string - Title string - // Preview is the plan item's preview tree when the provider supplied one. - // When it is nil the shell falls back to rendering the raw input, which the - // plan/apply gate spec requires rather than showing nothing. - Preview *renderv1.RenderTree - RawInput string -} - -// NoticeMsg is an out-of-band message for the operator: an error, a rejected -// client event, or a session status change. Notices are surfaced, never -// silently dropped, because several of the protocol's error categories are -// explicitly required to be visible and distinct. -type NoticeMsg struct { - Text string - Level NoticeLevel -} - -// NoticeLevel classifies a notice for styling. -type NoticeLevel int - -const ( - // NoticeInfo is ordinary progress information. - NoticeInfo NoticeLevel = iota - // NoticeWarn is worth attention short of an error. - NoticeWarn - // NoticeError is a failure. - NoticeError -) - -// StatusMsg updates the top bar and the model line of the status bar. -type StatusMsg struct { - Session string - Model string - Status string - // Thinking and Effort describe the model's reasoning configuration as the - // operator would say it ("extended", "high"). ModelSpec carries a - // ThinkingSpec, but no frontend-facing event exposes it yet — the bridge - // resolves it and passes it through here. - // - // They sit beside the composer rather than in the top bar because they - // change with the selected agent: they are settings, not identity. - Thinking string - Effort string - // Elapsed is how long the session has been running. - // - // It arrives pre-computed rather than being derived from a start time, - // because the model is pure: it never reads the clock. Whatever drives the - // shell decides how often this ticks. - Elapsed time.Duration -} - -// DismissOverlayMsg clears overlay content the shell did not resolve itself — -// the case where another frontend won a decision race and the kernel rejected -// this shell's late response. -type DismissOverlayMsg struct{ Reason string } - -// Action is an operator-originated event destined for the kernel's Attach -// stream. The shell emits these; the bridge translates them to ClientEvents. -type Action interface{ isAction() } - -// SubmitPrompt is a user message. The protocol carries content blocks rather -// than a bare string; the bridge wraps this text in a text block. -type SubmitPrompt struct{ Text string } - -// DecisionScope mirrors the protocol's PlanDecisionScope. -type DecisionScope int - -const ( - // ScopeOnce applies to this item only. It is the default the shell sends - // absent explicit operator intent, which the spec states as a SHOULD. - ScopeOnce DecisionScope = iota - // ScopeSession remembers the verdict for the rest of the session. - ScopeSession - // ScopeAlways persists the verdict as policy. - ScopeAlways -) - -// Decision resolves one plan item. -type Decision struct { - ItemID string - Allow bool - Scope DecisionScope -} - -// Trigger activates an ActionNode. The protocol requires the node's tool name, -// args, and provider be dispatched unchanged, so this carries them verbatim -// rather than reinterpreting them. -type Trigger struct { - NodeID string - ToolName string - Provider string - Args *structpb.Struct -} - -// Interrupt cancels the running turn. Cancellation cascades to the whole -// sub-agent tree, so this is never scoped to a single child. -type Interrupt struct{} - -func (SubmitPrompt) isAction() {} -func (Decision) isAction() {} -func (Trigger) isAction() {} -func (Interrupt) isAction() {} - -// AgentSelected reports that the operator switched agent profile. -// -// No ClientEvent carries this today: the frontend protocol's client-event set -// has no agent-profile variant, and a session's profile is fixed when the -// session is created. The shell therefore keeps the selection as local state -// and emits this so the bridge can decide what it means — most plausibly the -// profile for the next session, or a direct-invoke slash command. Closing that -// gap is a protocol question, not a shell one; see the design doc. -type AgentSelected struct { - Name string -} - -func (AgentSelected) isAction() {} - -// UsageMsg carries the session's context pressure and running cost, translated -// from ServerEvent.usage_update. -// -// The denominator is the *effective ceiling*, not the model's raw context -// window: the ceiling is what remains after the kernel reserves room for -// expected output and tool schemas, and it is the figure the protocol names as -// the one a context-budget indicator should divide against. Showing pressure -// against the raw window would understate it — an operator would read 70% while -// the next turn is already at risk of not fitting. -type UsageMsg struct { - UsedTokens int64 - EffectiveCeiling int64 - CumulativeCostUSD float64 - - // Cumulative token split, from model.v1.Usage. Cache reads are never also - // counted in InputTokens, which is what makes the cache rate below a real - // ratio rather than a double count. - InputTokens int64 - OutputTokens int64 - CacheReadTokens int64 - CacheWriteTokens int64 -} - -// CacheRate is the share of input that came from cache, and whether there was -// enough input to say. It is the number that explains why a turn was cheap. -func (u UsageMsg) CacheRate() (float64, bool) { - total := u.InputTokens + u.CacheReadTokens - if total <= 0 { - return 0, false - } - - return float64(u.CacheReadTokens) / float64(total), true -} - -// WorkspaceMsg describes where the session is working. -// -// None of this is in the protocol: there is no workspace or VCS concept -// anywhere in the wire contracts, and there should not be one invented just to -// feed a status bar. It arrives as a message so the shell stays pure — cmd/tui -// can supply the directory, and a git widget is the natural source for the rest. -// Only what the shell actually renders is carried here. Branch, subtree, and -// pull request are deliberately absent: they are version-control detail, a git -// widget already contributes them as ordinary sidebar content, and duplicating -// them in shell chrome would give the operator two sources for one truth. -type WorkspaceMsg struct { - // Directory is where the session is rooted. - Directory string - // Repository is the project it belongs to, e.g. "org/name". - Repository string -} - -// EditStatsMsg counts what the session has read and changed. -// -// Also not in the protocol: no event aggregates per-tool line counts today. A -// tool provider knows them, so the natural path is a widget or a kernel-side -// rollup — either way it reaches the shell as this message. -type EditStatsMsg struct { - LinesRead int64 - LinesAdded int64 - LinesRemoved int64 -} diff --git a/internal/tui/shell/focus.go b/internal/tui/shell/focus.go deleted file mode 100644 index 647b059..0000000 --- a/internal/tui/shell/focus.go +++ /dev/null @@ -1,77 +0,0 @@ -package shell - -// Focus identifies which region owns the keyboard. -// -// Focus targets are regions, not individual nodes: within a focused region an -// action cursor selects among that region's reachable elements. The protocol -// has no concept of focus at all, so this is entirely the shell's model. -type Focus int - -const ( - // FocusInput is the composer. It holds focus at startup, because typing is - // the overwhelmingly common intent and a shell that demands a keystroke - // before accepting text is hostile. - FocusInput Focus = iota - // FocusMain is the conversation transcript. - FocusMain - // FocusSidebar is the widget column. - FocusSidebar -) - -// String returns the region name, used in the hotkey hint line. -func (f Focus) String() string { - switch f { - case FocusInput: - return "input" - case FocusMain: - return "chat" - case FocusSidebar: - return "sidebar" - default: - return "unknown" - } -} - -// focusRing returns the focus targets currently reachable by tab, in cycle -// order. A region that is off screen, or on screen with nothing to interact -// with, is omitted rather than being a dead stop in the cycle. -func focusRing(l Layout, sidebarHasContent bool) []Focus { - ring := []Focus{FocusInput, FocusMain} - if l.ShowSidebar && sidebarHasContent { - ring = append(ring, FocusSidebar) - } - - return ring -} - -// cycleFocus advances focus around the ring. A current focus that is no longer -// in the ring — the sidebar closing while focused, say — resolves to the first -// entry rather than trapping the keyboard in a region that is gone. -func cycleFocus(cur Focus, ring []Focus, back bool) Focus { - if len(ring) == 0 { - return FocusInput - } - - idx := -1 - - for i, f := range ring { - if f == cur { - idx = i - - break - } - } - - if idx < 0 { - return ring[0] - } - - step := 1 - if back { - step = -1 - } - - next := (idx + step + len(ring)) % len(ring) - - return ring[next] -} diff --git a/internal/tui/shell/focus_test.go b/internal/tui/shell/focus_test.go deleted file mode 100644 index b248daf..0000000 --- a/internal/tui/shell/focus_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package shell - -import "testing" - -func sameFocus(a, b []Focus) bool { - if len(a) != len(b) { - return false - } - - for i := range a { - if a[i] != b[i] { - return false - } - } - - return true -} - -func TestFocusString(t *testing.T) { - t.Parallel() - - tests := map[Focus]string{ - FocusInput: "input", - FocusMain: "chat", - FocusSidebar: "sidebar", - Focus(99): "unknown", - } - - for f, want := range tests { - if got := f.String(); got != want { - t.Errorf("Focus(%d).String() = %q, want %q", f, got, want) - } - } -} - -// A region that is off screen, or on screen with nothing to interact with, is -// omitted rather than being a dead stop in the tab cycle. -func TestFocusRingOmitsUnreachableRegions(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - sidebar bool - hasContent bool - want []Focus - }{ - {"sidebar hidden", false, true, []Focus{FocusInput, FocusMain}}, - {"sidebar shown but empty", true, false, []Focus{FocusInput, FocusMain}}, - {"sidebar shown with content", true, true, []Focus{FocusInput, FocusMain, FocusSidebar}}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got := focusRing(Layout{ShowSidebar: tc.sidebar}, tc.hasContent) - if !sameFocus(got, tc.want) { - t.Fatalf("focusRing = %v, want %v", got, tc.want) - } - }) - } -} - -func TestCycleFocusWrapsBothWays(t *testing.T) { - t.Parallel() - - ring := []Focus{FocusInput, FocusMain, FocusSidebar} - - forward := []Focus{FocusMain, FocusSidebar, FocusInput} - cur := FocusInput - - for i, want := range forward { - cur = cycleFocus(cur, ring, false) - if cur != want { - t.Fatalf("forward step %d = %v, want %v", i, cur, want) - } - } - - backward := []Focus{FocusSidebar, FocusMain, FocusInput} - cur = FocusInput - - for i, want := range backward { - cur = cycleFocus(cur, ring, true) - if cur != want { - t.Fatalf("backward step %d = %v, want %v", i, cur, want) - } - } -} - -// The sidebar closing while focused must not trap the keyboard in a region -// that is no longer there. -func TestCycleFocusRecoversFromAVanishedRegion(t *testing.T) { - t.Parallel() - - ring := []Focus{FocusInput, FocusMain} - - if got := cycleFocus(FocusSidebar, ring, false); got != FocusInput { - t.Fatalf("cycling from a vanished region = %v, want %v", got, FocusInput) - } -} - -func TestCycleFocusEmptyRing(t *testing.T) { - t.Parallel() - - if got := cycleFocus(FocusMain, nil, false); got != FocusInput { - t.Fatalf("empty ring = %v, want %v", got, FocusInput) - } -} diff --git a/internal/tui/shell/format.go b/internal/tui/shell/format.go deleted file mode 100644 index 59f9acd..0000000 --- a/internal/tui/shell/format.go +++ /dev/null @@ -1,132 +0,0 @@ -package shell - -import ( - "fmt" - "math" - "strconv" - "strings" - "time" -) - -const ( - // minMeterBar is the shortest fill bar worth drawing. Below it the bar says - // less than the percentage printed beside it, so the segment drops the bar - // and keeps the number. - minMeterBar = 8 - // minContextSegment reserves the label, the absolute figures, the - // percentage, and a bar of at least minMeterBar. - // - // Reserving room for the bar rather than just the text is what keeps the - // line stable while a terminal is resized: without it, a right-hand field - // becoming affordable could take the meter from drawable to - // below-the-minimum in a single column, so the bar blinked out and back as - // the window moved. - minContextSegment = 36 -) - -// formatPercent renders a 0..1 fraction as a whole percentage. -func formatPercent(f float64) string { - return strconv.Itoa(int(math.Round(math.Min(math.Max(f, 0), 1)*100))) + "%" -} - -// formatTokens abbreviates a token count: exact below a thousand, then k, then -// M. A status bar has no room for nine digits, and nobody reads them anyway. -func formatTokens(n int64) string { - switch { - case n < 0: - return "0" - case n < 1_000: - return strconv.FormatInt(n, 10) - case n < 1_000_000: - return trimZero(float64(n)/1_000) + "k" - default: - return trimZero(float64(n)/1_000_000) + "M" - } -} - -// trimZero renders one decimal place, dropping it when it is zero, so counts -// read as "18.2k" and "5k" rather than "5.0k". -func trimZero(v float64) string { - s := strconv.FormatFloat(v, 'f', 1, 64) - - return strings.TrimSuffix(s, ".0") -} - -// formatUSD renders a cost. Sub-cent amounts get a third decimal rather than -// rounding to $0.00, which would read as free. -func formatUSD(v float64) string { - if v > 0 && v < 0.01 { - return fmt.Sprintf("$%.3f", v) - } - - return fmt.Sprintf("$%.2f", v) -} - -// formatDuration renders elapsed time at the coarsest useful precision. -func formatDuration(d time.Duration) string { - if d <= 0 { - return "" - } - - d = d.Round(time.Second) - - h := int(d.Hours()) - mn := int(d.Minutes()) % 60 - sec := int(d.Seconds()) % 60 - - if h > 0 { - return fmt.Sprintf("%dh%02dm", h, mn) - } - - if mn > 0 { - return fmt.Sprintf("%dm%02ds", mn, sec) - } - - return strconv.Itoa(sec) + "s" -} - -// summary renders the read/changed line counts, or empty when nothing has been -// touched yet. -func (e EditStatsMsg) summary() string { - if e.LinesRead == 0 && e.LinesAdded == 0 && e.LinesRemoved == 0 { - return "" - } - - return fmt.Sprintf("%s read +%s -%s", - formatTokens(e.LinesRead), formatTokens(e.LinesAdded), formatTokens(e.LinesRemoved)) -} - -// tokenSummary renders the write/cache-write/read split. It is nil-safe -// because usage is absent until the first turn reports. -func (u *UsageMsg) tokenSummary() string { - if u == nil { - return "" - } - - return fmt.Sprintf("%s out %s in %s cw", - formatTokens(u.OutputTokens), formatTokens(u.InputTokens+u.CacheReadTokens), formatTokens(u.CacheWriteTokens)) -} - -// cacheRate is the nil-safe form of UsageMsg.CacheRate. -func (u *UsageMsg) cacheRate() (float64, bool) { - if u == nil { - return 0, false - } - - return u.CacheRate() -} - -// tokens renders one token count from usage, nil-safe, or empty when there is -// no usage yet or the count is zero. -func (u *UsageMsg) tokens(pick func(*UsageMsg) int64) string { - if u == nil { - return "" - } - - n := pick(u) - if n <= 0 { - return "" - } - - return formatTokens(n) -} diff --git a/internal/tui/shell/format_test.go b/internal/tui/shell/format_test.go deleted file mode 100644 index 2dbddfa..0000000 --- a/internal/tui/shell/format_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package shell - -import ( - "strings" - "testing" - "time" -) - -func TestFormatTokens(t *testing.T) { - t.Parallel() - - tests := map[int64]string{ - 0: "0", 42: "42", 999: "999", - 1_000: "1k", 1_240: "1.2k", 18_200: "18.2k", 999_000: "999k", - 1_000_000: "1M", 2_450_000: "2.5M", - -5: "0", - } - - for in, want := range tests { - if got := formatTokens(in); got != want { - t.Errorf("formatTokens(%d) = %q, want %q", in, got, want) - } - } -} - -// A sub-cent cost must not round to $0.00, which reads as free. -func TestFormatUSD(t *testing.T) { - t.Parallel() - - tests := map[float64]string{ - 0: "$0.00", 0.004: "$0.004", 0.42: "$0.42", 12.5: "$12.50", - } - - for in, want := range tests { - if got := formatUSD(in); got != want { - t.Errorf("formatUSD(%v) = %q, want %q", in, got, want) - } - } -} - -func TestFormatDuration(t *testing.T) { - t.Parallel() - - tests := map[time.Duration]string{ - 0: "", - -time.Second: "", - 45 * time.Second: "45s", - 90 * time.Second: "1m30s", - 22 * time.Minute: "22m00s", - 2*time.Hour + 5*time.Minute: "2h05m", - } - - for in, want := range tests { - if got := formatDuration(in); got != want { - t.Errorf("formatDuration(%v) = %q, want %q", in, got, want) - } - } -} - -func TestFormatPercent(t *testing.T) { - t.Parallel() - - tests := map[float64]string{0: "0%", 0.256: "26%", 1: "100%", -1: "0%", 5: "100%"} - - for in, want := range tests { - if got := formatPercent(in); got != want { - t.Errorf("formatPercent(%v) = %q, want %q", in, got, want) - } - } -} - -// Cache reads are never also counted as input tokens, so the rate is a real -// ratio rather than a double count. -func TestCacheRate(t *testing.T) { - t.Parallel() - - u := UsageMsg{InputTokens: 100, CacheReadTokens: 900} - - got, ok := u.CacheRate() - if !ok || got != 0.9 { - t.Fatalf("CacheRate() = (%v, %v), want (0.9, true)", got, ok) - } - - if _, ok := (UsageMsg{}).CacheRate(); ok { - t.Error("CacheRate claimed to know a rate with no tokens") - } - - // Nil-safe: usage is absent until the first turn reports. - var absent *UsageMsg - if _, ok := absent.cacheRate(); ok { - t.Error("nil usage reported a cache rate") - } - - if got := absent.tokenSummary(); got != "" { - t.Errorf("nil usage token summary = %q", got) - } -} - -func TestEditStatsSummary(t *testing.T) { - t.Parallel() - - if got := (EditStatsMsg{}).summary(); got != "" { - t.Errorf("untouched summary = %q, want empty", got) - } - - got := EditStatsMsg{LinesRead: 4820, LinesAdded: 612, LinesRemoved: 148}.summary() - for _, want := range []string{"4.8k", "+612", "-148"} { - if !strings.Contains(got, want) { - t.Errorf("summary %q missing %q", got, want) - } - } -} diff --git a/internal/tui/shell/input.go b/internal/tui/shell/input.go deleted file mode 100644 index 86c877d..0000000 --- a/internal/tui/shell/input.go +++ /dev/null @@ -1,178 +0,0 @@ -package shell - -import "strings" - -// input is the composer's editable buffer. -// -// It is deliberately small rather than a full editor: enough to type, correct, -// and recall a prompt, with no dependency on a terminal so every editing rule -// is unit-testable. Text is held as runes so cursor motion is -// grapheme-approximate rather than byte-indexed, which is what keeps multi-byte -// input from corrupting on backspace. -type input struct { - text []rune - cursor int - - history []string - // histIdx walks history from the end; len(history) means "not browsing", - // which is the state a fresh keystroke always returns to. - histIdx int - // draft preserves what was typed before history browsing started. - draft string -} - -func newInput() *input { return &input{} } - -// Value returns the current buffer contents. -func (i *input) Value() string { return string(i.text) } - -// Lines reports how many display lines the buffer needs. -func (i *input) Lines() int { return strings.Count(string(i.text), "\n") + 1 } - -// Insert adds runes at the cursor. -func (i *input) Insert(rs ...string) { - for _, s := range rs { - for _, r := range s { - i.text = append(i.text, 0) - copy(i.text[i.cursor+1:], i.text[i.cursor:]) - i.text[i.cursor] = r - i.cursor++ - } - } - - i.stopBrowsing() -} - -// Backspace deletes the rune before the cursor. -func (i *input) Backspace() { - if i.cursor == 0 { - return - } - - i.text = append(i.text[:i.cursor-1], i.text[i.cursor:]...) - i.cursor-- - i.stopBrowsing() -} - -// Left moves the cursor one rune left. -func (i *input) Left() { - if i.cursor > 0 { - i.cursor-- - } -} - -// Right moves the cursor one rune right. -func (i *input) Right() { - if i.cursor < len(i.text) { - i.cursor++ - } -} - -// Home moves the cursor to the start of the buffer. -func (i *input) Home() { i.cursor = 0 } - -// End moves the cursor to the end of the buffer. -func (i *input) End() { i.cursor = len(i.text) } - -// Empty reports whether the buffer has no content. -func (i *input) Empty() bool { return len(i.text) == 0 } - -// OnFirstLine reports whether the cursor sits on the buffer's first line, -// which is what makes "up" mean history rather than cursor motion. -func (i *input) OnFirstLine() bool { - return !strings.Contains(string(i.text[:i.cursor]), "\n") -} - -// OnLastLine reports whether the cursor sits on the buffer's last line. -func (i *input) OnLastLine() bool { - return !strings.Contains(string(i.text[i.cursor:]), "\n") -} - -// Submit returns the buffer, records it in history, and clears the composer. -// An all-whitespace buffer returns ok false and is neither sent nor recorded. -func (i *input) Submit() (string, bool) { - v := strings.TrimSpace(string(i.text)) - if v == "" { - return "", false - } - - i.history = append(i.history, v) - i.text = nil - i.cursor = 0 - i.draft = "" - i.histIdx = len(i.history) - - return v, true -} - -// HistoryPrev recalls the previous entry, preserving the in-progress draft on -// the first step back so browsing away and back is non-destructive. -func (i *input) HistoryPrev() { - if len(i.history) == 0 || i.histIdx == 0 { - return - } - - if i.histIdx == len(i.history) { - i.draft = string(i.text) - } - - i.histIdx-- - i.set(i.history[i.histIdx]) -} - -// HistoryNext walks forward, restoring the preserved draft past the newest -// entry. -func (i *input) HistoryNext() { - if i.histIdx >= len(i.history) { - return - } - - i.histIdx++ - if i.histIdx == len(i.history) { - i.set(i.draft) - - return - } - - i.set(i.history[i.histIdx]) -} - -func (i *input) set(s string) { - i.text = []rune(s) - i.cursor = len(i.text) -} - -// stopBrowsing returns the buffer to "editing a draft" state, so a keystroke -// during history browsing keeps what is on screen instead of snapping back. -func (i *input) stopBrowsing() { i.histIdx = len(i.history) } - -// CursorPos reports the cursor's line and column within the buffer, both -// zero-based. -// -// The shell turns this into an absolute screen position and hands it to Bubble -// Tea as the real terminal cursor, which is why the buffer renders no caret -// glyph of its own: a drawn caret and a real cursor would both be visible. -func (i *input) CursorPos() (line, col int) { - for _, r := range i.text[:i.cursor] { - if r == '\n' { - line++ - col = 0 - - continue - } - - col++ - } - - return line, col -} - -// render draws the buffer. Placeholder text stands in when the buffer is empty -// and the composer has focus, so the pane never looks broken. -func (i *input) render(placeholder string) string { - if i.Empty() { - return placeholder - } - - return string(i.text) -} diff --git a/internal/tui/shell/input_test.go b/internal/tui/shell/input_test.go deleted file mode 100644 index 5ae4765..0000000 --- a/internal/tui/shell/input_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package shell - -import "testing" - -func TestInsertAndBackspace(t *testing.T) { - t.Parallel() - - in := newInput() - in.Insert("h", "i") - - if got := in.Value(); got != "hi" { - t.Fatalf("Value = %q, want %q", got, "hi") - } - - in.Backspace() - - if got := in.Value(); got != "h" { - t.Fatalf("after backspace = %q, want %q", got, "h") - } - - in.Backspace() - in.Backspace() // past the start is a no-op, not a panic - - if !in.Empty() { - t.Fatalf("expected empty buffer, got %q", in.Value()) - } -} - -// Multi-byte input must not corrupt on backspace, which is why the buffer is -// held as runes rather than bytes. -func TestMultiByteEditing(t *testing.T) { - t.Parallel() - - in := newInput() - in.Insert("héllo→") - in.Backspace() - - if got := in.Value(); got != "héllo" { - t.Fatalf("multi-byte backspace corrupted the buffer: %q", got) - } -} - -func TestCursorMovementAndMidBufferInsert(t *testing.T) { - t.Parallel() - - in := newInput() - in.Insert("ac") - in.Left() - in.Insert("b") - - if got := in.Value(); got != "abc" { - t.Fatalf("mid-buffer insert = %q, want %q", got, "abc") - } - - in.Home() - in.Insert(">") - - if got := in.Value(); got != ">abc" { - t.Fatalf("insert at home = %q, want %q", got, ">abc") - } - - in.End() - in.Insert("<") - - if got := in.Value(); got != ">abc<" { - t.Fatalf("insert at end = %q, want %q", got, ">abc<") - } - - // Motion past either boundary is a no-op. - for range 10 { - in.Left() - } - - in.Left() - - for range 20 { - in.Right() - } - - in.Insert("!") - - if got := in.Value(); got != ">abc 0 { - label = b.Keys[0] - } - - return label + " " + b.Help -} - -// KeyMap is the shell's complete binding set. -// -// There is no protocol-level keybinding registration, so a widget cannot claim -// a key. Widgets expose affordances as ActionNodes and reach the keyboard -// through the action cursor instead. That is a deliberate limitation: it keeps -// this map total and conflict-free, at the cost of widgets not binding -// accelerators of their own. -type KeyMap struct { - // Global. - Interrupt Binding - Quit Binding - NextFocus Binding - PrevFocus Binding - CycleAgent Binding - ToggleSidebar Binding - - // main_chat and sidebar. - Up Binding - Down Binding - PageUp Binding - PageDown Binding - Top Binding - Bottom Binding - Activate Binding - - // input_bar. - Submit Binding - Newline Binding - HistoryPrev Binding - HistoryNext Binding - - // overlay. - Allow Binding - Deny Binding - AllowSession Binding - Edit Binding - Dismiss Binding -} - -// DefaultKeyMap returns the shell's built-in bindings. -func DefaultKeyMap() KeyMap { - return KeyMap{ - Interrupt: Binding{Keys: []string{"ctrl+c"}, Help: "interrupt"}, - Quit: Binding{Keys: []string{"ctrl+d"}, Help: "quit"}, - NextFocus: Binding{Keys: []string{"tab"}, Help: "focus"}, - // shift+tab belongs to the agent switcher, which is the convention - // operators arrive with. The focus ring is at most three entries, so - // cycling forward reaches everything and a backward binding buys - // nothing worth the key. PrevFocus keeps its field so a future - // configuration can bind it. - PrevFocus: Binding{}, - CycleAgent: Binding{Keys: []string{"shift+tab"}, Help: "agent"}, - ToggleSidebar: Binding{Keys: []string{"ctrl+b"}, Help: "sidebar"}, - - Up: Binding{Keys: []string{"up", "k"}, Label: "↑", Help: "up"}, - Down: Binding{Keys: []string{"down", "j"}, Label: "↓", Help: "down"}, - PageUp: Binding{Keys: []string{"pgup"}, Help: "page up"}, - PageDown: Binding{Keys: []string{"pgdown", "pgdn"}, Label: "pgdn", Help: "page down"}, - Top: Binding{Keys: []string{"home"}, Help: "top"}, - Bottom: Binding{Keys: []string{"end"}, Help: "live"}, - Activate: Binding{Keys: []string{"enter"}, Help: "activate"}, - - Submit: Binding{Keys: []string{"enter"}, Help: "send"}, - // shift+enter is the binding operators expect, but a bare terminal - // cannot distinguish it from enter — both are CR. Bubble Tea requests - // key disambiguation (Kitty keyboard / modifyOtherKeys) at startup, - // which makes it available on terminals that support the negotiation. - // alt+enter and ctrl+j are kept as fallbacks for those that do not: - // ctrl+j is literally line feed and works everywhere. - Newline: Binding{ - Keys: []string{"shift+enter", "alt+enter", "ctrl+j"}, - Label: "shift+enter", - Help: "newline", - }, - HistoryPrev: Binding{Keys: []string{"up"}, Label: "↑", Help: "history"}, - HistoryNext: Binding{Keys: []string{"down"}, Label: "↓", Help: "history"}, - - Allow: Binding{Keys: []string{"y"}, Help: "allow"}, - Deny: Binding{Keys: []string{"n"}, Help: "deny"}, - AllowSession: Binding{Keys: []string{"a"}, Help: "allow session"}, - Edit: Binding{Keys: []string{"e"}, Help: "edit args"}, - Dismiss: Binding{Keys: []string{"esc"}, Help: "dismiss"}, - } -} - -// Hints returns the bindings the hotkey hint line should advertise for the -// currently active layer, dropping whole bindings from the end until they fit -// in width. This is what makes hotkey_hints meaningful rather than a static -// legend: it always describes the keyboard as it is right now. -// -// Whole bindings go rather than the string being cut, because a hint truncated -// mid-word ("ctrl+c inter") is worse than an absent one — it looks like a -// rendering fault and tells the operator nothing. -func (k KeyMap) Hints(layer Layer, focus Focus, sidebarAvailable bool, width int) string { - var bindings []Binding - - switch layer { - case LayerOverlay: - bindings = []Binding{k.Allow, k.Deny, k.AllowSession, k.Edit, k.Dismiss} - case LayerRegion, LayerGlobal: - bindings = k.regionHints(focus, sidebarAvailable) - } - - parts := make([]string, 0, len(bindings)) - for _, b := range bindings { - parts = append(parts, b.hint()) - } - - for len(parts) > 1 && lipgloss.Width(strings.Join(parts, hintSeparator)) > width { - parts = parts[:len(parts)-1] - } - - return strings.Join(parts, hintSeparator) -} - -// hintSeparator divides adjacent key hints. -const hintSeparator = " · " - -func (k KeyMap) regionHints(focus Focus, sidebarAvailable bool) []Binding { - var bindings []Binding - - switch focus { - case FocusInput: - bindings = []Binding{k.Submit, k.Newline, k.CycleAgent, k.NextFocus} - case FocusMain, FocusSidebar: - bindings = []Binding{k.Up, k.Down, k.Activate, k.Bottom, k.CycleAgent, k.NextFocus} - } - - if sidebarAvailable { - bindings = append(bindings, k.ToggleSidebar) - } - - return append(bindings, k.Interrupt) -} diff --git a/internal/tui/shell/keymap_test.go b/internal/tui/shell/keymap_test.go deleted file mode 100644 index 9e13519..0000000 --- a/internal/tui/shell/keymap_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package shell - -import ( - "strings" - "testing" -) - -func TestBindingMatches(t *testing.T) { - t.Parallel() - - b := Binding{Keys: []string{"pgdown", "pgdn"}} - - for _, k := range []string{"pgdown", "pgdn"} { - if !b.Matches(k) { - t.Errorf("Matches(%q) = false, want true", k) - } - } - - if b.Matches("pgup") { - t.Error("Matches(pgup) = true, want false") - } - - if (Binding{}).Matches("anything") { - t.Error("an empty binding matched a key") - } -} - -func TestBindingHintUsesLabelThenFirstKey(t *testing.T) { - t.Parallel() - - labeled := Binding{Keys: []string{"down"}, Label: "↓", Help: "scroll"} - if got, want := labeled.hint(), "↓ scroll"; got != want { - t.Errorf("hint = %q, want %q", got, want) - } - - unlabeled := Binding{Keys: []string{"tab"}, Help: "focus"} - if got, want := unlabeled.hint(), "tab focus"; got != want { - t.Errorf("hint = %q, want %q", got, want) - } -} - -// The default map must not bind one key to two things inside a single layer, -// which is what makes the layered precedence total and conflict-free. -func TestDefaultKeyMapHasNoIntraLayerConflicts(t *testing.T) { - t.Parallel() - - k := DefaultKeyMap() - - layers := map[string][]Binding{ - "global": {k.Interrupt, k.Quit, k.NextFocus, k.PrevFocus, k.CycleAgent, k.ToggleSidebar}, - "content": {k.Up, k.Down, k.PageUp, k.PageDown, k.Top, k.Bottom, k.Activate}, - "input": {k.Submit, k.Newline}, - "overlay": {k.Allow, k.Deny, k.AllowSession, k.Edit, k.Dismiss}, - } - - for name, bindings := range layers { - seen := map[string]bool{} - - for _, b := range bindings { - for _, key := range b.Keys { - if seen[key] { - t.Errorf("layer %q binds %q twice", name, key) - } - - seen[key] = true - } - } - } -} - -func TestHintsDescribeTheActiveLayer(t *testing.T) { - t.Parallel() - - k := DefaultKeyMap() - - overlay := k.Hints(LayerOverlay, FocusInput, true, 200) - for _, want := range []string{"allow", "deny", "edit args"} { - if !strings.Contains(overlay, want) { - t.Errorf("overlay hints missing %q: %q", want, overlay) - } - } - - // The overlay layer captures the keyboard, so region bindings must not be - // advertised while it is up. - if strings.Contains(overlay, "send") { - t.Errorf("overlay hints leaked an input binding: %q", overlay) - } - - input := k.Hints(LayerRegion, FocusInput, true, 200) - if !strings.Contains(input, "send") { - t.Errorf("input hints missing send: %q", input) - } - - chat := k.Hints(LayerRegion, FocusMain, true, 200) - if !strings.Contains(chat, "activate") { - t.Errorf("chat hints missing activate: %q", chat) - } - - if strings.Contains(chat, "send") { - t.Errorf("chat hints advertised the input binding: %q", chat) - } -} - -// The sidebar toggle is only advertised where it can actually do something. -// Whole bindings drop from the end rather than the line being cut mid-word. -func TestHintsDropWholeBindingsToFit(t *testing.T) { - t.Parallel() - - k := DefaultKeyMap() - - full := k.Hints(LayerRegion, FocusInput, true, 200) - short := k.Hints(LayerRegion, FocusInput, true, 40) - - if len(short) >= len(full) { - t.Fatalf("narrow hints were not shortened: %q", short) - } - - if strings.HasSuffix(short, " ") || strings.Contains(short, " · \u0000") { - t.Errorf("hints end raggedly: %q", short) - } - - // What survives must be complete bindings, never a fragment. - for _, part := range strings.Split(short, " · ") { - if part == "" { - t.Errorf("empty hint fragment in %q", short) - } - } - - // At least one binding always survives, however little room there is. - if k.Hints(LayerRegion, FocusInput, true, 1) == "" { - t.Error("all hints dropped; at least the first should survive") - } -} - -func TestHintsOmitSidebarToggleWhenUnavailable(t *testing.T) { - t.Parallel() - - k := DefaultKeyMap() - - if got := k.Hints(LayerRegion, FocusInput, false, 200); strings.Contains(got, "sidebar") { - t.Errorf("hints advertised the sidebar toggle on a too-narrow terminal: %q", got) - } - - if got := k.Hints(LayerRegion, FocusInput, true, 200); !strings.Contains(got, "sidebar") { - t.Errorf("hints omitted an available sidebar toggle: %q", got) - } -} diff --git a/internal/tui/shell/layout.go b/internal/tui/shell/layout.go deleted file mode 100644 index 41f4706..0000000 --- a/internal/tui/shell/layout.go +++ /dev/null @@ -1,186 +0,0 @@ -package shell - -import "github.com/pluggableharness/agent/internal/tui/theme" - -// Layout geometry constants. These are the breakpoints documented in -// docs/first-party/frontends/tui.md; changing one here means changing it there. -const ( - sidebarMinWidth = 26 - sidebarMaxWidth = 38 - sidebarBreakpoint = 100 - sidebarFloorWidth = 64 - - // The header and footer are bordered boxes rather than single lines, which - // costs two rows each in chrome. They are dropped outright on a short - // terminal rather than degrading to an unboxed line: one visual language is - // worth more than one extra row of transcript. - chromePanelHeight = 3 - - hintsMinHeight = 16 - topBarMinHeight = 12 - - // statusMinHeight is where the volatile-state line fits beneath the - // composer. It is a single row: static session data lives in the sidebar, - // which has room for it, so the space beside the composer is spent only on - // what actually changes during a turn. - statusMinHeight = 9 - - inputMinHeight = 1 - inputMaxHeight = 6 - - // panelChrome is the rows and columns a panel spends on its own border. - panelChrome = 2 - // panelPadding is the horizontal padding inside a panel, per side. - panelPadding = theme.Space1 -) - -// Layout is the solved geometry for one frame: which regions are on screen and -// the outer box each one occupies. -// -// Every dimension here is an *outer* size, borders included. Interior content -// sizes come from the Inner helpers, so a caller never open-codes the chrome -// arithmetic and panes can never disagree about how wide their content is. -// -// Regions are dropped in a fixed order as space runs out — sidebar, then hotkey -// hints, then top bar. main_chat and input_bar are never dropped: a shell that -// can show neither input nor output is not a shell. -type Layout struct { - Width int - Height int - - ShowTopBar bool - ShowHints bool - ShowSidebar bool - - // SidebarAvailable reports whether the terminal is wide enough for the - // sidebar to be toggled on at all. Below the floor width its content folds - // into main_chat instead, per the protocol's allowance that a frontend may - // reinterpret placement for its own layout. - SidebarAvailable bool - - // Outer panel boxes. - MainWidth int - SidebarWidth int - BodyHeight int - - // InputHeight is the composer's content lines; ComposerHeight is its outer - // box, chrome included. - InputHeight int - ComposerHeight int - - // ShowStatus is whether the volatile-state line fits beneath the composer. - ShowStatus bool -} - -// FoldSidebar reports whether sidebar content should be folded into main_chat. -// True only when the terminal is too narrow for the sidebar to exist as a pane -// at all — a merely-toggled-off sidebar keeps its content, reachable by -// toggling it back on. -func (l Layout) FoldSidebar() bool { return !l.SidebarAvailable } - -// MainInnerWidth is the content width inside the main panel. -func (l Layout) MainInnerWidth() int { return max(l.MainWidth-panelChrome-2*panelPadding, 1) } - -// MainInnerHeight is the content height inside the main panel. -func (l Layout) MainInnerHeight() int { return max(l.BodyHeight-panelChrome, 1) } - -// SidebarInnerWidth is the content width inside a sidebar panel. -func (l Layout) SidebarInnerWidth() int { return max(l.SidebarWidth-panelChrome-2*panelPadding, 1) } - -// StatusInset is the margin either side of the status line. -// -// It is two cells rather than the panels' one because the status line has no -// border of its own: a panel spends a gutter, a border, and a pad before its -// text starts, so an unboxed line needs the same total to sit on the same -// column. Without it the status line starts two columns left of everything -// above and below it and reads as slightly loose. -const StatusInset = theme.Space2 - -// StatusWidth is the width the status line renders into, inset either side so -// its text aligns with the content inside the panels around it. -func (l Layout) StatusWidth() int { return max(l.Width-2*StatusInset, 1) } - -// ComposerTop is the row the composer's top border occupies. -// -// The frame stacks its bands in this order, and the cursor has to be placed -// against the same arithmetic. Deriving it here rather than recomputing it at -// the point of use is what stops the two from drifting apart — they already did -// once, when the header grew from a single line into a bordered box and the -// cursor kept counting it as one row. -func (l Layout) ComposerTop() int { - top := l.BodyHeight - if l.ShowTopBar { - top += chromePanelHeight - } - - return top -} - -// ChromeInnerWidth is the content width inside a header or footer panel. -func (l Layout) ChromeInnerWidth() int { - return max(l.Width-2*theme.Gutter-panelChrome-2*panelPadding, 1) -} - -// ComposerInnerWidth is the content width inside the composer panel. -func (l Layout) ComposerInnerWidth() int { - return max(l.Width-2*theme.Gutter-panelChrome-2*panelPadding, 1) -} - -// Solve computes the layout for a terminal of the given size. -// -// inputLines is the number of lines the composer currently needs, and -// sidebarOpen is the operator's toggle state, which only matters between the -// floor and the breakpoint — above the breakpoint the sidebar is always shown, -// below the floor it is never available. -func Solve(width, height, inputLines int, sidebarOpen bool) Layout { - l := Layout{Width: width, Height: height} - - l.InputHeight = clamp(inputLines, inputMinHeight, inputMaxHeight) - l.ComposerHeight = l.InputHeight + panelChrome - l.ShowHints = height >= hintsMinHeight - l.ShowTopBar = height >= topBarMinHeight - - l.SidebarAvailable = width >= sidebarFloorWidth - switch { - case width >= sidebarBreakpoint: - l.ShowSidebar = true - case l.SidebarAvailable: - l.ShowSidebar = sidebarOpen - default: - l.ShowSidebar = false - } - - // Horizontal: a gutter at each screen edge, and a gap between the main - // panel and the sidebar column when both are present. - bodyWidth := max(width-2*theme.Gutter, 1) - - if l.ShowSidebar { - l.SidebarWidth = min(clamp(width*3/10, sidebarMinWidth, sidebarMaxWidth), width*2/5) - l.MainWidth = max(bodyWidth-l.SidebarWidth-theme.Space1, 1) - } else { - l.MainWidth = bodyWidth - } - - l.ShowStatus = height >= statusMinHeight - - used := l.ComposerHeight - if l.ShowStatus { - used++ - } - - // The header and footer are boxes, not lines: each costs its border rows - // as well as its content row. - if l.ShowTopBar { - used += chromePanelHeight - } - - if l.ShowHints { - used += chromePanelHeight - } - - l.BodyHeight = max(height-used, 1) - - return l -} - -func clamp(v, lo, hi int) int { return min(max(v, lo), hi) } diff --git a/internal/tui/shell/layout_test.go b/internal/tui/shell/layout_test.go deleted file mode 100644 index 9343f64..0000000 --- a/internal/tui/shell/layout_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package shell - -import ( - "testing" - - "github.com/pluggableharness/agent/internal/tui/theme" -) - -func TestSolveWideTerminalShowsEveryRegion(t *testing.T) { - t.Parallel() - - l := Solve(120, 40, 1, false) - - if !l.ShowTopBar || !l.ShowHints || !l.ShowSidebar { - t.Fatalf("wide terminal dropped a region: %+v", l) - } - - if l.SidebarWidth < sidebarMinWidth || l.SidebarWidth > sidebarMaxWidth { - t.Errorf("sidebar width %d outside [%d,%d]", l.SidebarWidth, sidebarMinWidth, sidebarMaxWidth) - } - - // Panels plus both gutters plus the gap between them fill the terminal - // exactly; a mismatch would leave an uncovered column. - if got := l.MainWidth + l.SidebarWidth + theme.Space1 + 2*theme.Gutter; got != l.Width { - t.Errorf("widths do not sum to the terminal: %d != %d", got, l.Width) - } - - // The header and footer boxes, the status line, and the composer's outer - // box all take rows out of the body. - chrome := 2*chromePanelHeight + 1 + l.ComposerHeight - - if want := 40 - chrome; l.BodyHeight != want { - t.Errorf("BodyHeight = %d, want %d", l.BodyHeight, want) - } -} - -// Interior sizes must account for border and padding, so no caller open-codes -// the chrome arithmetic. -func TestInnerSizesSubtractChrome(t *testing.T) { - t.Parallel() - - l := Solve(120, 40, 1, false) - - if got, want := l.MainInnerWidth(), l.MainWidth-panelChrome-2*panelPadding; got != want { - t.Errorf("MainInnerWidth() = %d, want %d", got, want) - } - - if got, want := l.MainInnerHeight(), l.BodyHeight-panelChrome; got != want { - t.Errorf("MainInnerHeight() = %d, want %d", got, want) - } - - if l.ComposerHeight != l.InputHeight+panelChrome { - t.Errorf("ComposerHeight = %d, want InputHeight+%d", l.ComposerHeight, panelChrome) - } -} - -// Above the breakpoint the sidebar is always shown; between the floor and the -// breakpoint it follows the operator's toggle; below the floor it is gone. -func TestSidebarBreakpoints(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - width int - open bool - wantShown bool - wantAvailable bool - }{ - {"wide ignores toggle off", 120, false, true, true}, - {"wide ignores toggle on", 120, true, true, true}, - {"medium closed", 80, false, false, true}, - {"medium opened", 80, true, true, true}, - {"at breakpoint", sidebarBreakpoint, false, true, true}, - {"just below breakpoint", sidebarBreakpoint - 1, false, false, true}, - {"at floor", sidebarFloorWidth, true, true, true}, - {"below floor cannot open", sidebarFloorWidth - 1, true, false, false}, - {"very narrow", 30, true, false, false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - l := Solve(tc.width, 40, 1, tc.open) - - if l.ShowSidebar != tc.wantShown { - t.Errorf("ShowSidebar = %v, want %v", l.ShowSidebar, tc.wantShown) - } - - if l.SidebarAvailable != tc.wantAvailable { - t.Errorf("SidebarAvailable = %v, want %v", l.SidebarAvailable, tc.wantAvailable) - } - - // Folding is exactly the case where the sidebar cannot exist as a - // pane, not merely the case where it is toggled off. - if got, want := l.FoldSidebar(), !tc.wantAvailable; got != want { - t.Errorf("FoldSidebar() = %v, want %v", got, want) - } - }) - } -} - -func TestSidebarNeverExceedsFortyPercent(t *testing.T) { - t.Parallel() - - for w := sidebarBreakpoint; w <= 400; w += 7 { - l := Solve(w, 40, 1, true) - if l.SidebarWidth > w*2/5 { - t.Fatalf("width %d: sidebar %d exceeds 40%% (%d)", w, l.SidebarWidth, w*2/5) - } - } -} - -// Regions are dropped in a fixed order as height runs out: hints first, then -// the top bar. main_chat and input_bar are never dropped. -func TestHeightDegradationOrder(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - height int - wantTopBar bool - wantHints bool - }{ - {"roomy", 40, true, true}, - {"at hints minimum", hintsMinHeight, true, true}, - {"below hints minimum", hintsMinHeight - 1, true, false}, - {"at top bar minimum", topBarMinHeight, true, false}, - {"below top bar minimum", topBarMinHeight - 1, false, false}, - {"pathological", 1, false, false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - l := Solve(120, tc.height, 1, false) - - if l.ShowTopBar != tc.wantTopBar { - t.Errorf("ShowTopBar = %v, want %v", l.ShowTopBar, tc.wantTopBar) - } - - if l.ShowHints != tc.wantHints { - t.Errorf("ShowHints = %v, want %v", l.ShowHints, tc.wantHints) - } - - // The two load-bearing regions always survive. - if l.BodyHeight < 1 { - t.Errorf("BodyHeight = %d, want at least 1", l.BodyHeight) - } - - if l.InputHeight < inputMinHeight { - t.Errorf("InputHeight = %d, want at least %d", l.InputHeight, inputMinHeight) - } - }) - } -} - -// The status line is a single row of volatile state and goes when height runs -// short — static session data lives in the sidebar, so nothing is lost with it. -func TestStatusLineDegradesByHeight(t *testing.T) { - t.Parallel() - - if !Solve(120, 40, 1, false).ShowStatus { - t.Error("no status line on a tall terminal") - } - - if !Solve(120, statusMinHeight, 1, false).ShowStatus { - t.Error("status line missing at its threshold height") - } - - if Solve(120, statusMinHeight-1, 1, false).ShowStatus { - t.Error("status line survived below its threshold") - } -} - -// However many rows the chrome takes, the body never collapses. -func TestBodySurvivesEveryHeight(t *testing.T) { - t.Parallel() - - for h := 1; h <= 60; h++ { - l := Solve(120, h, 1, false) - if l.BodyHeight < 1 { - t.Fatalf("height %d gave BodyHeight %d", h, l.BodyHeight) - } - } -} - -func TestInputHeightIsClamped(t *testing.T) { - t.Parallel() - - tests := []struct { - lines int - want int - }{ - {0, inputMinHeight}, - {1, 1}, - {3, 3}, - {inputMaxHeight, inputMaxHeight}, - {inputMaxHeight + 10, inputMaxHeight}, - } - - for _, tc := range tests { - if got := Solve(120, 40, tc.lines, false).InputHeight; got != tc.want { - t.Errorf("Solve(lines=%d).InputHeight = %d, want %d", tc.lines, got, tc.want) - } - } -} - -func TestMainWidthNeverCollapsesBelowOne(t *testing.T) { - t.Parallel() - - for _, w := range []int{0, 1, 5, 30} { - if got := Solve(w, 24, 1, true).MainWidth; got < 1 { - t.Errorf("width %d gave MainWidth %d", w, got) - } - } -} diff --git a/internal/tui/shell/model.go b/internal/tui/shell/model.go deleted file mode 100644 index 77aab8c..0000000 --- a/internal/tui/shell/model.go +++ /dev/null @@ -1,1275 +0,0 @@ -package shell - -import ( - "image/color" - "strconv" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/pluggableharness/agent/internal/tui/paint" - "github.com/pluggableharness/agent/internal/tui/region" - "github.com/pluggableharness/agent/internal/tui/theme" - "github.com/pluggableharness/agent/internal/tui/ui" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -// overlay is the modal prompt currently capturing the keyboard. -type overlay struct { - title string - itemID string - preview *renderv1.RenderTree - rawInput string - // restore is the focus to return to once the overlay clears. - restore Focus -} - -// Model is the shell's Bubble Tea model: layout, focus, keymap, and the -// composition of every region into one frame. -// -// The model is driven entirely by messages and never performs I/O, which is -// what lets it be tested through tea.WithoutRenderer or by calling Update -// directly with no terminal at all. -type Model struct { - th theme.Theme - painter *paint.Painter - keys KeyMap - store *region.Store - emit func(Action) - - width int - height int - layout Layout - - focus Focus - cursor int - expanded map[string]bool - input *input - scroll int - pinned bool - // maxScroll is the deepest scroll offset the last painted frame allowed. - // It is what lets scrolling back down to the bottom re-attach to the live - // tail instead of leaving the transcript one notch short of it forever. - maxScroll int - sidebarOpen bool - - agents agentRing - - overlay *overlay - notice *NoticeMsg - status StatusMsg - // usage is nil until the first UsageMsg arrives. Nil means "not known - // yet", which must render as absent rather than as zero — a gauge reading - // 0% before any turn has run is a confident lie. - usage *UsageMsg - workspace WorkspaceMsg - edits EditStatsMsg - - // interruptArmed tracks the first ctrl+c of the two-press quit sequence. - interruptArmed bool - quitting bool -} - -// Option configures a Model. -type Option func(*Model) - -// WithTheme sets the theme. The default is theme.Dark. -func WithTheme(t theme.Theme) Option { - return func(m *Model) { - m.th = t - m.painter = paint.New(t) - } -} - -// WithAgents replaces the selectable agent roster. An empty roster keeps the -// defaults, since the shell always needs something to display as active. -func WithAgents(agents []Agent) Option { - return func(m *Model) { m.agents = newAgentRing(agents) } -} - -// WithKeyMap replaces the default bindings. -func WithKeyMap(k KeyMap) Option { return func(m *Model) { m.keys = k } } - -// WithEmitter sets the sink for operator-originated actions. Without one the -// shell still runs and paints; it simply has nowhere to send, which is the -// right behavior for a rendering-only test. -func WithEmitter(f func(Action)) Option { return func(m *Model) { m.emit = f } } - -// New returns a Model ready to receive messages. -func New(opts ...Option) *Model { - m := &Model{ - th: theme.Dark(), - keys: DefaultKeyMap(), - store: region.NewStore(), - expanded: map[string]bool{}, - input: newInput(), - agents: newAgentRing(nil), - focus: FocusInput, - pinned: true, - width: 80, - height: 24, - } - m.painter = paint.New(m.th) - - for _, o := range opts { - o(m) - } - - m.relayout() - - return m -} - -// Init implements tea.Model. The event source runs outside the program, so -// there is no startup command. -func (m *Model) Init() tea.Cmd { return nil } - -// Store exposes the content store so a bridge can inspect what is placed. -func (m *Model) Store() *region.Store { return m.store } - -// Focus reports which region currently owns the keyboard. -func (m *Model) Focus() Focus { return m.focus } - -// Layout reports the geometry of the most recent frame. -func (m *Model) Layout() Layout { return m.layout } - -func (m *Model) relayout() { - m.layout = Solve(m.width, m.height, m.input.Lines(), m.sidebarOpen) -} - -func (m *Model) send(a Action) { - if m.emit != nil { - m.emit(a) - } -} - -// Update implements tea.Model. -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - m.relayout() - case tea.KeyPressMsg: - return m, m.handleKey(msg.String()) - case tea.MouseWheelMsg: - m.handleWheel(msg) - case PlaceMsg: - m.store.Place(msg.Content, msg.Producer, msg.Sequence) - m.store.ClearProducerStreams() - m.relayout() - case DeltaMsg: - m.store.Delta(msg.TargetID, msg.Text) - case SettledMsg: - m.store.ClearStream(msg.TargetID) - case PermissionMsg: - m.openOverlay(msg) - case DismissOverlayMsg: - m.closeOverlay() - m.notice = &NoticeMsg{Text: msg.Reason, Level: NoticeWarn} - case NoticeMsg: - m.notice = &msg - case StatusMsg: - m.status = msg - case UsageMsg: - m.usage = &msg - case WorkspaceMsg: - m.workspace = msg - case EditStatsMsg: - m.edits = msg - } - - return m, nil -} - -// wheelStep is how many transcript lines one wheel notch moves. -const wheelStep = 3 - -// handleWheel scrolls the transcript. -// -// Claiming the wheel is what stops the terminal from scrolling its own -// scrollback out from under a full-screen application — without it the gesture -// appears to work while actually moving the window behind the app. It applies -// regardless of which region holds the keyboard, because pointing at something -// and turning the wheel is not a focus operation. -func (m *Model) handleWheel(msg tea.MouseWheelMsg) { - switch msg.Button { - case tea.MouseWheelUp: - m.scrollBy(-wheelStep) - case tea.MouseWheelDown: - m.scrollBy(wheelStep) - default: - // Horizontal wheel events have nothing to act on. - } -} - -func (m *Model) openOverlay(msg PermissionMsg) { - m.overlay = &overlay{ - title: msg.Title, - itemID: msg.ItemID, - preview: msg.Preview, - rawInput: msg.RawInput, - restore: m.focus, - } -} - -func (m *Model) closeOverlay() { - if m.overlay == nil { - return - } - - m.focus = m.overlay.restore - m.overlay = nil -} - -// activeLayer reports which keymap layer currently has precedence. -func (m *Model) activeLayer() Layer { - if m.overlay != nil { - return LayerOverlay - } - - return LayerRegion -} - -// handleKey routes a keypress through the layer stack: overlay, then the -// focused region, then global. A layer that handles the key stops propagation. -func (m *Model) handleKey(key string) tea.Cmd { - // Any keypress other than the second ctrl+c disarms the quit sequence, so - // an interrupt followed by ordinary typing never quits unexpectedly. - armed := m.interruptArmed - m.interruptArmed = false - - // A notice is transient: it occupies the composer's title, so the next - // keystroke clears it and the title returns to naming the active agent. - // The durable record of anything important is the transcript, not this. - m.notice = nil - - if m.overlay != nil { - return m.overlayKey(key) - } - - if m.regionKey(key) { - return nil - } - - return m.globalKey(key, armed) -} - -func (m *Model) globalKey(key string, armed bool) tea.Cmd { - switch { - case m.keys.Interrupt.Matches(key): - if armed { - m.quitting = true - - return tea.Quit - } - - m.interruptArmed = true - m.send(Interrupt{}) - m.notice = &NoticeMsg{Text: "interrupted — press ctrl+c again to quit", Level: NoticeWarn} - case m.keys.Quit.Matches(key) && m.input.Empty(): - m.quitting = true - - return tea.Quit - case m.keys.NextFocus.Matches(key): - m.moveFocus(false) - case m.keys.PrevFocus.Matches(key): - m.moveFocus(true) - case m.keys.CycleAgent.Matches(key): - m.cycleAgent(false) - case m.keys.ToggleSidebar.Matches(key): - m.sidebarOpen = !m.sidebarOpen - m.relayout() - } - - return nil -} - -// Agent reports the currently selected agent. -func (m *Model) Agent() Agent { return m.agents.Current() } - -// cycleAgent advances the agent selection and announces it. -// -// The selection is local state: no ClientEvent carries an agent profile today. -// AgentSelected is emitted so a bridge can decide what the change means once -// the protocol grows an answer. -func (m *Model) cycleAgent(back bool) { - next := m.agents.cycle(back) - m.send(AgentSelected{Name: next.Name}) - m.notice = &NoticeMsg{Text: next.Name + " — " + next.Description, Level: NoticeInfo} -} - -func (m *Model) moveFocus(back bool) { - ring := focusRing(m.layout, m.hasContent(renderv1.Region_REGION_SIDEBAR)) - m.focus = cycleFocus(m.focus, ring, back) - m.cursor = 0 -} - -// regionKey dispatches to the focused region's bindings, reporting whether the -// key was consumed. -func (m *Model) regionKey(key string) bool { - switch m.focus { - case FocusInput: - return m.inputKey(key) - case FocusMain, FocusSidebar: - return m.contentKey(key) - default: - return false - } -} - -func (m *Model) inputKey(key string) bool { - switch { - case m.keys.Newline.Matches(key): - m.input.Insert("\n") - case m.keys.Submit.Matches(key): - if text, ok := m.input.Submit(); ok { - m.send(SubmitPrompt{Text: text}) - m.pinned = true - } - case m.keys.HistoryPrev.Matches(key) && m.input.OnFirstLine(): - m.input.HistoryPrev() - case m.keys.HistoryNext.Matches(key) && m.input.OnLastLine(): - m.input.HistoryNext() - case key == "backspace": - m.input.Backspace() - case key == "left": - m.input.Left() - case key == "right": - m.input.Right() - case key == "home": - m.input.Home() - case key == "end": - m.input.End() - case key == "space": - // Bubble Tea reports space by name rather than as its literal - // character, so it never reaches the printable check below. - m.input.Insert(" ") - case isPrintable(key): - m.input.Insert(key) - default: - return false - } - - m.relayout() - - return true -} - -func (m *Model) contentKey(key string) bool { - targets := m.targets(m.focusedRegion()) - - switch { - case m.keys.Up.Matches(key): - m.moveCursor(-1, len(targets)) - case m.keys.Down.Matches(key): - m.moveCursor(1, len(targets)) - case m.keys.PageUp.Matches(key): - m.scrollBy(-m.layout.MainInnerHeight()) - case m.keys.PageDown.Matches(key): - m.scrollBy(m.layout.MainInnerHeight()) - case m.keys.Top.Matches(key): - m.scroll, m.pinned = 0, false - case m.keys.Bottom.Matches(key): - m.pinned = true - case m.keys.Activate.Matches(key): - m.activate(targets) - default: - return false - } - - return true -} - -func (m *Model) moveCursor(delta, n int) { - if n == 0 { - m.scrollBy(delta) - - return - } - - m.cursor = clamp(m.cursor+delta, 0, n-1) -} - -func (m *Model) scrollBy(delta int) { - m.scroll = clamp(m.scroll+delta, 0, m.maxScroll) - // Reaching the bottom re-attaches to the live tail, which is what makes - // scrolling down feel like catching up rather than like getting stuck one - // line short of the newest content. - m.pinned = m.scroll >= m.maxScroll -} - -// activate fires the focused target: an action node dispatches unchanged to -// the kernel, a collapsible toggles locally and sends nothing. -func (m *Model) activate(targets []paint.Target) { - if m.cursor < 0 || m.cursor >= len(targets) { - return - } - - t := targets[m.cursor] - switch t.Kind { - case paint.TargetCollapsible: - m.expanded[t.Path] = !m.isExpanded(t.Path) - case paint.TargetAction: - m.send(Trigger{ - NodeID: t.Action.GetId(), - ToolName: t.Action.GetToolName(), - Provider: t.Action.GetProvider(), - Args: t.Action.GetArgs(), - }) - } -} - -func (m *Model) isExpanded(path string) bool { - if v, ok := m.expanded[path]; ok { - return v - } - - return false -} - -func (m *Model) overlayKey(key string) tea.Cmd { - switch { - case m.keys.Allow.Matches(key): - m.resolve(true, ScopeOnce) - case m.keys.Deny.Matches(key): - m.resolve(false, ScopeOnce) - case m.keys.AllowSession.Matches(key): - m.resolve(true, ScopeSession) - case m.keys.Dismiss.Matches(key): - // Dismiss never resolves a pending decision — it would be indefensible - // to turn "go away" into an allow or a deny on the operator's behalf. - m.notice = &NoticeMsg{Text: "decision still pending", Level: NoticeWarn} - } - - return nil -} - -func (m *Model) resolve(allow bool, scope DecisionScope) { - if m.overlay == nil { - return - } - - m.send(Decision{ItemID: m.overlay.itemID, Allow: allow, Scope: scope}) - m.closeOverlay() -} - -func (m *Model) focusedRegion() renderv1.Region { - if m.focus == FocusSidebar { - return renderv1.Region_REGION_SIDEBAR - } - - return renderv1.Region_REGION_MAIN_CHAT -} - -func (m *Model) hasContent(r renderv1.Region) bool { - return len(m.store.Contents(r)) > 0 -} - -// targets enumerates the focusable elements of a region, giving each -// placement a distinct root path so paths stay unique across producers. -func (m *Model) targets(r renderv1.Region) []paint.Target { - var out []paint.Target - - for i, pl := range m.store.Contents(r) { - out = append(out, paint.TargetsAt(pl.Tree, m.expanded, strconv.Itoa(i))...) - } - - return out -} - -func isPrintable(key string) bool { - if len([]rune(key)) != 1 { - return false - } - - r := []rune(key)[0] - - return r >= 0x20 && r != 0x7f -} - -// focusedActionID returns the ActionNode ID under the cursor in a region, or -// empty when the cursor is elsewhere or on a non-action target. -func (m *Model) focusedActionID(r renderv1.Region, focused bool) string { - if !focused { - return "" - } - - targets := m.targets(r) - if m.cursor < 0 || m.cursor >= len(targets) || targets[m.cursor].Kind != paint.TargetAction { - return "" - } - - return targets[m.cursor].Action.GetId() -} - -// focusedPath returns the node path under the cursor in a region. -func (m *Model) focusedPath(r renderv1.Region) string { - targets := m.targets(r) - if m.cursor < 0 || m.cursor >= len(targets) { - return "" - } - - return targets[m.cursor].Path -} - -// paintRegion renders every placement in a region, joined vertically. -func (m *Model) paintRegion(r renderv1.Region, width int, focused bool) string { - placements := m.store.Contents(r) - if len(placements) == 0 { - return "" - } - - items := make([]sidebarItem, 0, len(placements)) - for i, pl := range placements { - items = append(items, sidebarItem{index: i, tree: pl.Tree}) - } - - return m.paintItems(items, width, m.focusedActionID(r, focused)) -} - -// View implements tea.Model. -// -// The frame is composed as a stack of full-width bands — top bar, body, -// composer, status bar — each of which paints every cell it claims. Nothing is -// left uncovered, which together with View's own background and foreground -// colors is what makes the shell read as an application that owns the terminal -// rather than as text printed into someone else's window. -func (m *Model) View() tea.View { - v := tea.NewView(m.frame()) - v.AltScreen = true - v.BackgroundColor = m.th.C.Background - v.ForegroundColor = m.th.C.Text - v.WindowTitle = m.windowTitle() - - // Claim the mouse so the wheel scrolls this transcript rather than the - // terminal's own scrollback behind the alt screen. The tradeoff is that - // drag-selection needs the terminal's override (shift+drag in most). - v.MouseMode = tea.MouseModeCellMotion - - // The real terminal cursor is placed in the composer rather than drawing a - // caret glyph, so it blinks and behaves the way every other terminal - // application's cursor does. It is hidden whenever the composer does not - // own the keyboard. - if m.focus == FocusInput && m.overlay == nil && !m.quitting { - if x, y, ok := m.cursorScreenPos(); ok { - c := tea.NewCursor(x, y) - c.Color = m.th.C.Primary - c.Blink = true - v.Cursor = c - } - } - - return v -} - -func (m *Model) windowTitle() string { - if m.status.Session == "" { - return "pluggableharness" - } - - return "pluggableharness — " + m.status.Session -} - -func (m *Model) frame() string { - if m.quitting { - return "" - } - - l := m.layout - rows := make([]string, 0, l.Height) - - if l.ShowTopBar { - rows = append(rows, m.viewTopBar(l)...) - } - - rows = append(rows, m.viewBody(l)...) - rows = append(rows, m.viewComposer(l)...) - - if l.ShowStatus { - rows = append(rows, m.statusLine(l)) - } - - if l.ShowHints { - rows = append(rows, m.viewHints(l)...) - } - - frame := strings.Join(rows, "\n") - if m.overlay != nil { - frame = m.viewOverlay(l, frame) - } - - return frame -} - -// app is the utility style for cells that belong to the application surface -// rather than to any panel — gutters, gaps, and the space around panels. -func (m *Model) app() ui.Style { - return ui.New().Fg(m.th.C.TextSubtle) -} - -// viewTopBar names what the session is working on. -// -// It is a bordered panel rather than a tinted line. A background fill is the -// obvious way to mark chrome and it does not carry: at these contrast levels a -// tinted row reads as a slightly-off content row, not as a frame. A box reads -// as a box. Its title carries the product name, which is both branding and the -// cheapest possible way to make the border feel deliberate. -// -// The agent is deliberately absent: it belongs beside the composer with the -// other settings that change when it does. What sits here is the answer to -// "where am I" — stable for the whole session, and the first thing an operator -// checks when returning to a window. -func (m *Model) viewTopBar(l Layout) []string { - w := m.workspace - - left := []ui.Segment{ - {Value: ui.New().Fg(m.th.C.Text).Bold().Render(m.workspaceDir(l))}, - {Value: w.Repository, Tone: m.th.C.TextMuted}, - } - - // Session and run state read as one unit, so they share a segment rather - // than being separated by a divider that implies they are different fields. - session := ui.New().Fg(m.th.C.TextSubtle).Render(m.status.Session) - if m.status.Status != "" { - if session != "" { - session += " " - } - - session += ui.Badge(m.th, m.status.Status, m.th.C.Success) - } - - return m.chromePanel(l, "pluggableharness", m.th.C.Primary, ui.StatusLine{ - Segments: left, - Right: []ui.Segment{{Value: session}}, - Width: l.ChromeInnerWidth(), - Flush: true, - }.Render(m.th)) -} - -// chromePanel wraps a single line of chrome in the same bordered box the rest -// of the interface uses, so the header and footer belong to the same visual -// language as the panels between them. -func (m *Model) chromePanel(l Layout, title string, accent color.Color, body string) []string { - panel := ui.Panel{ - Title: title, - Body: body, - Width: l.Width - 2*theme.Gutter, - Height: chromePanelHeight, - Accent: accent, - }.Render(m.th) - - gutter := m.app().Render(strings.Repeat(" ", theme.Gutter)) - - out := make([]string, 0, chromePanelHeight) - for r := range strings.SplitSeq(panel, "\n") { - out = append(out, gutter+r+gutter) - } - - return out -} - -// workspaceDir clips the directory from the left, so a long path keeps the tail -// that identifies it rather than the prefix every path shares. -func (m *Model) workspaceDir(l Layout) string { - return ui.ClipLeft(m.workspace.Directory, max(l.Width/3, 12)) -} - -// viewBody composes the main panel beside the sidebar column, returning one -// string per screen row so the caller can stack bands without measuring. -func (m *Model) viewBody(l Layout) []string { - main := ui.Panel{ - Title: "conversation", - Body: m.mainBody(l), - Width: l.MainWidth, - Height: l.BodyHeight, - Focused: m.focus == FocusMain, - Accent: m.panelAccent(m.focus == FocusMain), - }.Render(m.th) - - mainRows := strings.Split(main, "\n") - gutter := m.app().Render(strings.Repeat(" ", theme.Gutter)) - - if !l.ShowSidebar { - out := make([]string, 0, len(mainRows)) - for _, r := range mainRows { - out = append(out, gutter+r+gutter) - } - - return out - } - - sideRows := m.sidebarColumn(l) - gap := m.app().Render(strings.Repeat(" ", theme.Space1)) - - out := make([]string, 0, l.BodyHeight) - - for i := range l.BodyHeight { - mainRow, sideRow := "", "" - if i < len(mainRows) { - mainRow = mainRows[i] - } - - if i < len(sideRows) { - sideRow = sideRows[i] - } - - out = append(out, gutter+mainRow+gap+ui.Fit(sideRow, l.SidebarWidth)+gutter) - } - - return out -} - -// agentColor is the active agent's tone resolved against the current theme. -func (m *Model) agentColor() color.Color { return m.th.Tone(m.agents.Current().Tone) } - -func (m *Model) panelAccent(focused bool) color.Color { - if focused { - return m.th.C.Primary - } - - return m.th.C.TextSubtle -} - -// mainBody assembles the transcript: placed content, folded sidebar content -// when the terminal is too narrow for a sidebar, and any live streaming text. -func (m *Model) mainBody(l Layout) string { - width := l.MainInnerWidth() - parts := []string{m.paintRegion(renderv1.Region_REGION_MAIN_CHAT, width, m.focus == FocusMain)} - - if l.FoldSidebar() { - parts = append(parts, m.paintRegion(renderv1.Region_REGION_SIDEBAR, width, false)) - } - - for _, s := range m.store.Streams() { - parts = append(parts, m.th.Default.Width(width).Render(s.Text)) - } - - return m.window(strings.Join(nonEmpty(parts), "\n"), l.MainInnerHeight()) -} - -// sidebarColumn renders one panel per contributing producer, stacked. -// -// Giving each producer its own titled panel — rather than concatenating every -// widget into one undifferentiated column — is what makes it obvious which -// plugin contributed what, and it is the visual affordance widget authors -// design against. -func (m *Model) sidebarColumn(l Layout) []string { - const minPanelHeight = 3 - - groups := m.sidebarGroups() - focused := m.focus == FocusSidebar - activeRoot := rootOf(m.focusedPath(renderv1.Region_REGION_SIDEBAR)) - action := m.focusedActionID(renderv1.Region_REGION_SIDEBAR, focused) - - rows := make([]string, 0, l.BodyHeight) - - for _, p := range m.sessionPanels(l) { - if l.BodyHeight-len(rows) < minPanelHeight { - break - } - - p.Width = l.SidebarWidth - p.Height = min(lipgloss.Height(p.Body)+panelChrome, l.BodyHeight-len(rows)) - p.Accent = m.th.C.TextSubtle - - rows = append(rows, strings.Split(p.Render(m.th), "\n")...) - } - - for _, g := range groups { - remaining := l.BodyHeight - len(rows) - if remaining < minPanelHeight { - break - } - - body := m.paintItems(g.items, l.SidebarInnerWidth(), action) - hot := focused && g.holds(activeRoot) - - panel := ui.Panel{ - Title: g.title, - Body: body, - Width: l.SidebarWidth, - Height: min(lipgloss.Height(body)+panelChrome, remaining), - Focused: hot, - Accent: m.panelAccent(hot), - }.Render(m.th) - - rows = append(rows, strings.Split(panel, "\n")...) - } - - // Pad the column so it covers the full body height; an uncovered cell is - // where the terminal's own background shows through. - blank := m.app().Render(strings.Repeat(" ", l.SidebarWidth)) - for len(rows) < l.BodyHeight { - rows = append(rows, blank) - } - - return rows[:l.BodyHeight] -} - -// sidebarItem is one placement plus its index within the region's ordered -// contents. The index is the node path root, so paths stay aligned with what -// targets() enumerates even though panels group placements by producer. -type sidebarItem struct { - index int - tree *renderv1.RenderTree -} - -type sidebarGroup struct { - title string - items []sidebarItem -} - -// holds reports whether this group owns the given path root. -func (g sidebarGroup) holds(root string) bool { - for _, it := range g.items { - if strconv.Itoa(it.index) == root { - return true - } - } - - return false -} - -// sidebarGroups buckets sidebar placements by producer, preserving the store's -// priority ordering and each producer's first appearance within it. -func (m *Model) sidebarGroups() []sidebarGroup { - var groups []sidebarGroup - - at := map[region.Producer]int{} - - for i, pl := range m.store.Contents(renderv1.Region_REGION_SIDEBAR) { - g, ok := at[pl.Producer] - if !ok { - groups = append(groups, sidebarGroup{title: pl.Producer.Name}) - g = len(groups) - 1 - at[pl.Producer] = g - } - - groups[g].items = append(groups[g].items, sidebarItem{index: i, tree: pl.Tree}) - } - - return groups -} - -// paintItems renders a group's placements, each rooted at its own path. -func (m *Model) paintItems(items []sidebarItem, width int, focusedAction string) string { - parts := make([]string, 0, len(items)) - - for _, it := range items { - parts = append(parts, m.painter.TreeAt(it.tree, paint.Opts{ - Width: width, - FocusedAction: focusedAction, - Expanded: m.expanded, - }, strconv.Itoa(it.index))) - } - - return strings.Join(parts, "\n") -} - -// rootOf returns the leading path segment, which identifies the placement a -// target belongs to. -func rootOf(path string) string { - if i := strings.IndexByte(path, '.'); i >= 0 { - return path[:i] - } - - return path -} - -// window clips content to a visible height, pinning to the live tail unless the -// operator has scrolled away from it. -// -// Content shorter than the viewport is pushed to the *bottom* rather than left -// at the top. A transcript grows upward from the composer the way every chat -// interface does: the newest message belongs next to where the operator is -// typing, and the empty space belongs above it, out of the way. Top-anchoring -// instead strands the last message a screen away from the input. -func (m *Model) window(content string, height int) string { - lines := strings.Split(content, "\n") - - if pad := height - len(lines); pad > 0 { - lines = append(make([]string, pad), lines...) - } - - m.maxScroll = max(len(lines)-height, 0) - - // While pinned, the offset tracks the tail rather than sitting at zero, so - // the first scroll away from the live edge starts from where the operator - // is actually looking instead of jumping to the top of the transcript. - if m.pinned { - m.scroll = m.maxScroll - } - - top := clamp(m.scroll, 0, m.maxScroll) - end := min(top+height, len(lines)) - - return strings.Join(lines[top:end], "\n") -} - -func (m *Model) viewComposer(l Layout) []string { - prompt := ui.New().Fg(m.agentColor()).Bold().Render("› ") - - placeholder := "" - if m.focus == FocusInput { - placeholder = ui.New().Fg(m.th.C.TextSubtle). - Render("ask anything, or / for commands") - } - - body := m.input.render(placeholder) - lines := strings.Split(body, "\n") - - for i := range lines { - if i == 0 { - lines[i] = prompt + lines[i] - - continue - } - - lines[i] = " " + lines[i] - } - - // The agent takes the near corner and the model takes the far one. - // - // They answer different questions — the agent is *who you are talking to* - // and changes on a keystroke, the model is *what is behind it* and changes - // rarely — but run together in one title they became a four-part string in - // which neither was findable, and the agent's color bled onto settings it - // does not own. Split across the diagonal, the identity sits where the eye - // already goes for a panel's name and the configuration sits out of the way - // until looked for. - title := m.agents.Current().Name - if m.notice != nil { - title = m.notice.Text - } - - panel := ui.Panel{ - Title: title, - Caption: m.modelCaption(), - Body: strings.Join(lines, "\n"), - Width: l.Width - 2*theme.Gutter, - Height: l.ComposerHeight, - Focused: m.focus == FocusInput, - Accent: m.noticeAccent(), - }.Render(m.th) - - gutter := m.app().Render(strings.Repeat(" ", theme.Gutter)) - out := make([]string, 0, l.ComposerHeight) - - for _, r := range strings.Split(panel, "\n") { - out = append(out, gutter+r+gutter) - } - - return out -} - -// modelCaption is the model configuration that rides in the composer's -// bottom-right corner: which model, and how it is set to reason. -// -// Thinking and effort are joined with a slash rather than given separators of -// their own. They are one setting read two ways — "extended thinking at high -// effort" — and promoting each to a peer of the model name made three equal -// fields out of a name and its two modifiers. -// -// Absent a model there is no caption at all: a lone "extended/high" names a -// setting without saying what it applies to. -func (m *Model) modelCaption() string { - if m.status.Model == "" { - return "" - } - - reasoning := strings.Join(nonEmpty([]string{m.status.Thinking, m.status.Effort}), "/") - - return strings.Join(nonEmpty([]string{m.status.Model, reasoning}), " · ") -} - -// noticeAccent colors the composer's title by the severity of the most recent -// notice, which is how errors and rejected client events stay visible without -// stealing a row from the transcript. -func (m *Model) noticeAccent() color.Color { - if m.notice == nil { - // With nothing to report, the composer wears the active agent's color, - // which is what keeps the current mode visible without spending a row - // on saying so. - return m.agentColor() - } - - switch m.notice.Level { - case NoticeError: - return m.th.C.Danger - case NoticeWarn: - return m.th.C.Warning - case NoticeInfo: - return m.th.C.Info - default: - return m.th.C.TextMuted - } -} - -func (m *Model) viewHints(l Layout) []string { - inner := l.ChromeInnerWidth() - - // The right-hand label names whatever currently owns the keyboard, which is - // the overlay while one is up rather than the region underneath it. - owner := m.focus.String() - if m.overlay != nil { - owner = "permission" - } - - // Context pressure outranks the focus label. Which region holds the - // keyboard is recoverable by looking at the borders; running out of context - // is not visible anywhere else on this line. - tone := m.th.C.Primary - if warning := m.contextWarning(); warning != "" { - owner, tone = warning, m.th.C.Danger - } - - // The hints are long enough to crowd out the right-hand label, and a line - // sheds its right group first — so without bounding them the warning would - // be the thing that disappears. Keys are a reminder and always recoverable; - // running out of context is news. - room := inner - lipgloss.Width(owner) - lipgloss.Width(ui.SegmentSeparator) - - hints := m.keys.Hints(m.activeLayer(), m.focus, l.SidebarAvailable, max(room, 0)) - if contributed := m.paintRegion(renderv1.Region_REGION_HOTKEY_HINTS, room, false); contributed != "" { - hints = ui.Clip(contributed, max(room, 0)) - } - - return m.chromePanel(l, "keys", m.th.C.TextSubtle, ui.StatusLine{ - Segments: []ui.Segment{{Value: hints, Tone: m.th.C.TextSubtle}}, - Right: []ui.Segment{{Value: owner, Tone: tone}}, - Width: inner, - Flush: true, - }.Render(m.th)) -} - -// cursorScreenPos maps the composer's buffer cursor onto absolute screen -// coordinates. -func (m *Model) cursorScreenPos() (x, y int, ok bool) { - l := m.layout - - line, col := m.input.CursorPos() - if line >= l.InputHeight { - return 0, 0, false - } - - // Gutter, panel border, panel padding, then the two-cell prompt on line 0. - x = theme.Gutter + 1 + panelPadding + 2 + col - y = l.ComposerTop() + 1 + line - - if x >= l.Width || y >= l.Height { - return 0, 0, false - } - - return x, y, true -} - -// viewOverlay composites the modal pane on top of the composed frame. -// -// The frame underneath is preserved rather than blanked: an operator deciding -// whether to allow a tool call needs to see the transcript that led to it, and -// a full-screen takeover would hide exactly the context the decision depends -// on. The pane is drawn on the element surface with an active border, which is -// what makes it read as elevated above the panels behind it. -func (m *Model) viewOverlay(l Layout, frame string) string { - // The preferred width is two thirds of the screen, but the terminal always - // wins: on a very small terminal the desired minimum exceeds the available - // width, and a pane wider than the screen wraps and corrupts every row - // beneath it. - width := clamp(l.Width*2/3, 24, max(l.Width-2*theme.Space2, 1)) - inner := max(width-panelChrome-2*panelPadding, 1) - - parts := []string{ui.New().Fg(m.th.C.Text).Bold().Render(m.overlay.title)} - - switch { - case m.overlay.preview != nil: - parts = append(parts, "", m.painter.Tree(m.overlay.preview, paint.Opts{Width: inner})) - case m.overlay.rawInput != "": - // The plan/apply gate requires falling back to the raw input when a - // provider supplied no preview, rather than showing nothing. - parts = append(parts, "", m.th.Code.Width(inner).Render(m.overlay.rawInput)) - } - - parts = append(parts, "", - ui.New().Fg(m.th.C.TextSubtle). - Render(m.keys.Hints(LayerOverlay, m.focus, false, inner))) - - body := strings.Join(parts, "\n") - height := min(lipgloss.Height(body)+panelChrome, max(l.Height-2, 3)) - - pane := ui.Panel{ - Title: "permission", - Body: body, - Width: width, - Height: height, - Focused: true, - Accent: m.th.C.Warning, - }.Render(m.th) - - x := max((l.Width-width)/2, 0) - y := max((l.Height-height)/2, 0) - - return ui.Overlay(frame, pane, x, y) -} - -func nonEmpty(in []string) []string { - out := make([]string, 0, len(in)) - - for _, s := range in { - if s != "" { - out = append(out, s) - } - } - - return out -} - -// contextDangerAt is the fraction of the effective ceiling past which the -// status line says so in words. -// The hue itself is continuous across the ramp; this threshold governs only -// the text warning, which needs a discrete moment to appear at. -const contextDangerAt = 0.85 - -// contextFill reports context pressure as a fraction, and whether it is known. -func (m *Model) contextFill() (float64, bool) { - // A non-positive ceiling means the kernel has not resolved a budget for - // this session yet; dividing by it would invent a number. - if m.usage == nil || m.usage.EffectiveCeiling <= 0 { - return 0, false - } - - return float64(m.usage.UsedTokens) / float64(m.usage.EffectiveCeiling), true -} - -// contextTone resolves the pressure hue from the theme's gauge ramp, which runs -// green through amber to red by default and is configurable as a list of tone -// names rather than colors. -func (m *Model) contextTone(fill float64) color.Color { - return m.th.GaugeRamp.At(m.th, fill) -} - -// contextWarning returns the status-line warning for high context pressure, or -// empty when there is nothing to say. -// -// It deliberately names no command. Compaction in this system is automatic — a -// context provider declaring compactor: true receives the conversation history -// and returns a rewritten one on its own initiative — so there is no operator -// action like "/compact" to point at, and inventing one would be worse than -// saying nothing. The honest message is that room is running out. -func (m *Model) contextWarning() string { - fill, ok := m.contextFill() - if !ok || fill < contextDangerAt { - return "" - } - - return "context nearly full" -} - -// statusLine is the single row beneath the composer. -// -// It carries only what changes during a turn — context, cache, cost, elapsed. -// Everything static about a session (model, directory, repository) lives in the -// sidebar instead, because a field that never changes does not earn a place -// beside the input box. Volatile data goes where the operator is already -// looking; reference data goes to the periphery. -func (m *Model) statusLine(l Layout) string { - // Context is the only left-hand segment, and everything else is pinned - // right. That is not cosmetic: a line drops left segments right-to-left, so - // anything sitting beside context would survive at its expense — and - // context is the field worth keeping longest. Alone on the left, it can - // never be dropped, and it still reads as the growing middle because the - // right cluster is what it grows against. - var left []ui.Segment - if seg, ok := m.contextSegment(); ok { - left = append(left, seg) - } - - right := []ui.Segment{} - if rate, ok := m.usage.cacheRate(); ok { - right = append(right, ui.Segment{Label: "cache", Value: formatPercent(rate), Tone: m.th.C.Info}) - } - - if m.usage != nil { - right = append(right, ui.Segment{ - Label: "cost", - Value: formatUSD(m.usage.CumulativeCostUSD), - Tone: m.th.C.Warning, - }) - } - - right = append(right, ui.Segment{ - Label: "elapsed", - Value: formatDuration(m.status.Elapsed), - Tone: m.th.C.TextMuted, - }) - - inset := m.app().Render(strings.Repeat(" ", StatusInset)) - - return inset + ui.StatusLine{ - Segments: left, - Right: right, - Width: l.StatusWidth(), - }.Render(m.th) + inset -} - -// contextSegment is the growing middle of the status line: the gradient meter, -// the absolute figures, and the percentage. -// -// It sits between the fixed groups so it absorbs every spare cell — the meter -// is the one thing on the line that benefits from more room. The absolute -// figures ride immediately after the bar rather than at the far edge, which is -// what keeps a long bar from ending in a number marooned across the screen. -func (m *Model) contextSegment() (ui.Segment, bool) { - fill, ok := m.contextFill() - if !ok { - return ui.Segment{}, false - } - - tone := m.contextTone(fill) - figures := formatTokens(m.usage.UsedTokens) + " / " + formatTokens(m.usage.EffectiveCeiling) - - return ui.Segment{ - MinWidth: minContextSegment, - Fill: func(width int) string { - label := ui.New().Fg(m.th.C.TextSubtle).Render("context ") - tail := ui.New().Fg(m.th.C.TextMuted).Render(" "+figures+" ") + - ui.New().Fg(tone).Bold().Render(formatPercent(fill)) - - bar := width - lipgloss.Width(label) - lipgloss.Width(tail) - if bar < minMeterBar { - return label + ui.New().Fg(tone).Render(figures+" "+formatPercent(fill)) - } - - return label + ui.GradientMeter(m.th, m.th.GaugeRamp, bar, fill) + tail - }, - }, true -} - -// sessionPanels are the shell's own sidebar panels: the session reference data -// that used to crowd the composer. -// -// They are built here rather than contributed by a plugin because the shell -// already has the data — but they render exactly like a widget's panel, which -// is deliberate. A widget author looking at the sidebar should see one visual -// language, not shell chrome sitting apart from plugin content. -func (m *Model) sessionPanels(l Layout) []ui.Panel { - inner := l.SidebarInnerWidth() - - usage := ui.Fields(m.th, []ui.Field{ - {Label: "out", Value: m.usage.tokens(func(u *UsageMsg) int64 { return u.OutputTokens })}, - {Label: "in", Value: m.usage.tokens(func(u *UsageMsg) int64 { return u.InputTokens + u.CacheReadTokens })}, - {Label: "cached", Value: m.usage.tokens(func(u *UsageMsg) int64 { return u.CacheWriteTokens })}, - {Label: "lines", Value: m.edits.summary()}, - }, inner) - - panels := make([]ui.Panel, 0, 1) - for _, p := range []ui.Panel{ - {Title: "usage", Body: usage}, - } { - if p.Body != "" { - panels = append(panels, p) - } - } - - return panels -} diff --git a/internal/tui/shell/model_test.go b/internal/tui/shell/model_test.go deleted file mode 100644 index e1295c0..0000000 --- a/internal/tui/shell/model_test.go +++ /dev/null @@ -1,1449 +0,0 @@ -package shell - -import ( - "context" - "fmt" - "image/color" - "regexp" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/pluggableharness/agent/internal/tui/region" - "github.com/pluggableharness/agent/pkg/render" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) - -func plain(s string) string { return ansiPattern.ReplaceAllString(s, "") } - -// recorder captures the actions a model emits so tests can assert on what the -// shell would have sent to the kernel. -type recorder struct{ actions []Action } - -func (r *recorder) emit(a Action) { r.actions = append(r.actions, a) } - -func (r *recorder) last(t *testing.T) Action { - t.Helper() - - if len(r.actions) == 0 { - t.Fatal("no action was emitted") - } - - return r.actions[len(r.actions)-1] -} - -// newTestModel returns a model sized to a wide terminal with an emitter wired. -func newTestModel(t *testing.T) (*Model, *recorder) { - t.Helper() - - rec := &recorder{} - m := New(WithEmitter(rec.emit)) - m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - - return m, rec -} - -func press(t *testing.T, m *Model, keys ...string) { - t.Helper() - - for _, k := range keys { - m.Update(key(k)) - } -} - -// key builds a keypress whose String() matches what the shell binds against. -// Printable single characters carry Text; everything else is a named key. -func key(s string) tea.KeyPressMsg { - if len([]rune(s)) == 1 && s != " " { - r := []rune(s)[0] - - return tea.KeyPressMsg{Code: r, Text: s} - } - - switch s { - case "enter": - return tea.KeyPressMsg{Code: tea.KeyEnter} - case "alt+enter": - return tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt} - case "shift+enter": - return tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift} - case "ctrl+j": - return tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl} - case "tab": - return tea.KeyPressMsg{Code: tea.KeyTab} - case "shift+tab": - return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} - case "backspace": - return tea.KeyPressMsg{Code: tea.KeyBackspace} - case "up": - return tea.KeyPressMsg{Code: tea.KeyUp} - case "down": - return tea.KeyPressMsg{Code: tea.KeyDown} - case "esc": - return tea.KeyPressMsg{Code: tea.KeyEscape} - case "ctrl+c": - return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} - case "ctrl+d": - return tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl} - case "ctrl+b": - return tea.KeyPressMsg{Code: 'b', Mod: tea.ModCtrl} - default: - return tea.KeyPressMsg{Code: tea.KeyEnter} - } -} - -func placeMsg(r renderv1.Region, text string, seq uint64) PlaceMsg { - return PlaceMsg{ - Producer: region.Producer{Category: "tool", Name: "fs"}, - Sequence: seq, - Content: &renderv1.PlacedContent{ - Region: r, - Content: render.Tree(render.Text(text)), - }, - } -} - -// The key helper must produce the strings the keymap actually binds, otherwise -// every keyboard test below would be vacuous. -func TestKeyHelperMatchesBindings(t *testing.T) { - t.Parallel() - - tests := map[string]string{ - "ctrl+c": "ctrl+c", "ctrl+d": "ctrl+d", "ctrl+b": "ctrl+b", - "tab": "tab", "shift+tab": "shift+tab", "enter": "enter", - "alt+enter": "alt+enter", "up": "up", "down": "down", - "esc": "esc", "backspace": "backspace", "y": "y", "a": "a", - "shift+enter": "shift+enter", "ctrl+j": "ctrl+j", - } - - for in, want := range tests { - if got := key(in).String(); got != want { - t.Errorf("key(%q).String() = %q, want %q", in, got, want) - } - } -} - -func TestTypingAndSubmitting(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - press(t, m, "h", "i") - - if got := m.input.Value(); got != "hi" { - t.Fatalf("input = %q, want %q", got, "hi") - } - - press(t, m, "enter") - - got, ok := rec.last(t).(SubmitPrompt) - if !ok { - t.Fatalf("emitted %T, want SubmitPrompt", rec.last(t)) - } - - if got.Text != "hi" { - t.Fatalf("SubmitPrompt.Text = %q, want %q", got.Text, "hi") - } -} - -func TestAltEnterInsertsNewlineInsteadOfSubmitting(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - press(t, m, "a", "alt+enter", "b") - - if got := m.input.Value(); got != "a\nb" { - t.Fatalf("input = %q, want %q", got, "a\nb") - } - - if len(rec.actions) != 0 { - t.Fatalf("alt+enter submitted: %+v", rec.actions) - } - - // The composer growing must be reflected in the solved layout. - if m.Layout().InputHeight != 2 { - t.Fatalf("InputHeight = %d, want 2", m.Layout().InputHeight) - } -} - -func TestFocusCyclesAndSkipsEmptySidebar(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - - if m.Focus() != FocusInput { - t.Fatalf("startup focus = %v, want input", m.Focus()) - } - - press(t, m, "tab") - - if m.Focus() != FocusMain { - t.Fatalf("after tab = %v, want main", m.Focus()) - } - - // The sidebar is on screen but empty, so it is not in the ring. - press(t, m, "tab") - - if m.Focus() != FocusInput { - t.Fatalf("after second tab = %v, want input (empty sidebar skipped)", m.Focus()) - } - - // Give the sidebar content and it joins the ring. - m.Update(placeMsg(renderv1.Region_REGION_SIDEBAR, "git", 1)) - press(t, m, "tab", "tab") - - if m.Focus() != FocusSidebar { - t.Fatalf("with sidebar content, focus = %v, want sidebar", m.Focus()) - } -} - -func TestStreamingDeltasAccumulateAndSettle(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - - m.Update(DeltaMsg{TargetID: "t1", Text: "hello "}) - m.Update(DeltaMsg{TargetID: "t1", Text: "world"}) - - streams := m.Store().Streams() - if len(streams) != 1 || streams[0].Text != "hello world" { - t.Fatalf("deltas did not accumulate: %+v", streams) - } - - if got := plain(m.View().Content); !strings.Contains(got, "hello world") { - t.Fatalf("streamed text not painted: %q", got) - } - - m.Update(SettledMsg{TargetID: "t1"}) - - if len(m.Store().Streams()) != 0 { - t.Fatal("SettledMsg left the live buffer in place; content would appear twice") - } -} - -// A finished render replaces the live buffer rather than appearing beside it. -func TestPlaceClearsLiveStreams(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(DeltaMsg{TargetID: "t1", Text: "partial"}) - m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, "final", 1)) - - if len(m.Store().Streams()) != 0 { - t.Fatal("a settled render left the streaming buffer live") - } - - got := plain(m.View().Content) - if !strings.Contains(got, "final") { - t.Fatalf("final content missing: %q", got) - } - - if strings.Contains(got, "partial") { - t.Fatalf("streamed text painted twice: %q", got) - } -} - -func TestOverlayIsModalAndRestoresFocus(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "tab") // focus main - - m.Update(PermissionMsg{ItemID: "item_1", Title: "Allow write_file?"}) - - if m.activeLayer() != LayerOverlay { - t.Fatal("overlay did not take the keymap layer") - } - - // While modal, region bindings must not reach the shell. - press(t, m, "tab") - - if m.Focus() != FocusMain { - t.Fatalf("tab changed focus while modal: %v", m.Focus()) - } - - if got := plain(m.View().Content); !strings.Contains(got, "Allow write_file?") { - t.Fatalf("overlay not painted: %q", got) - } - - press(t, m, "y") - - if m.activeLayer() != LayerRegion { - t.Fatal("overlay stayed up after a decision") - } - - if m.Focus() != FocusMain { - t.Fatalf("focus not restored after overlay: %v", m.Focus()) - } -} - -func TestPlanDecisionScopes(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - key string - wantAllow bool - wantScope DecisionScope - }{ - {"allow once", "y", true, ScopeOnce}, - {"deny once", "n", false, ScopeOnce}, - {"allow for session", "a", true, ScopeSession}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - m.Update(PermissionMsg{ItemID: "item_1", Title: "?"}) - press(t, m, tc.key) - - got, ok := rec.last(t).(Decision) - if !ok { - t.Fatalf("emitted %T, want Decision", rec.last(t)) - } - - if got.ItemID != "item_1" || got.Allow != tc.wantAllow || got.Scope != tc.wantScope { - t.Fatalf("Decision = %+v, want allow=%v scope=%v", got, tc.wantAllow, tc.wantScope) - } - }) - } -} - -// Dismissing must never resolve a pending decision on the operator's behalf. -func TestEscapeDoesNotResolveAPendingDecision(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - m.Update(PermissionMsg{ItemID: "item_1", Title: "?"}) - press(t, m, "esc") - - if len(rec.actions) != 0 { - t.Fatalf("esc emitted %+v, want nothing", rec.actions) - } - - if m.activeLayer() != LayerOverlay { - t.Fatal("esc dismissed a pending decision overlay") - } -} - -// An operator deciding whether to allow a tool call needs to see the -// transcript that led to it, so the overlay composites over the frame rather -// than blanking it. -func TestOverlayPreservesTheFrameBeneathIt(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, "earlier transcript line", 1)) - m.Update(placeMsg(renderv1.Region_REGION_SIDEBAR, "sidebar widget", 2)) - m.Update(StatusMsg{Session: "session-01", Model: "claude-opus-5"}) - m.Update(PermissionMsg{ItemID: "i", Title: "Allow?"}) - - got := plain(m.View().Content) - - for _, want := range []string{"Allow?", "earlier transcript line", "sidebar widget", "session-01"} { - if !strings.Contains(got, want) { - t.Errorf("overlay frame missing %q:\n%s", want, got) - } - } -} - -// The sidebar column must actually paint its content in the wide layout, not -// merely reserve space for it. -func TestSidebarContentPaintsInWideLayout(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(placeMsg(renderv1.Region_REGION_SIDEBAR, "branch: main", 1)) - - if !m.Layout().ShowSidebar { - t.Fatalf("sidebar not shown at width 120: %+v", m.Layout()) - } - - if got := plain(m.View().Content); !strings.Contains(got, "branch: main") { - t.Fatalf("sidebar content missing from the frame:\n%s", got) - } -} - -// When a provider supplied no preview, the raw input is shown rather than an -// empty prompt. -func TestOverlayFallsBackToRawInput(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(PermissionMsg{ItemID: "i", Title: "Allow?", RawInput: `{"path":"/etc/hosts"}`}) - - if got := plain(m.View().Content); !strings.Contains(got, "/etc/hosts") { - t.Fatalf("raw input fallback not painted: %q", got) - } -} - -func TestActionTriggerDispatchesUnchanged(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - m.Update(PlaceMsg{ - Producer: region.Producer{Category: "widget", Name: "w"}, - Sequence: 1, - Content: &renderv1.PlacedContent{ - Region: renderv1.Region_REGION_MAIN_CHAT, - Content: render.Tree(render.Action("act_1", "Compact", "compact_context", nil, "builtin")), - }, - }) - - press(t, m, "tab") // focus main - press(t, m, "enter") - - got, ok := rec.last(t).(Trigger) - if !ok { - t.Fatalf("emitted %T, want Trigger", rec.last(t)) - } - - if got.NodeID != "act_1" || got.ToolName != "compact_context" || got.Provider != "builtin" { - t.Fatalf("Trigger = %+v; tool name, provider and node id must pass through unchanged", got) - } -} - -func TestActivatingACollapsibleTogglesLocallyAndSendsNothing(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - m.Update(PlaceMsg{ - Producer: region.Producer{Category: "tool", Name: "fs"}, - Sequence: 1, - Content: &renderv1.PlacedContent{ - Region: renderv1.Region_REGION_MAIN_CHAT, - Content: render.Tree(render.CollapsedByDefault("summary", render.Text("hidden body"))), - }, - }) - - press(t, m, "tab") - - if got := plain(m.View().Content); strings.Contains(got, "hidden body") { - t.Fatalf("collapsed content was visible: %q", got) - } - - press(t, m, "enter") - - if len(rec.actions) != 0 { - t.Fatalf("toggling a collapsible sent %+v to the kernel", rec.actions) - } - - if got := plain(m.View().Content); !strings.Contains(got, "hidden body") { - t.Fatalf("collapsible did not expand: %q", got) - } -} - -func TestInterruptThenQuit(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - press(t, m, "ctrl+c") - - if _, ok := rec.last(t).(Interrupt); !ok { - t.Fatalf("first ctrl+c emitted %T, want Interrupt", rec.last(t)) - } - - if m.quitting { - t.Fatal("first ctrl+c quit immediately") - } - - _, cmd := m.Update(key("ctrl+c")) - - if !m.quitting || cmd == nil { - t.Fatal("second ctrl+c did not quit") - } -} - -// An interrupt followed by ordinary typing must not quit on the next ctrl+c. -func TestTypingDisarmsTheQuitSequence(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "ctrl+c", "x", "ctrl+c") - - if m.quitting { - t.Fatal("quit sequence survived an intervening keystroke") - } -} - -func TestCtrlDQuitsOnlyOnAnEmptyComposer(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "x", "ctrl+d") - - if m.quitting { - t.Fatal("ctrl+d quit with text in the composer") - } - - press(t, m, "backspace", "ctrl+d") - - if !m.quitting { - t.Fatal("ctrl+d did not quit on an empty composer") - } -} - -func TestSidebarToggle(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) // between floor and breakpoint - - if m.Layout().ShowSidebar { - t.Fatal("sidebar shown by default on a medium terminal") - } - - press(t, m, "ctrl+b") - - if !m.Layout().ShowSidebar { - t.Fatal("ctrl+b did not open the sidebar") - } - - press(t, m, "ctrl+b") - - if m.Layout().ShowSidebar { - t.Fatal("ctrl+b did not close the sidebar") - } -} - -// Below the floor width the sidebar cannot exist as a pane, so its content -// folds into main_chat rather than being dropped. -func TestNarrowTerminalFoldsSidebarContentIntoMainChat(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(placeMsg(renderv1.Region_REGION_SIDEBAR, "git status", 1)) - m.Update(tea.WindowSizeMsg{Width: 50, Height: 24}) - - if !m.Layout().FoldSidebar() { - t.Fatal("expected the layout to fold the sidebar at width 50") - } - - if got := plain(m.View().Content); !strings.Contains(got, "git status") { - t.Fatalf("folded sidebar content was dropped: %q", got) - } -} - -func TestNoticesAreSurfaced(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(NoticeMsg{Text: "already decided elsewhere", Level: NoticeError}) - - if got := plain(m.View().Content); !strings.Contains(got, "already decided elsewhere") { - t.Fatalf("notice not painted: %q", got) - } -} - -// A late decision the kernel rejected must clear the overlay and say why, -// rather than leaving the UI looking hung. -func TestDismissOverlayMsgClearsAndExplains(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(PermissionMsg{ItemID: "i", Title: "?"}) - m.Update(DismissOverlayMsg{Reason: "already decided elsewhere"}) - - if m.activeLayer() != LayerRegion { - t.Fatal("overlay survived a kernel-side dismissal") - } - - if got := plain(m.View().Content); !strings.Contains(got, "already decided elsewhere") { - t.Fatalf("dismissal reason not surfaced: %q", got) - } -} - -func TestStatusMsgPaintsInTopBar(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(StatusMsg{Session: "session-01", Model: "claude-opus-5", Status: "ready"}) - - header := headerRows(t, m) - - // Session and run state are the header's right-hand group; a line with an - // empty left group still renders them. - for _, want := range []string{"session-01", "ready"} { - if !strings.Contains(header, want) { - t.Errorf("header missing %q:\n%s", want, header) - } - } -} - -func TestViewIsAltScreenAndSurvivesQuitting(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - - if !m.View().AltScreen { - t.Error("view did not declare AltScreen; the shell is a full-screen takeover") - } - - m.quitting = true - - if v := m.View(); v.Content != "" || !v.AltScreen { - t.Errorf("quitting view = %+v, want empty content with AltScreen set", v) - } -} - -func TestModelWithoutEmitterDoesNotPanic(t *testing.T) { - t.Parallel() - - m := New() - m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) - press(t, m, "h", "enter", "ctrl+c") - - // Reaching here without a panic is the assertion: a rendering-only model - // has nowhere to send, and that must be a supported configuration. - if m.input.Value() != "" { - t.Fatalf("submit did not clear the composer: %q", m.input.Value()) - } -} - -func TestInitReturnsNoCommand(t *testing.T) { - t.Parallel() - - if cmd := New().Init(); cmd != nil { - t.Fatal("Init returned a command; the event source runs outside the program") - } -} - -func TestDemoSourceEmitsItsScriptAndStopsOnCancel(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithCancel(context.Background()) - - var got []tea.Msg - - done := make(chan error, 1) - - go func() { - done <- DemoSource{}.Run(ctx, func(m tea.Msg) { got = append(got, m) }) - }() - - // The source blocks on ctx after emitting its script; canceling is the - // documented way it ends, and it must report that as success rather than - // as an error. - cancel() - - if err := <-done; err != nil { - t.Fatalf("Run returned %v, want nil on cancellation", err) - } - - if len(demoScript()) == 0 { - t.Fatal("demo script is empty; the skeleton would show nothing") - } -} - -// The demo fixture must exercise every region the shell lays out, since that -// is the whole reason it exists ahead of the kernel bridge. -func TestDemoScriptCoversTheMainRegions(t *testing.T) { - t.Parallel() - - seen := map[renderv1.Region]bool{} - - for _, msg := range demoScript() { - if p, ok := msg.(PlaceMsg); ok { - seen[p.Content.GetRegion()] = true - } - } - - for _, want := range []renderv1.Region{ - renderv1.Region_REGION_MAIN_CHAT, - renderv1.Region_REGION_SIDEBAR, - } { - if !seen[want] { - t.Errorf("demo script never places content in %v", want) - } - } -} - -// Every frame must be exactly Height rows of exactly Width cells. -// -// This is the invariant that makes the shell a full-screen application rather -// than text printed into someone else's window: an uncovered cell shows the -// terminal's own background, and a row wider than the terminal wraps and -// shifts everything below it. It is also the cheapest way to catch a layout -// arithmetic slip, which is otherwise only visible by eye. -func TestFrameCoversTheTerminalExactly(t *testing.T) { - t.Parallel() - - sizes := [][2]int{ - {120, 30}, {100, 24}, {80, 24}, {72, 20}, - {64, 18}, {52, 16}, {40, 12}, {30, 9}, {20, 6}, - } - - for _, size := range sizes { - w, h := size[0], size[1] - - t.Run(fmt.Sprintf("%dx%d", w, h), func(t *testing.T) { - t.Parallel() - - for _, withOverlay := range []bool{false, true} { - m := New() - m.Update(tea.WindowSizeMsg{Width: w, Height: h}) - - for _, msg := range demoScript() { - m.Update(msg) - } - - if !withOverlay { - m.Update(key("y")) // resolve the demo's permission prompt - } - - rows := strings.Split(m.View().Content, "\n") - if len(rows) != h { - t.Fatalf("overlay=%v: got %d rows, want %d", withOverlay, len(rows), h) - } - - for i, r := range rows { - if got := lipgloss.Width(r); got != w { - t.Errorf("overlay=%v row %d: width %d, want %d: %q", - withOverlay, i, got, w, plain(r)) - } - } - } - }) - } -} - -// Content carrying tabs must not overflow its pane. A tab measures as zero -// cells but a terminal advances to the next tab stop when drawing one, so an -// unexpanded tab silently pushes a row past the terminal width. -func TestTabbedContentDoesNotOverflow(t *testing.T) { - t.Parallel() - - m := New() - m.Update(tea.WindowSizeMsg{Width: 72, Height: 20}) - m.Update(PlaceMsg{ - Producer: region.Producer{Category: "tool", Name: "fs"}, - Sequence: 1, - Content: &renderv1.PlacedContent{ - Region: renderv1.Region_REGION_MAIN_CHAT, - Content: render.Tree(render.Code("go", "func main() {\n\tif x {\n\t\treturn\n\t}\n}")), - }, - }) - - frame := m.View().Content - if strings.ContainsRune(frame, '\t') { - t.Fatal("frame still contains a raw tab; width math cannot be trusted") - } - - for i, r := range strings.Split(frame, "\n") { - if got := lipgloss.Width(r); got != 72 { - t.Errorf("row %d width %d, want 72: %q", i, got, plain(r)) - } - } -} - -// shift+enter is the binding operators reach for. A bare terminal cannot -// distinguish it from enter, so fallbacks exist — but all of them must insert a -// newline and grow the composer rather than submitting. -func TestNewlineBindingsInsertAndGrowTheComposer(t *testing.T) { - t.Parallel() - - for _, k := range []string{"shift+enter", "alt+enter", "ctrl+j"} { - t.Run(k, func(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - press(t, m, "a") - m.Update(key(k)) - press(t, m, "b") - - if got := m.input.Value(); got != "a\nb" { - t.Fatalf("%s: input = %q, want %q", k, got, "a\nb") - } - - if len(rec.actions) != 0 { - t.Fatalf("%s submitted instead of inserting a newline: %+v", k, rec.actions) - } - - // The composer must actually grow, and the body must shrink to - // make room for it. - if got := m.Layout().InputHeight; got != 2 { - t.Fatalf("%s: InputHeight = %d, want 2", k, got) - } - - if m.Layout().ComposerHeight != 2+panelChrome { - t.Fatalf("%s: ComposerHeight = %d, want %d", k, m.Layout().ComposerHeight, 2+panelChrome) - } - }) - } -} - -// A plain enter still submits; the newline bindings must not have swallowed it. -func TestPlainEnterStillSubmits(t *testing.T) { - t.Parallel() - - m, rec := newTestModel(t) - press(t, m, "h", "i", "enter") - - if _, ok := rec.last(t).(SubmitPrompt); !ok { - t.Fatalf("enter emitted %T, want SubmitPrompt", rec.last(t)) - } -} - -// The composer grows only to its cap, then scrolls internally rather than -// eating the whole screen. -func TestComposerGrowthIsCapped(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - for range 20 { - m.Update(key("shift+enter")) - } - - if got := m.Layout().InputHeight; got != inputMaxHeight { - t.Fatalf("InputHeight = %d, want the cap %d", got, inputMaxHeight) - } -} - -// The wheel must scroll this transcript. Without claiming it, the terminal -// scrolls its own scrollback behind the alt screen and the app only appears to -// have handled the gesture. -func TestWheelScrollsTheTranscript(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - for i := range 60 { - m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, fmt.Sprintf("line %d", i), uint64(i))) - } - - // Paint once so the model learns how far the content can scroll. - _ = m.View() - - if !m.pinned { - t.Fatal("expected to start pinned to the live tail") - } - - m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) - - if m.pinned { - t.Fatal("scrolling up did not detach from the live tail") - } - - if m.scroll != m.maxScroll-wheelStep { - t.Fatalf("scroll = %d, want %d", m.scroll, m.maxScroll-wheelStep) - } - - // Scrolling back to the bottom re-attaches, so new content follows again. - m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) - - if !m.pinned { - t.Fatal("scrolling back to the bottom did not re-attach to the live tail") - } -} - -func TestWheelDoesNotScrollPastTheTop(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, "only line", 1)) - _ = m.View() - - for range 10 { - m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) - } - - if m.scroll < 0 { - t.Fatalf("scroll went negative: %d", m.scroll) - } -} - -// The view must declare the takeover properties, since each of them is what -// stops some part of the terminal from behaving as if the app were not there. -func TestViewClaimsTheTerminal(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(StatusMsg{Session: "session-01"}) - - v := m.View() - - if !v.AltScreen { - t.Error("AltScreen not set; the app would print into the user's scrollback") - } - - if v.MouseMode != tea.MouseModeCellMotion { - t.Error("mouse not claimed; the wheel would scroll the terminal instead of the transcript") - } - - if v.BackgroundColor == nil || v.ForegroundColor == nil { - t.Error("view did not set terminal default colors") - } - - if !strings.Contains(v.WindowTitle, "session-01") { - t.Errorf("WindowTitle = %q, want it to name the session", v.WindowTitle) - } -} - -// Before any turn there is no usage report, and the gauge must be absent -// rather than reading zero — a confident 0% is a lie. -func TestContextGaugeAbsentUntilUsageArrives(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - - if strings.Contains(plain(m.View().Content), "context") { - t.Fatal("context shown before any usage was reported") - } - - if _, ok := m.contextFill(); ok { - t.Fatal("contextFill claimed to know pressure with no usage") - } - - // A ceiling the kernel has not resolved is equally unknown. - m.Update(UsageMsg{UsedTokens: 100, EffectiveCeiling: 0}) - - if strings.Contains(plain(m.View().Content), "context") { - t.Fatal("context shown against a zero ceiling") - } -} - -// Pressure is measured against the effective ceiling, which is what remains -// after the kernel reserves room for output and tool schemas. -func TestContextFillDividesByTheEffectiveCeiling(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(UsageMsg{UsedTokens: 50_000, EffectiveCeiling: 200_000}) - - got, ok := m.contextFill() - if !ok { - t.Fatal("pressure unknown after a usage report") - } - - if got != 0.25 { - t.Fatalf("fill = %v, want 0.25", got) - } - - if got := plain(m.View().Content); !strings.Contains(got, "25%") { - t.Fatalf("context percentage not shown:\n%s", got) - } -} - -// The gauge hue is a continuous ramp, not a set of steps: it runs green -// through amber to red so the color itself reads as pressure. -func TestContextToneRunsGreenToRed(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - th := m.th - - // The ramp's endpoints and midpoint land exactly on their stops. - for _, tc := range []struct { - fill float64 - want color.Color - name string - }{ - {0, th.C.Success, "empty is green"}, - {0.5, th.C.Warning, "half is amber"}, - {1, th.C.Danger, "full is red"}, - } { - if got := m.contextTone(tc.fill); got != tc.want { - t.Errorf("%s: tone at %.2f = %v, want %v", tc.name, tc.fill, got, tc.want) - } - } - - // And it moves monotonically between them: more red, less green, as - // pressure climbs. - var prevR, prevG uint32 - - for i := range 11 { - r, g, _, _ := m.contextTone(float64(i) / 10).RGBA() - - if i > 0 { - if r < prevR { - t.Errorf("red channel fell between %d0%% and %d0%%", i-1, i) - } - - if g > prevG { - t.Errorf("green channel rose between %d0%% and %d0%%", i-1, i) - } - } - - prevR, prevG = r, g - } -} - -// The warning names no command: compaction is automatic in this system, driven -// by a compactor context provider, so there is no operator action to point at. -func TestContextWarningAppearsOnlyUnderPressure(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - - if got := m.contextWarning(); got != "" { - t.Fatalf("warning with no usage: %q", got) - } - - m.Update(UsageMsg{UsedTokens: 100_000, EffectiveCeiling: 200_000}) - - if got := m.contextWarning(); got != "" { - t.Fatalf("warning at 50%%: %q", got) - } - - m.Update(UsageMsg{UsedTokens: 190_000, EffectiveCeiling: 200_000}) - - warning := m.contextWarning() - if warning == "" { - t.Fatal("no warning at 95% of the ceiling") - } - - if strings.Contains(warning, "/") { - t.Fatalf("warning names a command that does not exist: %q", warning) - } - - // It must reach the status line, replacing the less urgent focus label. - if got := plain(m.View().Content); !strings.Contains(got, warning) { - t.Fatalf("warning never reached the frame:\n%s", got) - } -} - -func TestContextMeterReachesTheStatusBar(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(UsageMsg{UsedTokens: 51_204, EffectiveCeiling: 200_000}) - - got := plain(m.View().Content) - if !strings.Contains(got, "26%") { - t.Fatalf("context percentage not painted:\n%s", got) - } - - if !strings.Contains(got, "━") { - t.Fatalf("gauge fill not painted:\n%s", got) - } -} - -// A transcript grows upward from the composer. Content shorter than the -// viewport is pushed to the bottom so the newest message sits next to where -// the operator is typing, with the empty space above it rather than between. -func TestTranscriptIsBottomAnchored(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, "newest line", 1)) - - rows := strings.Split(plain(m.View().Content), "\n") - - // Find the transcript's content rows: inside the conversation panel. - var lastContent int - - for i, r := range rows { - if strings.Contains(r, "newest line") { - lastContent = i - } - } - - if lastContent == 0 { - t.Fatal("content not found in the frame") - } - - // The composer starts within a few rows of the content, not a screen away. - composerRow := 0 - - for i, r := range rows { - if strings.Contains(r, "ask anything") { - composerRow = i - } - } - - if gap := composerRow - lastContent; gap > 4 { - t.Fatalf("content sits %d rows above the composer; it should hug it", gap) - } -} - -// Static session data belongs in the sidebar, where there is room, rather than -// beside the composer. -func TestSessionPanelsRenderInTheSidebar(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(UsageMsg{UsedTokens: 10, EffectiveCeiling: 100, OutputTokens: 9_120}) - - got := plain(m.View().Content) - - for _, want := range []string{"usage", "9.1k"} { - if !strings.Contains(got, want) { - t.Errorf("sidebar missing %q:\n%s", want, got) - } - } -} - -// Where the session is working belongs in the top bar: it is stable for the -// whole session and is the first thing checked on returning to a window. -func TestWorkspaceAppearsInTheTopBar(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(WorkspaceMsg{Directory: "~/code/aiagent", Repository: "pluggableharness/agent"}) - - header := headerRows(t, m) - - for _, want := range []string{"~/code/aiagent", "pluggableharness/agent"} { - if !strings.Contains(header, want) { - t.Errorf("header missing %q:\n%s", want, header) - } - } - - // The agent is a setting, not identity, and lives beside the composer. - if strings.Contains(header, "Code") { - t.Errorf("agent leaked into the header:\n%s", header) - } -} - -// statusRow returns the plain text of the volatile-state line, which sits -// between the composer and the footer box. -func statusRow(t *testing.T, m *Model) string { - t.Helper() - - rows := strings.Split(plain(m.View().Content), "\n") - - return rows[len(rows)-chromePanelHeight-1] -} - -// headerRows returns the plain text of the header box. -func headerRows(t *testing.T, m *Model) string { - t.Helper() - - rows := strings.Split(plain(m.View().Content), "\n") - - return strings.Join(rows[:chromePanelHeight], "\n") -} - -// footerRows returns the plain text of the footer box. -func footerRows(t *testing.T, m *Model) string { - t.Helper() - - rows := strings.Split(plain(m.View().Content), "\n") - - return strings.Join(rows[len(rows)-chromePanelHeight:], "\n") -} - -// A long path keeps its tail, which is the part that identifies it. -func TestLongDirectoryClipsFromTheLeft(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(WorkspaceMsg{Directory: "/home/steven/code/aiagent/internal/tui/shell/deeply/nested"}) - - if header := headerRows(t, m); !strings.Contains(header, "nested") { - t.Errorf("header lost the path tail:\n%s", header) - } -} - -// A panel with nothing to show is not rendered: an empty titled box is worse -// than no box. -func TestSessionPanelsAbsentWithoutData(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - - if got := plain(m.View().Content); strings.Contains(got, "workspace") { - t.Fatal("workspace panel rendered with no workspace data") - } -} - -// The status line carries only what changes during a turn. -func TestStatusLineCarriesOnlyVolatileState(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(WorkspaceMsg{Directory: "~/code/aiagent", Repository: "pluggableharness/agent"}) - m.Update(UsageMsg{UsedTokens: 52_000, EffectiveCeiling: 200_000, CumulativeCostUSD: 0.42, InputTokens: 100, CacheReadTokens: 900}) - m.Update(StatusMsg{Model: "claude-opus-5", Effort: "high", Elapsed: 22 * time.Minute}) - - status := statusRow(t, m) - - for _, want := range []string{"context", "26%", "cache", "cost", "$0.42", "elapsed"} { - if !strings.Contains(status, want) { - t.Errorf("status line missing %q: %q", want, status) - } - } - - // Static fields must not have followed it down here. - for _, unwanted := range []string{"dir", "repo", "model"} { - if strings.Contains(status, unwanted) { - t.Errorf("static field %q leaked onto the status line: %q", unwanted, status) - } - } - - // Model and reasoning sit on the composer, not the status line — but in the - // opposite corner from the agent, so the two are not read as one label. - rows := strings.Split(plain(m.View().Content), "\n") - prompt := composerRowIndex(t, rows) - top, bottom := rows[prompt-1], rows[prompt+1] - - for _, want := range []string{"claude-opus-5", "high"} { - if !strings.Contains(bottom, want) { - t.Errorf("composer bottom border missing %q: %q", want, bottom) - } - - if strings.Contains(top, want) { - t.Errorf("%q leaked into the composer title: %q", want, top) - } - } - - if !strings.Contains(top, "Code") { - t.Errorf("composer title missing the agent: %q", top) - } -} - -// The context meter must never blink out while a terminal is being resized. -// -// It did: the right-hand group was all-or-nothing, so one column of width could -// make a field affordable and take the meter from drawable to -// below-the-minimum in a single step. -func TestContextMeterSurvivesEveryWidth(t *testing.T) { - t.Parallel() - - // One model, resized in place: rebuilding it per width made this the - // slowest test in the package for no added coverage. - m := New() - m.Update(UsageMsg{ - UsedTokens: 51_204, EffectiveCeiling: 200_000, - CumulativeCostUSD: 0.42, InputTokens: 100, CacheReadTokens: 900, - }) - m.Update(StatusMsg{Elapsed: 22 * time.Minute}) - - // Rendering just the status line rather than the whole frame: the sidebar - // and transcript cost far more to paint and have nothing to do with this. - // The range stops at 150 deliberately: every group-shedding transition - // happens below it, and each width costs a full gradient render. - for w := 50; w <= 150; w++ { - status := plain(m.statusLine(Solve(w, 24, 1, false))) - - if !strings.Contains(status, "context") { - t.Fatalf("width %d: context segment dropped entirely: %q", w, status) - } - - if bar := strings.Count(status, "━") + strings.Count(status, "─"); bar < minMeterBar { - t.Fatalf("width %d: meter collapsed to %d cells: %q", w, bar, status) - } - } -} - -// Header and footer are bordered boxes, in the same visual language as the -// panels between them. A background tint was tried first and did not read as -// chrome at these contrast levels. -func TestHeaderAndFooterAreBoxed(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(WorkspaceMsg{Directory: "~/code/aiagent"}) - - header, footer := headerRows(t, m), footerRows(t, m) - - for _, tc := range []struct{ name, rows, title string }{ - {"header", header, "pluggableharness"}, - {"footer", footer, "keys"}, - } { - if !strings.Contains(tc.rows, "╭") || !strings.Contains(tc.rows, "╰") { - t.Errorf("%s is not boxed:\n%s", tc.name, tc.rows) - } - - if !strings.Contains(tc.rows, tc.title) { - t.Errorf("%s missing its title %q:\n%s", tc.name, tc.title, tc.rows) - } - } - - // The status line between them stays unboxed: it is what the boxes are - // separating, not another piece of chrome. - rows := strings.Split(plain(m.View().Content), "\n") - status := rows[len(rows)-chromePanelHeight-1] - - if strings.Contains(status, "╭") || strings.Contains(status, "╰") { - t.Errorf("status line was boxed: %q", status) - } -} - -// The cursor must land on the row the composer actually paints, at the column -// just past the text. -// -// It drifted once already: the header grew from a single line into a bordered -// box and the cursor kept counting it as one row, so it sat two rows above the -// text. Asserting against the painted frame rather than against the arithmetic -// is what makes that class of mistake fail loudly. -func TestCursorLandsOnTheComposerRow(t *testing.T) { - t.Parallel() - - sizes := [][2]int{ - {120, 30}, {120, 24}, {120, 16}, {120, 14}, {120, 10}, {80, 20}, {60, 12}, - } - - for _, size := range sizes { - w, h := size[0], size[1] - - t.Run(fmt.Sprintf("%dx%d", w, h), func(t *testing.T) { - t.Parallel() - - m := New() - m.Update(tea.WindowSizeMsg{Width: w, Height: h}) - press(t, m, "h", "i") - - x, y, ok := m.cursorScreenPos() - if !ok { - t.Fatal("cursor reported no position while the composer had focus") - } - - rows := strings.Split(plain(m.View().Content), "\n") - if y >= len(rows) { - t.Fatalf("cursor row %d is outside the frame (%d rows)", y, len(rows)) - } - - if !strings.Contains(rows[y], "› hi") { - t.Fatalf("cursor row %d is not the composer row: %q", y, rows[y]) - } - - // The column sits immediately after what has been typed. - if got := []rune(rows[y]); x >= len(got) || string(got[x-2:x]) != "hi" { - t.Fatalf("cursor column %d does not follow the text: %q", x, rows[y]) - } - }) - } -} - -// A multi-line composer puts the cursor on the continuation row. -func TestCursorFollowsTheComposerAcrossLines(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "a") - m.Update(key("shift+enter")) - press(t, m, "b") - - _, y, ok := m.cursorScreenPos() - if !ok { - t.Fatal("no cursor position") - } - - rows := strings.Split(plain(m.View().Content), "\n") - if !strings.Contains(rows[y], "b") || strings.Contains(rows[y], "›") { - t.Fatalf("cursor row %d is not the second composer line: %q", y, rows[y]) - } -} - -// The cursor is hidden whenever the composer does not own the keyboard. -func TestCursorHiddenWhenComposerIsNotFocused(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - press(t, m, "tab") // focus the transcript - - if v := m.View(); v.Cursor != nil { - t.Fatal("cursor shown while the transcript had focus") - } - - m.Update(PermissionMsg{ItemID: "i", Title: "?"}) - - if v := m.View(); v.Cursor != nil { - t.Fatal("cursor shown while a modal was up") - } -} - -// composerRow finds the row carrying the prompt, rather than assuming an -// offset that shifts whenever a band's height changes. -func composerRow(t *testing.T, rows []string) string { - t.Helper() - - return rows[composerRowIndex(t, rows)] -} - -// composerRowIndex locates the composer by its prompt rather than by counting -// rows from an edge. Offsets from the bottom of the frame have broken twice — -// once when the header became a bordered box, once when the status line grew — -// and each time the test kept passing against the wrong row for a while first. -func composerRowIndex(t *testing.T, rows []string) int { - t.Helper() - - for i, r := range rows { - if strings.Contains(r, "›") { - return i - } - } - - t.Fatal("no composer row in the frame") - - return 0 -} - -// runeCol reports the display column of the first occurrence of sub. Byte -// offsets are useless here: the box-drawing characters are multi-byte, so -// strings.Index would report a position several columns off. -func runeCol(t *testing.T, row, sub string) int { - t.Helper() - - b := strings.Index(row, sub) - if b < 0 { - t.Fatalf("%q not found in %q", sub, row) - } - - return len([]rune(row[:b])) -} - -// Every band's text starts on the same column. -// -// The status line has no border of its own, so it needs a wider margin than the -// panels to land where their content does; the lines inside the header and -// footer need a narrower one, because their panel has already padded them. -// Getting either wrong leaves a band a column adrift, which reads as sloppy -// long before anyone works out why. -func TestChromeTextSharesOneLeftMargin(t *testing.T) { - t.Parallel() - - for _, w := range []int{124, 100, 90, 72} { - m := New() - m.Update(tea.WindowSizeMsg{Width: w, Height: 26}) - m.Update(WorkspaceMsg{Directory: "~/code/aiagent"}) - m.Update(UsageMsg{UsedTokens: 51_204, EffectiveCeiling: 200_000}) - - rows := strings.Split(plain(m.View().Content), "\n") - - cols := map[string]int{ - "header": runeCol(t, rows[1], "~/code"), - "composer": runeCol(t, composerRow(t, rows), "›"), - "status": runeCol(t, rows[len(rows)-chromePanelHeight-1], "context"), - "footer": runeCol(t, rows[len(rows)-chromePanelHeight+1], "enter"), - } - - for name, got := range cols { - if got != cols["composer"] { - t.Errorf("width %d: %s starts at column %d, composer at %d", w, name, got, cols["composer"]) - } - } - } -} - -// The status line's right edge lands on the panels' content edge, not on their -// border — it is inset to match what is inside them. -func TestStatusLineRightEdgeMatchesPanelContent(t *testing.T) { - t.Parallel() - - m, _ := newTestModel(t) - m.Update(UsageMsg{ - UsedTokens: 51_204, EffectiveCeiling: 200_000, - CumulativeCostUSD: 0.42, InputTokens: 100, CacheReadTokens: 900, - }) - m.Update(StatusMsg{Elapsed: 22 * time.Minute}) - - rows := strings.Split(plain(m.View().Content), "\n") - status := strings.TrimRight(rows[len(rows)-chromePanelHeight-1], " ") - composer := strings.TrimRight(composerRow(t, rows), " ") - - // The composer row ends with gutter + border; its content ends two columns - // earlier, which is where the status line should end. - statusEnd := len([]rune(status)) - 1 - composerContentEnd := len([]rune(composer)) - 1 - 2 - - if statusEnd != composerContentEnd { - t.Errorf("status ends at column %d, panel content ends at %d", statusEnd, composerContentEnd) - } -} diff --git a/internal/tui/theme/CLAUDE.md b/internal/tui/theme/CLAUDE.md deleted file mode 100644 index 169d45f..0000000 --- a/internal/tui/theme/CLAUDE.md +++ /dev/null @@ -1,39 +0,0 @@ -# internal/tui/theme — agent notes - -## Palette and Tokens are two layers on purpose - -`Palette` is raw values named for what they are; `Tokens` names them by role. Only `tokens()` bridges the two. Do not let a `Palette` field leak outside this package, and do not add a color to `Tokens` by inlining a hex literal — add the value to `Palette` and map it. - -Both built-in themes route through `New`, so a new derived style is added once and both get it. A theme constructed by hand somewhere else will drift. - -## Unset style is not the same as `TEXT_STYLE_NORMAL` - -`TextNode.style` is optional. A nil pointer means "frontend's own default" (`Theme.Default`); an explicit `TEXT_STYLE_NORMAL` is a producer deliberately asking for plain styling (`Theme.Normal`). The spec calls these out as distinct states, so they stay separate fields even though both built-ins render them identically. A test asserts they remain independently overridable. - -## Unknown enum values must fall back, never panic or drop - -`TextStyle` has a `default:` branch returning `Theme.Default`, for a value added to the enum after this build shipped. The protocol requires rendering text a frontend has no visual treatment for rather than dropping it. - -## Content styles must never set a background - -Lip Gloss ends every styled run with a full SGR reset, which clears any background its container had set. A text style carrying its own background paints a band that stops where the text stops and drops the rest of the row onto the terminal background — a visible patchwork across every pane. This was a real bug, not a hypothetical. - -Surfaces are painted by the container that owns them; text contributes color only. `TestContentStylesSetNoBackground` enforces this across both themes — if it fails, do not "fix" the test. - -The one deliberate exception is `ActionFocused`: a filled control is a single self-contained run whose reset lands at its own end, so it cannot bleed. `ui.Badge` is filled for the same reason. - -Note that `GetBackground()` on an unset style returns a zero color value, not nil — compare against `lipgloss.NewStyle().GetBackground()`. - -## Neutrals must stay grey, and dividers must stay visible - -Three tests guard the palette and they are worth understanding before changing a hex value: - -- `TestNeutralsAreNearlyGrey` — every neutral stays within a channel spread of 24. It measures absolute spread rather than HSV saturation because saturation is meaningless near black: a six-point spread at `#101216` is a quarter of the maximum channel and completely invisible. -- `TestDividerIsVisibleAgainstTheBackground` — asserts from both ends: the divider must be legible as a line (>2:1) *and* stay subordinate to the dimmest text. -- `TestTextContrastIsLegible` — text clears 7:1, subtle text 3:1. - -`Divider` is deliberately a separate token from `Border`. A border frames a region and reads as structure when dim; an inline separator glyph needs more contrast. Do not collapse them. - -## This package is pure domain - -No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. It is 100%-covered; keep it there. diff --git a/internal/tui/theme/README.md b/internal/tui/theme/README.md deleted file mode 100644 index 93d4030..0000000 --- a/internal/tui/theme/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# internal/tui/theme - -The shell's design tokens: colors, the spacing scale, and the border presets. This is the bottom of the UI dependency chain — it imports nothing from the rest of the shell, and everything else consumes it. - -## Two color layers - -The split is the same one any design system makes, and it is the reason a theme can be swapped without touching a line of painting code. - -- **`Palette`** — the raw values, named for what they are: a ten-step neutral ramp from app background to strongest text, plus six intent hues. Nothing outside this package references a `Palette` field. -- **`Tokens`** — the semantic layer, naming every color by its *role*. This is the only color vocabulary the rest of the shell sees. - -`tokens()` is the single function that decides which ramp step means "panel" or "muted text", so both built-in themes stay structurally identical and only their colors differ. - -### The token set - -| Group | Tokens | Why three | -|---|---|---| -| Surfaces | `Background`, `BackgroundPanel`, `BackgroundElement` | The application surface, plus two fills reserved for self-contained controls (see below) | -| Text | `Text`, `TextMuted`, `TextSubtle`, `OnAccent` | Primary, secondary, and de-emphasized, plus text on a filled accent | -| Lines | `BorderSubtle`, `Border`, `BorderActive`, `Divider` | Quiet edges, ordinary pane edges, focus — and inline separators, which need more contrast than a border | -| Intents | `Primary`, `Accent`, `Success`, `Warning`, `Danger`, `Info` | | -| Diff | `DiffAdded`, `DiffRemoved`, `DiffContext`, `DiffHunkHeader` | | - -## Neutrals are actually neutral - -The ramp keeps only a hint of cool. An earlier version carried a third of its value in blue — `#4a5570` is periwinkle, not grey — and at low luminance that cast is the first thing the eye notices, so borders and separators stopped reading as quiet structure and started reading as dark blue lines. `TestNeutralsAreNearlyGrey` holds every neutral to a channel spread of 24 or less. - -That test measures **absolute channel spread, not HSV saturation**, and the distinction matters: near black a six-point spread is a quarter of the maximum channel yet completely imperceptible, so saturation flags colors that look perfectly neutral while catching nothing that matters. - -## A divider is not a border - -`Divider` exists separately from `Border` because the two have different jobs. A border frames a region and reads as structure even when dim; a lone `│` between two fields sits among text and needs more contrast to register at all. Sharing one token left the separators effectively invisible — 1.2:1 against the background. - -Current contrast against the background: divider 2.8:1, border 1.9:1, subtle text 4.8:1, text 12.6:1. The divider is visible as a line while staying subordinate to the dimmest text, which is what `TestDividerIsVisibleAgainstTheBackground` asserts from both ends. - -## Tone — naming a role, not a color - -`Tone` is the selector configuration uses when it wants to say "this thing is amber" without naming a hex value: `color = "warning"` resolves through `ToneByName` and then `Theme.Tone`, so a custom theme recolors everything that referenced it. The agent roster in `internal/tui/shell` is the first consumer. - -## Ramps and blending - -`Ramp` is an ordered list of tone *roles* a gauge interpolates across — `{ToneSuccess, ToneWarning, ToneDanger}` by default, expressible in config as `["success", "warning", "danger"]`. `Ramp.At` resolves a position from 0 to 1, interpolating between the two stops it falls between. - -`Mix` blends two colors, and it is how the theme derives a shade instead of hardcoding one: `Theme.Muted` is a token mixed toward `Background`, and an intermediate ramp hue is one stop mixed toward the next. Both stay expressed in terms of tokens, so a custom theme recolors them too. This is the sanctioned way to produce a color that is not itself a token — never a literal at a call site. - -## The non-color half - -Spacing is a scale (`Space0`..`Space4`) plus `Gutter`, the breathing room between the screen edge and the outermost pane. Borders are presets (`BorderNone`, `BorderNormal`, `BorderRounded`, `BorderThick`). Call sites pick a step; they do not invent a number. - -## One surface - -What makes the UI read as paneled is borders, titles, and spacing — not competing background colors. The shell paints a single application surface, set once on the Bubble Tea `View`. - -That is a correctness rule before a stylistic one: Lip Gloss ends every styled run with a full SGR reset, which clears whatever background its container set, so a broadly-filled region only stays filled until the first styled run inside it ends. `BackgroundPanel` and `BackgroundElement` therefore exist for genuinely filled, self-contained controls — a badge, a focused button — not for regions. - -## Derived styles - -`Theme` also carries the Lip Gloss styles the painter uses, derived from the tokens in `New` so the two can never disagree. **Content styles set a foreground and never a background**, for the reason above; `ActionFocused` is the one deliberate exception. - -`TextStyle` maps the protocol's `TextStyle` enum onto those styles, including the two cases the spec distinguishes (unset versus explicit `NORMAL`) and the graceful fallback for a value added to the enum after this build shipped. - -## Related - -- `internal/tui/ui` — the utility and component layer built on these tokens. -- [`docs/first-party/frontends/tui.md`](../../../docs/first-party/frontends/tui.md) — the design system in prose. diff --git a/internal/tui/theme/doc.go b/internal/tui/theme/doc.go deleted file mode 100644 index 9c8fd88..0000000 --- a/internal/tui/theme/doc.go +++ /dev/null @@ -1,12 +0,0 @@ -// Package theme owns the reference TUI shell's style tokens: the mapping from -// the protocol's TextStyle vocabulary -// (docs/specifications/frontend/render-tree.md) plus the shell's own chrome -// roles onto concrete Lip Gloss styles. -// -// The package is deliberately a token table rather than a styling engine. Every -// visual decision the shell makes resolves to one of the fields on Theme, so a -// future config-driven theme can be added by constructing a different Theme -// without touching the painter. Nothing here performs I/O or inspects the -// terminal; profile downsampling for 16-color and monochrome terminals is Lip -// Gloss's job, so tokens are authored once in truecolor. -package theme diff --git a/internal/tui/theme/export_test.go b/internal/tui/theme/export_test.go deleted file mode 100644 index bed59e2..0000000 --- a/internal/tui/theme/export_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package theme - -// ExportToOklab exposes the Oklab conversion to this package's external tests, -// which need it to assert that a gradient's lightness behaves. -var ExportToOklab = toOklab diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go deleted file mode 100644 index 27292e3..0000000 --- a/internal/tui/theme/theme.go +++ /dev/null @@ -1,529 +0,0 @@ -package theme - -import ( - "image/color" - "math" - - "charm.land/lipgloss/v2" - - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -// Palette is the raw color layer: the literal values a theme is built from, -// named for what they are rather than what they are used for. -// -// Nothing outside this package should reference a Palette field directly. It -// exists to be mapped onto Tokens, exactly as a design system keeps its raw -// ramp separate from the semantic names that reference it — the palette answers -// "what colors exist", Tokens answers "what do we use them for". -type Palette struct { - // Neutral is the surface-to-text ramp, darkest first for a dark theme and - // lightest first for a light one. Index 0 is the app background and index 9 - // is the highest-contrast text. - Neutral [10]string - - Primary string - Accent string - Success string - Warning string - Danger string - Info string - - // OnAccent is the text color placed on top of a filled accent surface. - OnAccent string -} - -// Tokens is the semantic color layer: every color the shell paints with, -// named for its role. -// -// What makes the UI read as paneled is borders, titles, and spacing — not -// competing surface colors. Background is the whole application surface and is -// set once on the Bubble Tea View; BackgroundPanel and BackgroundElement exist -// for genuinely filled, self-contained controls (a badge, a focused button), -// not for broad regions. -// -// The reason is mechanical rather than aesthetic. Lip Gloss ends every styled -// run with a full SGR reset, which clears any background a container set, so a -// broadly-filled region only stays filled until the first styled run inside it -// ends. Filling large areas is therefore unreliable, and a single flat -// application surface is both correct and calmer to look at. See New for the -// rule this places on content styles. -type Tokens struct { - Background color.Color - BackgroundPanel color.Color - BackgroundElement color.Color - - Text color.Color - TextMuted color.Color - TextSubtle color.Color - OnAccent color.Color - - Border color.Color - BorderSubtle color.Color - BorderActive color.Color - // Divider is for inline separators between fields, which have a different - // job from a panel edge and therefore a different weight. A border frames a - // region and reads as structure even when dim; a lone separator glyph sits - // among text and needs more contrast to register at all. Sharing one token - // between them leaves one of the two wrong. - Divider color.Color - - Primary color.Color - Accent color.Color - Success color.Color - Warning color.Color - Danger color.Color - Info color.Color - - DiffAdded color.Color - DiffRemoved color.Color - DiffContext color.Color - DiffHunkHeader color.Color -} - -// Tone names a color role rather than a color. It is the selector a -// configuration file uses when it wants to say "this thing is amber" without -// naming a hex value the theme should own — `color = "warning"` resolves -// through ToneByName and then through Theme.Tone, so a custom theme recolors -// everything that referenced it. -type Tone int - -// The tone roles, in the order their config-facing names are declared below. -const ( - // ToneNeutral is the default, muted role. - ToneNeutral Tone = iota - // TonePrimary is the main accent used for focus and interaction. - TonePrimary - // ToneAccent is the secondary accent. - ToneAccent - // ToneSuccess marks a positive or completed state. - ToneSuccess - // ToneWarning marks something worth attention, short of an error. - ToneWarning - // ToneDanger marks a failure or a destructive action. - ToneDanger - // ToneInfo marks neutral, informational emphasis. - ToneInfo -) - -// toneNames is the config-facing spelling of each tone, in declaration order. -var toneNames = [...]string{"neutral", "primary", "accent", "success", "warning", "danger", "info"} - -// String returns the tone's config-facing name. -func (t Tone) String() string { - if int(t) < 0 || int(t) >= len(toneNames) { - return toneNames[ToneNeutral] - } - - return toneNames[t] -} - -// ToneByName resolves a configured tone name. An unknown name resolves to -// ToneNeutral with ok false, so a caller can report the bad value rather than -// failing startup over a cosmetic setting. -func ToneByName(name string) (Tone, bool) { - for i, n := range toneNames { - if n == name { - return Tone(i), true - } - } - - return ToneNeutral, false -} - -// Tone resolves a role to this theme's color for it. -func (t Theme) Tone(tone Tone) color.Color { - switch tone { - case TonePrimary: - return t.C.Primary - case ToneAccent: - return t.C.Accent - case ToneSuccess: - return t.C.Success - case ToneWarning: - return t.C.Warning - case ToneDanger: - return t.C.Danger - case ToneInfo: - return t.C.Info - case ToneNeutral: - return t.C.TextMuted - default: - return t.C.TextMuted - } -} - -// Mix blends two colors, with t running from 0 (all a) to 1 (all b). -// -// This is how the theme derives a shade instead of hardcoding one: a muted -// variant is the token mixed toward Background, and a gauge's intermediate hue -// is one ramp stop mixed toward the next. Both stay expressed in terms of -// tokens, so a custom theme recolors them along with everything else — which is -// the whole reason the palette layer exists. -// -// The blend happens in Oklab rather than in sRGB. Interpolating sRGB channels -// directly is the obvious implementation and it looks wrong: the path from -// green to red passes through a muddy olive, the midpoint is noticeably darker -// than either end, and a gradient built from it bands visibly because equal -// numeric steps are not equal perceptual steps. Oklab is designed so that equal -// distances look equal, which is exactly what a gradient needs. -func Mix(a, b color.Color, t float64) color.Color { - t = math.Min(math.Max(t, 0), 1) - - al, aa, ab2 := toOklab(a) - bl, ba, bb2 := toOklab(b) - - return fromOklab( - al+(bl-al)*t, - aa+(ba-aa)*t, - ab2+(bb2-ab2)*t, - ) -} - -// srgbToLinear removes the sRGB transfer function. -func srgbToLinear(c float64) float64 { - if c <= 0.04045 { - return c / 12.92 - } - - return math.Pow((c+0.055)/1.055, 2.4) -} - -// linearToSrgb reapplies it. -func linearToSrgb(c float64) float64 { - if c <= 0.0031308 { - return c * 12.92 - } - - return 1.055*math.Pow(c, 1/2.4) - 0.055 -} - -// toOklab converts a color into Oklab, the perceptually uniform space this -// theme interpolates in. The matrices are Björn Ottosson's published values. -func toOklab(c color.Color) (lightness, greenRed, blueYellow float64) { - r32, g32, b32, _ := c.RGBA() - r := srgbToLinear(float64(r32>>8) / 255) - g := srgbToLinear(float64(g32>>8) / 255) - b := srgbToLinear(float64(b32>>8) / 255) - - l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b) - m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b) - s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b) - - return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s, - 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, - 0.0259040371*l + 0.7827717662*m - 0.8086757660*s -} - -// fromOklab is the inverse, clamped back into displayable sRGB. -func fromOklab(lightness, greenRed, blueYellow float64) color.Color { - l := lightness + 0.3963377774*greenRed + 0.2158037573*blueYellow - m := lightness - 0.1055613458*greenRed - 0.0638541728*blueYellow - s := lightness - 0.0894841775*greenRed - 1.2914855480*blueYellow - - l, m, s = l*l*l, m*m*m, s*s*s - - channel := func(v float64) uint8 { - return uint8(math.Round(math.Min(math.Max(linearToSrgb(v), 0), 1) * 255)) - } - - return color.RGBA{ - R: channel(4.0767416621*l - 3.3077115913*m + 0.2309699292*s), - G: channel(-1.2684380046*l + 2.6097574011*m - 0.3413193965*s), - B: channel(-0.0041960863*l - 0.7034186147*m + 1.7076147010*s), - A: 0xff, - } -} - -// MutedMix is how far a muted variant is blended toward the background. Enough -// to read as "not active" without becoming invisible. -const MutedMix = 0.55 - -// Muted returns a dimmed variant of a color, blended toward this theme's -// background. -func (t Theme) Muted(c color.Color) color.Color { return Mix(c, t.C.Background, MutedMix) } - -// Ramp is an ordered list of tone stops a gauge interpolates across. -// -// It is a list of *roles*, not colors, so configuration can express a ramp as -// `gauge_ramp = ["success", "warning", "danger"]` and the active theme decides -// what those look like. -type Ramp []Tone - -// DefaultGaugeRamp runs green through amber to red — the universally read -// pressure ramp. -var DefaultGaugeRamp = Ramp{ToneSuccess, ToneWarning, ToneDanger} - -// At resolves the ramp at position f, from 0 to 1, interpolating between the -// two stops it falls between. An empty ramp resolves to the muted text token so -// a misconfigured ramp degrades to something visible rather than to nothing. -func (r Ramp) At(t Theme, f float64) color.Color { - switch len(r) { - case 0: - return t.C.TextMuted - case 1: - return t.Tone(r[0]) - } - - f = math.Min(math.Max(f, 0), 1) - - // Position along the ramp in stop-index space; the fractional part is how - // far between this stop and the next. - pos := f * float64(len(r)-1) - i := int(pos) - - if i >= len(r)-1 { - return t.Tone(r[len(r)-1]) - } - - return Mix(t.Tone(r[i]), t.Tone(r[i+1]), pos-float64(i)) -} - -// Spacing scale, in terminal cells. Every pad, gap, and gutter in the shell -// uses one of these rather than a literal, which is what keeps rhythm -// consistent across panes written at different times. -const ( - Space0 = 0 - Space1 = 1 - Space2 = 2 - Space3 = 3 - Space4 = 4 -) - -// Gutter is the breathing room between the screen edge and the outermost pane. -const Gutter = Space1 - -// Border presets. The shell picks from this set rather than calling Lip Gloss -// border constructors at the point of use, so a change of border language is -// one edit here. -var ( - BorderNone = lipgloss.HiddenBorder() - BorderNormal = lipgloss.NormalBorder() - BorderRounded = lipgloss.RoundedBorder() - BorderThick = lipgloss.ThickBorder() -) - -// Theme is a resolved theme: its semantic tokens plus the Lip Gloss styles the -// painter uses, derived from those tokens so the two can never disagree. -type Theme struct { - Name string - C Tokens - // GaugeRamp is the ramp a pressure gauge interpolates across. - GaugeRamp Ramp - - // Text-style tokens, one per TextStyle enum value plus the unset case. - Default lipgloss.Style - Normal lipgloss.Style - Bold lipgloss.Style - Italic lipgloss.Style - Code lipgloss.Style - Dim lipgloss.Style - Error lipgloss.Style - Warning lipgloss.Style - Success lipgloss.Style - - // Chrome roles owned by the shell rather than by the protocol. - CodeBlock lipgloss.Style - Border lipgloss.Style - BorderFocused lipgloss.Style - RegionTitle lipgloss.Style - Action lipgloss.Style - ActionFocused lipgloss.Style - DiffAdd lipgloss.Style - DiffRemove lipgloss.Style - DiffHeader lipgloss.Style - TableHeader lipgloss.Style - Link lipgloss.Style - SubSession lipgloss.Style -} - -// TextStyle resolves a TextNode's optional style pointer to a concrete style. -// A nil pointer means the producer left style unset and gets Default; an -// explicit TEXT_STYLE_NORMAL gets Normal. Any value this build does not -// recognize — including one added to the enum after this shell shipped — falls -// back to Default rather than being dropped, which is what -// docs/specifications/frontend/render-tree.md requires of every frontend. -func (t Theme) TextStyle(style *renderv1.TextStyle) lipgloss.Style { - if style == nil { - return t.Default - } - - switch *style { - case renderv1.TextStyle_TEXT_STYLE_NORMAL: - return t.Normal - case renderv1.TextStyle_TEXT_STYLE_BOLD: - return t.Bold - case renderv1.TextStyle_TEXT_STYLE_ITALIC: - return t.Italic - case renderv1.TextStyle_TEXT_STYLE_CODE: - return t.Code - case renderv1.TextStyle_TEXT_STYLE_DIM: - return t.Dim - case renderv1.TextStyle_TEXT_STYLE_ERROR: - return t.Error - case renderv1.TextStyle_TEXT_STYLE_WARNING: - return t.Warning - case renderv1.TextStyle_TEXT_STYLE_SUCCESS: - return t.Success - case renderv1.TextStyle_TEXT_STYLE_UNSPECIFIED: - return t.Default - default: - return t.Default - } -} - -// DarkPalette is the raw ramp behind Dark. -var DarkPalette = Palette{ - // Near-neutral greys with only a hint of cool. An earlier ramp carried a - // third of its value in blue — #4a5570 is periwinkle, not grey — and at low - // luminance that cast is the first thing the eye picks up, so borders and - // separators read as "dark blue lines" rather than as quiet structure. - // Saturation here stays under roughly a fifth at every step. - Neutral: [10]string{ - "#101216", // 0 app background - "#16181d", // 1 panel - "#1d2026", // 2 element - "#2b2f36", // 3 subtle border - "#3d424b", // 4 border - "#565c67", // 5 divider - "#7b828e", // 6 subtle text - "#a0a7b3", // 7 muted text - "#ced4dd", // 8 text - "#eef1f5", // 9 strong text - }, - Primary: "#7aa2f7", - Accent: "#bb9af7", - Success: "#9ece6a", - Warning: "#e0af68", - Danger: "#f7768e", - Info: "#7dcfff", - OnAccent: "#101216", -} - -// LightPalette is the raw ramp behind Light. -var LightPalette = Palette{ - Neutral: [10]string{ - "#fcfcfd", // 0 app background - "#f4f5f7", // 1 panel - "#eaebee", // 2 element - "#dee0e4", // 3 subtle border - "#c6c9cf", // 4 border - "#9ca0a9", // 5 divider - "#767a84", // 6 subtle text - "#585c66", // 7 muted text - "#2c2f36", // 8 text - "#171a1f", // 9 strong text - }, - Primary: "#2f5ea8", - Accent: "#7048b6", - Success: "#3a6f22", - Warning: "#8a6100", - Danger: "#b02a44", - Info: "#1c6f96", - OnAccent: "#fbfcfe", -} - -// tokens maps a raw palette onto the semantic layer. This is the single place -// that decides which ramp step means "panel" or "muted text", so both built-in -// themes stay structurally identical and only their colors differ. -func tokens(p Palette) Tokens { - n := func(i int) color.Color { return lipgloss.Color(p.Neutral[i]) } - - return Tokens{ - Background: n(0), - BackgroundPanel: n(1), - BackgroundElement: n(2), - - Text: n(8), - TextMuted: n(7), - TextSubtle: n(6), - OnAccent: lipgloss.Color(p.OnAccent), - - BorderSubtle: n(3), - Border: n(4), - Divider: n(5), - BorderActive: lipgloss.Color(p.Primary), - - Primary: lipgloss.Color(p.Primary), - Accent: lipgloss.Color(p.Accent), - Success: lipgloss.Color(p.Success), - Warning: lipgloss.Color(p.Warning), - Danger: lipgloss.Color(p.Danger), - Info: lipgloss.Color(p.Info), - - DiffAdded: lipgloss.Color(p.Success), - DiffRemoved: lipgloss.Color(p.Danger), - DiffContext: n(7), - DiffHunkHeader: n(6), - } -} - -// New assembles a Theme from a raw palette. Both built-in themes route through -// here, so a new derived style is added once and both get it. -// -// Content styles set a foreground and never a background. This is a -// correctness rule, not a preference: Lip Gloss terminates every styled run -// with a full SGR reset, and a reset inside a container clears the container's -// background for everything after it. A text style that set its own background -// therefore paints a band that ends wherever the text ends, leaving the rest of -// the row on the terminal's background — which is precisely the patchwork this -// rule exists to prevent. Surfaces are painted by the container that owns them; -// text only ever contributes color. -// -// The two deliberate exceptions are Action and ActionFocused, which are filled -// controls rather than runs of text: each is a single self-contained run whose -// reset lands at its own end, so it cannot bleed into anything. -func New(name string, p Palette) Theme { - c := tokens(p) - - fg := func(v color.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(v) } - base := fg(c.Text) - - return Theme{ - Name: name, - C: c, - GaugeRamp: DefaultGaugeRamp, - Default: base, - Normal: base, - Bold: base.Bold(true), - Italic: base.Italic(true), - Code: fg(c.Info), - Dim: fg(c.TextSubtle), - Error: fg(c.Danger).Bold(true), - Warning: fg(c.Warning), - Success: fg(c.Success), - - CodeBlock: fg(c.TextMuted), - Border: fg(c.Border), - BorderFocused: fg(c.BorderActive), - RegionTitle: fg(c.TextMuted).Bold(true), - Action: fg(c.Primary), - ActionFocused: lipgloss.NewStyle().Foreground(c.OnAccent).Background(c.Primary).Bold(true), - DiffAdd: fg(c.DiffAdded), - DiffRemove: fg(c.DiffRemoved), - DiffHeader: fg(c.DiffHunkHeader).Bold(true), - TableHeader: fg(c.Text).Bold(true), - Link: fg(c.Info).Underline(true), - SubSession: fg(c.TextMuted).Italic(true), - } -} - -// Dark returns the built-in dark theme. -func Dark() Theme { return New("dark", DarkPalette) } - -// Light returns the built-in light theme. -func Light() Theme { return New("light", LightPalette) } - -// ByName resolves a configured theme name. An unknown name resolves to Dark -// with ok false, so a caller can log the fallback rather than failing startup -// over a cosmetic setting. -func ByName(name string) (Theme, bool) { - switch name { - case "dark", "": - return Dark(), true - case "light": - return Light(), true - default: - return Dark(), false - } -} diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go deleted file mode 100644 index 954269c..0000000 --- a/internal/tui/theme/theme_test.go +++ /dev/null @@ -1,511 +0,0 @@ -package theme_test - -import ( - "image/color" - "math" - "testing" - - "charm.land/lipgloss/v2" - - "github.com/pluggableharness/agent/internal/tui/theme" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" -) - -func TestTextStyleMapsEveryEnumValue(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - // Every enum value must resolve to a style that renders its input. The - // protocol requires a frontend with no visual distinction for a style to - // still render the underlying text rather than dropping it. - for value, name := range renderv1.TextStyle_name { - style := renderv1.TextStyle(value) - got := th.TextStyle(&style).Render("payload") - - if got == "" { - t.Errorf("TextStyle(%s) rendered empty, dropping content", name) - } - } -} - -func TestTextStyleUnsetIsDistinctFromNormal(t *testing.T) { - t.Parallel() - - th := theme.Dark() - th.Normal = th.Normal.Bold(true) - - normal := renderv1.TextStyle_TEXT_STYLE_NORMAL - - unset := th.TextStyle(nil).Render("x") - explicit := th.TextStyle(&normal).Render("x") - - // Unset means "frontend's own default"; an explicit NORMAL is a producer - // deliberately asking for plain styling. They are separate states and the - // theme must keep them separately overridable. - if unset == explicit { - t.Fatalf("unset style and explicit NORMAL resolved identically after overriding Normal") - } -} - -func TestTextStyleUnknownValueFallsBackToDefault(t *testing.T) { - t.Parallel() - - th := theme.Dark() - future := renderv1.TextStyle(9999) - - got := th.TextStyle(&future).Render("from the future") - want := th.Default.Render("from the future") - - if got != want { - t.Fatalf("unknown TextStyle did not fall back to Default\ngot: %q\nwant: %q", got, want) - } -} - -func TestByName(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in string - wantName string - wantOK bool - }{ - {name: "dark", in: "dark", wantName: "dark", wantOK: true}, - {name: "light", in: "light", wantName: "light", wantOK: true}, - {name: "empty defaults to dark", in: "", wantName: "dark", wantOK: true}, - {name: "unknown falls back", in: "solarized", wantName: "dark", wantOK: false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got, ok := theme.ByName(tc.in) - if ok != tc.wantOK { - t.Errorf("ByName(%q) ok = %v, want %v", tc.in, ok, tc.wantOK) - } - - if got.Name != tc.wantName { - t.Errorf("ByName(%q) name = %q, want %q", tc.in, got.Name, tc.wantName) - } - }) - } -} - -func TestBuiltinThemesDifferAndAreNamed(t *testing.T) { - t.Parallel() - - dark, light := theme.Dark(), theme.Light() - - if dark.Name != "dark" || light.Name != "light" { - t.Fatalf("themes misnamed: %q / %q", dark.Name, light.Name) - } - - if dark.Default.Render("x") == light.Default.Render("x") { - t.Fatal("dark and light rendered identically; palettes are not being applied") - } -} - -// Content styles must not set a background. -// -// Lip Gloss terminates every styled run with a full SGR reset, which clears any -// background a container had set. A text style carrying its own background -// therefore paints a band that stops where the text stops, leaving the rest of -// the row on the terminal's background — a visible patchwork across every pane. -// Surfaces are painted by the container that owns them; text contributes color -// only. -func TestContentStylesSetNoBackground(t *testing.T) { - t.Parallel() - - // An unset background is a zero color value, not nil, so the comparison is - // against what a fresh style reports rather than against nil. - unset := lipgloss.NewStyle().GetBackground() - - for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { - styles := map[string]lipgloss.Style{ - "Default": th.Default, - "Normal": th.Normal, - "Bold": th.Bold, - "Italic": th.Italic, - "Code": th.Code, - "Dim": th.Dim, - "Error": th.Error, - "Warning": th.Warning, - "Success": th.Success, - "CodeBlock": th.CodeBlock, - "Border": th.Border, - "RegionTitle": th.RegionTitle, - "Action": th.Action, - "DiffAdd": th.DiffAdd, - "DiffRemove": th.DiffRemove, - "DiffHeader": th.DiffHeader, - "TableHeader": th.TableHeader, - "Link": th.Link, - "SubSession": th.SubSession, - } - - for name, s := range styles { - if bg := s.GetBackground(); bg != unset { - t.Errorf("%s theme: style %s sets a background (%v); it would band across its row", th.Name, name, bg) - } - } - } -} - -// The one deliberate exception: a focused action is a filled control, a single -// self-contained run whose reset lands at its own end. -func TestFocusedActionIsDeliberatelyFilled(t *testing.T) { - t.Parallel() - - if theme.Dark().ActionFocused.GetBackground() == lipgloss.NewStyle().GetBackground() { - t.Fatal("ActionFocused lost its fill; a focused button needs to be visibly inverted") - } -} - -func TestToneRoundTripsByName(t *testing.T) { - t.Parallel() - - for _, name := range []string{"neutral", "primary", "accent", "success", "warning", "danger", "info"} { - tone, ok := theme.ToneByName(name) - if !ok { - t.Errorf("ToneByName(%q) not found", name) - - continue - } - - if got := tone.String(); got != name { - t.Errorf("tone %q round-tripped to %q", name, got) - } - } -} - -func TestUnknownToneFallsBack(t *testing.T) { - t.Parallel() - - tone, ok := theme.ToneByName("chartreuse") - if ok { - t.Fatal("ToneByName accepted an unknown name") - } - - if tone != theme.ToneNeutral { - t.Fatalf("unknown tone = %v, want ToneNeutral", tone) - } - - if got := theme.Tone(99).String(); got != "neutral" { - t.Fatalf("out-of-range tone name = %q, want neutral", got) - } -} - -// Every tone must resolve to a color, including an out-of-range value. -func TestToneResolvesToDistinctColors(t *testing.T) { - t.Parallel() - - th := theme.Dark() - // Keyed on the channels themselves rather than a packed integer. RGBA - // returns 16-bit channels, so packing them as r<<16|g<<8|b overlaps green - // into red's bits and blue into green's — two distinct tones could collide - // on one key and this test would report a duplicate that does not exist, - // or miss one that does. - seen := map[[3]uint32]string{} - - tones := map[theme.Tone]string{ - theme.TonePrimary: "primary", - theme.ToneAccent: "accent", - theme.ToneSuccess: "success", - theme.ToneWarning: "warning", - theme.ToneDanger: "danger", - theme.ToneInfo: "info", - } - - for tone, name := range tones { - r, g, b, _ := th.Tone(tone).RGBA() - key := [3]uint32{r, g, b} - - if other, dup := seen[key]; dup { - t.Errorf("tones %s and %s resolve to the same color", name, other) - } - - seen[key] = name - } - - if th.Tone(theme.Tone(99)) == nil { - t.Fatal("out-of-range tone resolved to nil") - } -} - -func TestMixHitsItsEndpointsAndClamps(t *testing.T) { - t.Parallel() - - black := color.RGBA{A: 0xff} - white := color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} - - tests := []struct { - name string - at float64 - want uint8 - }{ - {"all a", 0, 0x00}, - {"all b", 1, 0xff}, - {"below range clamps", -2, 0x00}, - {"above range clamps", 4, 0xff}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - r, _, _, _ := theme.Mix(black, white, tc.at).RGBA() - if got := uint8(r >> 8); got != tc.want { - t.Fatalf("Mix at %v = %#02x, want %#02x", tc.at, got, tc.want) - } - }) - } -} - -// Blending happens in a perceptually uniform space, so equal steps look equal. -// A plain sRGB channel average would put the midpoint of black and white at -// 0x80, which reads far lighter than half — the whole reason gradients built -// that way band and look muddy. -func TestMixIsPerceptuallyUniform(t *testing.T) { - t.Parallel() - - black := color.RGBA{A: 0xff} - white := color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} - - mid, _, _, _ := theme.Mix(black, white, 0.5).RGBA() - if got := uint8(mid >> 8); got >= 0x80 { - t.Errorf("perceptual midpoint = %#02x, expected well below the sRGB average 0x80", got) - } - - // Lightness still rises monotonically across the whole range. - prev := -1.0 - - for i := range 21 { - r, _, _, _ := theme.Mix(black, white, float64(i)/20).RGBA() - - if got := float64(r >> 8); got < prev { - t.Fatalf("step %d went backwards: %v after %v", i, got, prev) - } else { - prev = got - } - } -} - -// A gradient across the default ramp must not dip in lightness partway, which -// is what makes an sRGB green-to-red blend look muddy in the middle. -func TestRampGradientDoesNotDipInTheMiddle(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - var minL, endL float64 - - minL = 1 - - for i := range 41 { - l, _, _ := theme.ExportToOklab(theme.DefaultGaugeRamp.At(th, float64(i)/40)) - minL = math.Min(minL, l) - - if i == 40 { - endL = l - } - } - - startL, _, _ := theme.ExportToOklab(theme.DefaultGaugeRamp.At(th, 0)) - - // The dimmest point on the ramp should be one of its ends, not a sag - // somewhere in between. - if minL < math.Min(startL, endL)-0.02 { - t.Errorf("ramp dips to lightness %.3f, below both ends (%.3f, %.3f)", minL, startL, endL) - } -} - -// A muted color must sit between its source and the background — dimmer, but -// still the same hue rather than a new one. -func TestMutedBlendsTowardBackground(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - got := th.Muted(th.C.Success) - if got == th.C.Success { - t.Fatal("Muted returned the original color") - } - - if want := theme.Mix(th.C.Success, th.C.Background, theme.MutedMix); got != want { - t.Fatalf("Muted = %v, want %v", got, want) - } - - // Dimming a dark theme's color moves it toward the dark background, so the - // muted variant is no brighter than the original. - sr, sg, sb, _ := th.C.Success.RGBA() - mr, mg, mb, _ := got.RGBA() - - if mr+mg+mb > sr+sg+sb { - t.Error("muted variant is brighter than its source on a dark theme") - } -} - -func TestRampResolvesStopsAndInterpolates(t *testing.T) { - t.Parallel() - - th := theme.Dark() - ramp := theme.DefaultGaugeRamp - - // Endpoints and the midpoint land exactly on their stops. - for _, tc := range []struct { - at float64 - want color.Color - name string - }{ - {0, th.C.Success, "start"}, - {0.5, th.C.Warning, "middle"}, - {1, th.C.Danger, "end"}, - {-1, th.C.Success, "below range clamps"}, - {2, th.C.Danger, "above range clamps"}, - } { - if got := ramp.At(th, tc.at); got != tc.want { - t.Errorf("%s: At(%v) = %v, want %v", tc.name, tc.at, got, tc.want) - } - } - - // Between stops it blends rather than snapping. - mid := ramp.At(th, 0.25) - if mid == th.C.Success || mid == th.C.Warning { - t.Error("At(0.25) snapped to a stop instead of interpolating") - } -} - -// A degenerate ramp must still resolve to something visible rather than -// failing or returning nil. -func TestDegenerateRampsDegradeGracefully(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - if got := (theme.Ramp{}).At(th, 0.5); got != th.C.TextMuted { - t.Errorf("empty ramp = %v, want the muted text token", got) - } - - single := theme.Ramp{theme.ToneInfo} - for _, at := range []float64{0, 0.5, 1} { - if got := single.At(th, at); got != th.C.Info { - t.Errorf("single-stop ramp at %v = %v, want info", at, got) - } - } -} - -func TestThemesCarryTheDefaultGaugeRamp(t *testing.T) { - t.Parallel() - - for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { - if len(th.GaugeRamp) != len(theme.DefaultGaugeRamp) { - t.Errorf("%s theme has no default gauge ramp", th.Name) - } - } -} - -// relLuminance is the WCAG relative luminance of a color. -func relLuminance(c color.Color) float64 { - r, g, b, _ := c.RGBA() - - channel := func(v uint32) float64 { - x := float64(v>>8) / 255 - if x <= 0.03928 { - return x / 12.92 - } - - return math.Pow((x+0.055)/1.055, 2.4) - } - - return 0.2126*channel(r) + 0.7152*channel(g) + 0.0722*channel(b) -} - -func contrastRatio(a, b color.Color) float64 { - la, lb := relLuminance(a)+0.05, relLuminance(b)+0.05 - - return math.Max(la, lb) / math.Min(la, lb) -} - -// channelSpread is the distance between a color's strongest and weakest -// channel, in 0..255. -// -// This is the right measure for "does this grey look like a hue", and HSV -// saturation is not: near black a six-point spread is a quarter of the maximum -// channel yet completely imperceptible, so saturation flags colors that look -// perfectly neutral while missing nothing that matters. Absolute spread tracks -// what the eye actually notices. -func channelSpread(c color.Color) float64 { - r, g, b, _ := c.RGBA() - hi := math.Max(float64(r>>8), math.Max(float64(g>>8), float64(b>>8))) - lo := math.Min(float64(r>>8), math.Min(float64(g>>8), float64(b>>8))) - - return hi - lo -} - -// The neutral ramp must actually be neutral. An earlier ramp carried a third of -// its value in blue, and at low luminance that cast is the first thing the eye -// picks up — borders and separators stopped reading as quiet structure and -// started reading as dark blue lines. -func TestNeutralsAreNearlyGrey(t *testing.T) { - t.Parallel() - - // #4a5570 — the periwinkle this rule exists to prevent — spreads 38. - const maxSpread = 24.0 - - for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { - neutrals := map[string]color.Color{ - "Background": th.C.Background, - "BackgroundPanel": th.C.BackgroundPanel, - "BackgroundElement": th.C.BackgroundElement, - "BorderSubtle": th.C.BorderSubtle, - "Border": th.C.Border, - "Divider": th.C.Divider, - "TextSubtle": th.C.TextSubtle, - "TextMuted": th.C.TextMuted, - "Text": th.C.Text, - } - - for name, c := range neutrals { - if got := channelSpread(c); got > maxSpread { - t.Errorf("%s theme: %s spreads %.0f across channels, want at most %.0f — it will read as a hue, not a grey", - th.Name, name, got, maxSpread) - } - } - } -} - -// A separator has to be visible as a line. Sharing the near-background subtle -// border token left it effectively invisible. -func TestDividerIsVisibleAgainstTheBackground(t *testing.T) { - t.Parallel() - - for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { - got := contrastRatio(th.C.Divider, th.C.Background) - if got < 2.0 { - t.Errorf("%s theme: divider/background contrast %.2f, too low to read as a line", th.Name, got) - } - - // But it must stay subordinate to the dimmest text, or it competes - // with the content it is separating. - if contrastRatio(th.C.Divider, th.C.Background) >= contrastRatio(th.C.TextSubtle, th.C.Background) { - t.Errorf("%s theme: divider is as prominent as subtle text", th.Name) - } - } -} - -// Text has to clear the usual legibility bar against the surface it sits on. -func TestTextContrastIsLegible(t *testing.T) { - t.Parallel() - - for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { - if got := contrastRatio(th.C.Text, th.C.Background); got < 7 { - t.Errorf("%s theme: text/background contrast %.2f, want at least 7", th.Name, got) - } - - if got := contrastRatio(th.C.TextSubtle, th.C.Background); got < 3 { - t.Errorf("%s theme: subtle text/background contrast %.2f, want at least 3", th.Name, got) - } - } -} diff --git a/internal/tui/ui/CLAUDE.md b/internal/tui/ui/CLAUDE.md deleted file mode 100644 index 1181c08..0000000 --- a/internal/tui/ui/CLAUDE.md +++ /dev/null @@ -1,33 +0,0 @@ -# internal/tui/ui — agent notes - -## No literals at call sites - -The point of this package is that padding comes from `theme.Space*` and colors from `theme.Tokens`. A `lipgloss.Color("#7aa2f7")` or a bare `Px(3)` anywhere in `shell` or `paint` defeats it. If a needed value is missing, add it to the scale in `theme` rather than inlining it here. - -## Components must return exact dimensions - -`Panel.Render` returns exactly `Height` lines of exactly `Width` cells, and `StatusLine.Render` exactly one line of `Width` cells. Callers stack them without measuring, so a component that returns a ragged block silently shifts everything below it. Tests assert this across a range of sizes including degenerate ones. - -The subtle case: `strings.Split("", "\n")` returns one empty element, not zero. A panel with no interior (`Height == 2`) must skip its body loop outright rather than trusting an empty `FitBlock` to produce no rows. - -## `ExpandTabs` is a correctness fix, not formatting - -`lipgloss.Width("\t")` is 0 but a terminal advances to the next tab stop. Leaving a tab in content makes the painted row wider than the measured one, which overflows the pane and corrupts every row to its right. Expansion happens in `Fit` and at every text-bearing leaf in `paint`. Do not "simplify" it away. - -## Status lines drop from the right, and never advertise empty fields - -`StatusLine` lays segments left to right and drops from the right when they will not fit, so segment *order is a priority ranking*. A segment with no value and no `Fill` is omitted entirely — a status bar showing fields it has no data for teaches the operator to stop reading it. - -Exactly one segment per line should set `Fill`; it receives whatever width is left after every fixed segment is placed, which is what makes the line reflow on resize. `MinWidth` must account for the segment's *whole* rendering — label and trailing text included — not just its bar, or the fill callback gets a width too small to use. A filling segment should also cap itself: handed the slack of a very wide terminal, an uncapped meter becomes a rule with a number marooned at the far end. - -`Right` segments are fitted **after** the left group and only survive if the left group fits beside them whole. Reserving the right group's width first lets a secondary field evict a primary one, which inverts the ranking — there is a test named for exactly this. - -`Meter` uses the same heavy-against-light stroke as everything else that shows fill. Do not make the boundary color-only: it is the channel that survives a monochrome terminal and does not depend on telling green from red. - -## `Overlay` exists because Lip Gloss cannot do this - -`Canvas.Compose` draws every layer at full canvas bounds and `Layer.Draw` ignores its own X/Y, so composing a pane over a frame erases the frame instead of sitting on it. Row-wise ANSI splicing is the working approach. `Overlay` also clips a too-wide block rather than widening the row, because an over-wide row wraps and shifts the whole frame. - -## This package is pure domain - -No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. It takes tokens and strings and returns strings. diff --git a/internal/tui/ui/README.md b/internal/tui/ui/README.md deleted file mode 100644 index eac82e6..0000000 --- a/internal/tui/ui/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# internal/tui/ui - -The shell's utility and component layer — the terminal equivalent of a utility-first CSS framework. - -## Why it exists - -Without a layer like this, every pane picks its own padding, its own border color, and its own idea of what "muted" means. The result is a collection of individually reasonable choices that does not look like one system. This package applies the three rules that make utility-first styling work: - -- **Values come from a scale, never a literal.** Padding is `theme.Space1`..`Space4`; colors are `theme.Tokens` fields. A pane that wants more padding picks the next step; it does not invent `3`. -- **Utilities compose.** `Style` is a chainable builder where each method sets exactly one property, so a pane's appearance reads as a sentence where it is used rather than hiding in a named style somewhere else. -- **Components are compositions of utilities, not escapes from them.** `Panel` and `StatusLine` are built from the same builder any caller uses. - -## What lives here - -| Symbol | Role | -|---|---| -| `Style` | The chainable utility builder: `Fg`, `Bg`, `P`/`Px`/`Py`, `W`/`H`/`MaxW`, `Bold`, `Italic`, `Underline`, `Align` | -| `Panel` | A titled, bordered surface — content panes, header and footer alike. Returns exactly `Height` lines of exactly `Width` cells, with an optional `Caption` in the bottom border | -| `Badge` | A small filled label for status pills | -| `StatusLine` | A full-width row of labelled segments, one of which absorbs the slack | -| `Meter` | An inline fill bar, heavy against light stroke | -| `GradientMeter` | A fill bar whose color runs across a ramp along its length | -| `Fields` | A label/value list with values aligned into a column | -| `Fit` / `FitBlock` | Force a line or block to exact cell dimensions, ANSI-aware | -| `Clip` / `ClipLeft` | Truncate a line from either end; clip left to keep a path's tail | -| `Overlay` | Splice a block on top of a frame, preserving what surrounds it | -| `ExpandTabs` | Replace tabs with spaces before anything measures or wraps | - -## Two cell-accuracy rules worth knowing - -**Every component covers every cell it claims.** An uncovered cell shows the terminal's own background and breaks the illusion of a full-screen application, so `Panel` and `StatusLine` pad out to their full size rather than returning ragged lines. - -**Tabs are expanded on the way in.** A tab measures as zero cells but a terminal advances to the next tab stop when it draws one, so unexpanded tabs paint wider than they measure — overflowing the pane and corrupting every row to the right. Producer content routinely contains tabs (Go source, diffs). - -## Related - -- `internal/tui/theme` — the token set and scales this package consumes. -- [`docs/first-party/frontends/tui.md`](../../../docs/first-party/frontends/tui.md) — the design system this implements. diff --git a/internal/tui/ui/doc.go b/internal/tui/ui/doc.go deleted file mode 100644 index 021b0bc..0000000 --- a/internal/tui/ui/doc.go +++ /dev/null @@ -1,21 +0,0 @@ -// Package ui is the shell's utility and component layer — the terminal -// equivalent of a utility-first CSS framework. -// -// It exists for the reason Tailwind exists: without it, every pane picks its -// own padding, its own border color, and its own idea of what "muted" means, -// and the interface drifts into a collection of individually reasonable -// choices that do not look like one system. The rules here are the same ones -// that make that approach work: -// -// - Values come from a scale, never from a literal. Padding is -// theme.Space1..Space4; colors are theme.Tokens fields. A call site that -// wants "a bit more padding" picks the next step, it does not invent 3. -// - Utilities compose. Style is a chainable builder where each method sets -// exactly one property, so a pane's appearance reads as a sentence at the -// point of use rather than hiding in a named style elsewhere. -// - Components are compositions of utilities, not escapes from them. -// Panel and StatusLine are built from the same Style builder any caller uses. -// -// Everything here is pure: it takes tokens and strings and returns strings. -// No terminal, no I/O, no global state. -package ui diff --git a/internal/tui/ui/fields.go b/internal/tui/ui/fields.go deleted file mode 100644 index 45c91ec..0000000 --- a/internal/tui/ui/fields.go +++ /dev/null @@ -1,77 +0,0 @@ -package ui - -import ( - "image/color" - "strings" - - "charm.land/lipgloss/v2" - - "github.com/pluggableharness/agent/internal/tui/theme" -) - -// Field is one label/value pair in a vertical list. -type Field struct { - Label string - Value string - // Tone colors the value. Nil uses ordinary text. - Tone color.Color - // Wide renders the value on its own line beneath the label, for values too - // long to sit beside one in a narrow panel. - Wide bool -} - -// Fields renders a label/value list with the labels aligned into a column. -// -// This is the shape almost every side panel wants, and aligning the values into -// a common column is what makes such a panel scannable — the eye tracks one -// vertical edge instead of hunting for where each value starts. Fields with no -// value are dropped, on the same principle as a status segment: a panel should -// not advertise what it cannot fill. -func Fields(t theme.Theme, fields []Field, width int) string { - present := make([]Field, 0, len(fields)) - - for _, f := range fields { - if f.Value != "" { - present = append(present, f) - } - } - - if len(present) == 0 || width <= 0 { - return "" - } - - labelWidth := 0 - for _, f := range present { - if !f.Wide { - labelWidth = max(labelWidth, lipgloss.Width(f.Label)) - } - } - - // A label column wider than half the panel is not a column, it is a wall. - // Past that, everything wraps to its own line instead. - stacked := labelWidth+2 > width/2 - - lines := make([]string, 0, len(present)) - for _, f := range present { - lines = append(lines, renderField(t, f, width, labelWidth, stacked)) - } - - return strings.Join(lines, "\n") -} - -func renderField(t theme.Theme, f Field, width, labelWidth int, stacked bool) string { - tone := t.C.Text - if f.Tone != nil { - tone = f.Tone - } - - label := New().Fg(t.C.TextSubtle).Render(f.Label) - - if stacked || f.Wide { - return label + "\n" + New().Fg(tone).Render(Clip(f.Value, width)) - } - - pad := strings.Repeat(" ", max(labelWidth-lipgloss.Width(f.Label), 0)+2) - - return label + pad + New().Fg(tone).Render(Clip(f.Value, max(width-labelWidth-2, 1))) -} diff --git a/internal/tui/ui/panel.go b/internal/tui/ui/panel.go deleted file mode 100644 index 1d5139d..0000000 --- a/internal/tui/ui/panel.go +++ /dev/null @@ -1,179 +0,0 @@ -package ui - -import ( - "image/color" - "strings" - - "charm.land/lipgloss/v2" - - "github.com/pluggableharness/agent/internal/tui/theme" -) - -const ( - // minPanelWidth is the narrowest a panel can be and still have an interior: - // two border columns plus one content column. - minPanelWidth = 3 - // panelChromeRows is the two rows a panel spends on its own border. - panelChromeRows = 2 -) - -// Panel is a titled, bordered surface. -// -// The title sits in the top border rather than on its own row, which buys back -// a line of content per pane and is what keeps a stack of small side panels -// affordable. A focused panel is distinguished by border color alone — moving -// the border weight instead would reflow the layout on every focus change. -type Panel struct { - Title string - Body string - // Caption is optional text embedded in the *bottom* border, against the - // right corner. - // - // It is the counterweight to Title. A panel often has two things to say - // about itself — what it is, and how it is currently configured — and - // running both along the top border makes one long string in which neither - // is findable. Splitting them across the diagonal gives each a corner, so - // the eye learns where to look for which. Like the title it costs no - // interior row. - Caption string - // Width and Height are the panel's outer dimensions, borders included. - Width int - Height int - // Focused draws the border in the active color. - Focused bool - // Accent overrides the title color. Nil uses the muted text token. - Accent color.Color - // CaptionAccent overrides the caption color. Nil uses the subtle text - // token, which is dimmer than the title's default: a caption is reference - // detail, and it should read as chrome rather than compete with the title. - CaptionAccent color.Color - // Border overrides the border language. The zero value uses rounded. - Border *lipgloss.Border -} - -// Render draws the panel and returns exactly Height lines of exactly Width -// cells, so a caller can place it without measuring. -func (p Panel) Render(t theme.Theme) string { - if p.Width < minPanelWidth || p.Height < 2 { - // Too small to frame: still cover every cell so the region is painted - // rather than left showing whatever was there before. - return FitBlock(p.Body, max(p.Width, 0), max(p.Height, 0), New().Fg(t.C.Text)) - } - - b := theme.BorderRounded - if p.Border != nil { - b = *p.Border - } - - edge := t.C.Border - if p.Focused { - edge = t.C.BorderActive - } - - // Borders and interior carry no background of their own: the application - // surface is set once on the Bubble Tea View, and a container that filled - // its own background would only stay filled until the first styled run - // inside it emitted its terminating reset. - borderStyle := New().Fg(edge) - inner := p.Width - 2 - bodyHeight := p.Height - panelChromeRows - - rows := make([]string, 0, p.Height) - rows = append(rows, p.top(t, b, borderStyle, inner)) - - bodyStyle := New().Fg(t.C.Text) - contentWidth := max(inner-2*theme.Space1, 0) - pad := strings.Repeat(" ", theme.Space1) - - // A two-row panel is all border and has no interior. Splitting an empty - // block would still yield one line, so the body is skipped outright rather - // than trusted to produce none. - if bodyHeight > 0 { - for line := range strings.SplitSeq(FitBlock(p.Body, contentWidth, bodyHeight, bodyStyle), "\n") { - rows = append(rows, - borderStyle.Render(b.Left)+pad+line+pad+borderStyle.Render(b.Right)) - } - } - - rows = append(rows, p.bottom(t, b, borderStyle, inner)) - - return strings.Join(rows, "\n") -} - -// bottom renders the closing border with the caption embedded against the -// right corner, mirroring how top embeds the title against the left. -func (p Panel) bottom(t theme.Theme, b lipgloss.Border, borderStyle Style, inner int) string { - label := p.captionLabel(inner) - if label == "" { - return borderStyle.Render(b.BottomLeft + strings.Repeat(b.Bottom, inner) + b.BottomRight) - } - - accent := t.C.TextSubtle - if p.CaptionAccent != nil { - accent = p.CaptionAccent - } - - rest := max(inner-1-lipgloss.Width(label), 0) - - return borderStyle.Render(b.BottomLeft+strings.Repeat(b.Bottom, rest)) + - New().Fg(accent).Render(label) + - borderStyle.Render(b.Bottom+b.BottomRight) -} - -// captionLabel is the caption as it appears in the bottom border, padded. -// -// Unlike the title it is never clipped. A truncated model name ("claude-op…") -// is worse than no model name, because the operator cannot tell which model it -// abbreviates — and unlike a title, the caption is not what identifies the -// panel, so losing it costs nothing. It renders whole or not at all, the same -// way a status segment with no room is dropped rather than shortened. -func (p Panel) captionLabel(inner int) string { - if p.Caption == "" { - return "" - } - - label := " " + p.Caption + " " - - // Leave at least one border cell to the left of the label, so a caption - // that only just fits still reads as sitting in a border rather than - // having replaced it. - if lipgloss.Width(label) > inner-2 { - return "" - } - - return label -} - -// top builds the top border with the title embedded in it. -func (p Panel) top(t theme.Theme, b lipgloss.Border, borderStyle Style, inner int) string { - if p.Title == "" || inner < 4 { - return borderStyle.Render(b.TopLeft + strings.Repeat(b.Top, inner) + b.TopRight) - } - - accent := t.C.TextMuted - if p.Accent != nil { - accent = p.Accent - } - - label := p.titleLabel(inner) - titleStyle := New().Fg(accent).Bold() - rest := max(inner-1-lipgloss.Width(label), 0) - - return borderStyle.Render(b.TopLeft+b.Top) + - titleStyle.Render(label) + - borderStyle.Render(strings.Repeat(b.Top, rest)+b.TopRight) -} - -// titleLabel is the title as it appears in the top border, padded and clipped. -func (p Panel) titleLabel(inner int) string { - if p.Title == "" || inner < 4 { - return "" - } - - return " " + Fit(p.Title, min(lipgloss.Width(p.Title), inner-4)) + " " -} - -// Badge is a small filled label used for status pills in bars. -func Badge(t theme.Theme, text string, fg color.Color) string { - return New().Fg(t.C.OnAccent).Bg(fg).Bold().Px(theme.Space1).Render(text) -} diff --git a/internal/tui/ui/status.go b/internal/tui/ui/status.go deleted file mode 100644 index 4254240..0000000 --- a/internal/tui/ui/status.go +++ /dev/null @@ -1,304 +0,0 @@ -package ui - -import ( - "image/color" - "math" - "strings" - - "charm.land/lipgloss/v2" - - "github.com/pluggableharness/agent/internal/tui/theme" -) - -// SegmentSeparator divides adjacent status segments. -// -// Two cells either side rather than one: a status line packs many short -// label/value pairs, and with tight separators the eye cannot find the field -// boundaries. The extra breathing room is what makes it scannable. -const SegmentSeparator = " │ " - -// Segment is one field in a status line. -// -// A segment is a label and a value rather than bare text, because a status bar -// that only shows values is unreadable the first time and a bar that spells -// everything out is too wide. The label is dim and the value is not, so the eye -// lands on what changed. -type Segment struct { - // Label names the field, e.g. "model". Optional. - Label string - // Value is the field's current reading. A segment with no value and no - // Fill is dropped: a status bar should not advertise fields it has no data - // for. - Value string - // Tone colors the value. Nil uses the theme's ordinary text. - Tone color.Color - // Fill, when set, renders the segment at whatever width is left over after - // every fixed segment is placed. Exactly one segment per line should set - // it; the first one wins. - Fill func(width int) string - // MinWidth is the least a filling segment may be squeezed to before the - // line gives up on it. - MinWidth int -} - -// empty reports whether this segment has nothing to show. -func (s Segment) empty() bool { return s.Value == "" && s.Fill == nil } - -// text renders a fixed segment. -func (s Segment) text(t theme.Theme) string { - tone := t.C.Text - if s.Tone != nil { - tone = s.Tone - } - - value := New().Fg(tone).Render(s.Value) - if s.Label == "" { - return value - } - - return New().Fg(t.C.TextSubtle).Render(s.Label+" ") + value -} - -// StatusLine is one row of segments spanning the full width. -// -// Segments are laid out left to right, separated by a divider, with one -// optional segment absorbing the slack so the line always spans its width and -// reflows as the terminal resizes. When the line cannot fit, segments are -// dropped from the right — the leftmost fields are the ones chosen to matter -// most, so they are the ones that survive. -type StatusLine struct { - // Segments are laid out from the left edge. - Segments []Segment - // Right are pinned to the right edge, so the line spans its full width - // instead of leaving a wide terminal packed to one side. They are dropped - // before the left group when space runs out — the left is the ranked side. - Right []Segment - Width int - // Flush drops the line's own one-cell inset, for a line rendered inside a - // container that already pads it. Without this a status line inside a panel - // sits one column right of everything else, since it adds its inset on top - // of the panel's padding. - Flush bool -} - -// Render draws the line as exactly Width cells. -func (l StatusLine) Render(t theme.Theme) string { - segments := make([]Segment, 0, len(l.Segments)) - - for _, s := range l.Segments { - if !s.empty() { - segments = append(segments, s) - } - } - - if l.Width <= 0 { - return "" - } - - sep := New().Fg(t.C.Divider).Render(SegmentSeparator) - sepWidth := lipgloss.Width(SegmentSeparator) - - // One cell of inset at each edge keeps text off the boundary, unless a - // container has already provided it. - pad := theme.Space1 - if l.Flush { - pad = theme.Space0 - } - - edge := strings.Repeat(" ", pad) - avail := l.Width - 2*pad - - // A line with nothing on the left still renders its right group: the two - // groups are independent, and an empty left is a legitimate state rather - // than a reason to discard the other half of the line. - if len(segments) == 0 { - right := renderGroup(t, presentSegments(l.Right), sep) - gap := max(avail-lipgloss.Width(right), 0) - - return New().Render(edge + strings.Repeat(" ", gap) + right + edge) - } - - fixed, flexIndex := renderFixed(t, segments) - - // The right group yields one segment at a time rather than all at once. - // - // Dropping it wholesale makes the line lurch on resize: a filling segment - // suddenly gains the entire right group's width, which can take it from - // too-small-to-draw to enormous across a single column of terminal width. - // Shedding the rightmost field first keeps each step small. - rightSegs := presentSegments(l.Right) - rendered, right := fitBothGroups(t, fixed, flexIndex, segments, rightSegs, sep, avail, sepWidth) - - left := strings.Join(rendered, sep) - gap := max(avail-lipgloss.Width(left)-lipgloss.Width(right), 0) - - // When a filling segment has taken every spare cell, the space between the - // two groups is exactly one separator wide — because that is what was - // reserved for it. Draw the separator there. Leaving it blank is what - // produced a conspicuous hole between the last left field and the first - // right one, with no divider to explain it. - // - // Without a filling segment the gap is genuine slack pushing the right - // group to the edge, and a divider stranded in the middle of it would only - // look lost. - if flexIndex >= 0 && right != "" && gap == sepWidth { - return New().Render(edge + left + sep + right + edge) - } - - return New().Render(edge + left + strings.Repeat(" ", gap) + right + edge) -} - -// presentSegments drops the segments with nothing to show. -func presentSegments(in []Segment) []Segment { - out := make([]Segment, 0, len(in)) - - for _, s := range in { - if !s.empty() { - out = append(out, s) - } - } - - return out -} - -// fitBothGroups finds the largest right group the left group still fits beside, -// shedding the rightmost field first. -func fitBothGroups(t theme.Theme, fixed []string, flexIndex int, segments, rightSegs []Segment, sep string, avail, sepWidth int) ([]string, string) { - for keep := len(rightSegs); keep > 0; keep-- { - right := renderGroup(t, rightSegs[:keep], sep) - - rendered := fit(fixed, flexIndex, segments, avail-lipgloss.Width(right)-sepWidth, sepWidth) - if len(rendered) == len(segments) { - return rendered, right - } - } - - return fit(fixed, flexIndex, segments, avail, sepWidth), "" -} - -// renderGroup renders a run of segments joined by the separator. -func renderGroup(t theme.Theme, segs []Segment, sep string) string { - parts := make([]string, 0, len(segs)) - - for _, s := range segs { - parts = append(parts, s.text(t)) - } - - return strings.Join(parts, sep) -} - -// renderFixed renders every non-filling segment and reports which segment, if -// any, absorbs the slack. -func renderFixed(t theme.Theme, segments []Segment) ([]string, int) { - out := make([]string, len(segments)) - flexIndex := -1 - - for i, s := range segments { - if s.Fill != nil && flexIndex < 0 { - flexIndex = i - - continue - } - - out[i] = s.text(t) - } - - return out, flexIndex -} - -// fit drops segments from the right until the line fits, then hands whatever -// space is left to the filling segment. -func fit(rendered []string, flexIndex int, segments []Segment, avail, sepWidth int) []string { - keep := len(rendered) - - width := func(n int) int { - total := 0 - for i := range n { - if i != flexIndex { - total += lipgloss.Width(rendered[i]) - } - } - - if n > 1 { - total += (n - 1) * sepWidth - } - - if flexIndex >= 0 && flexIndex < n { - total += segments[flexIndex].MinWidth - } - - return total - } - - for keep > 0 && width(keep) > avail { - keep-- - } - - out := rendered[:keep] - - if flexIndex >= 0 && flexIndex < keep { - slack := avail - width(keep) + segments[flexIndex].MinWidth - out[flexIndex] = segments[flexIndex].Fill(max(slack, 0)) - } - - return out -} - -// Meter renders an inline fill bar of exactly width cells. -// -// It uses the same heavy-against-light stroke the rest of the shell uses for -// fill, so the reading survives a monochrome terminal and does not depend on -// telling one color from another. Color reinforces the measurement; it never -// carries it alone. -func Meter(width int, fill float64, on, off color.Color) string { - if width <= 0 { - return "" - } - - fill = math.Min(math.Max(fill, 0), 1) - filled := min(int(math.Round(fill*float64(width))), width) - - return New().Fg(on).Render(strings.Repeat("━", filled)) + - New().Fg(off).Render(strings.Repeat("─", width-filled)) -} - -// GradientMeter renders a fill bar whose color runs across a ramp along its -// length, rather than recoloring the whole bar as the value changes. -// -// The difference matters: a bar that is uniformly amber tells you the current -// state, while a bar that runs green through amber to red shows you the whole -// scale and where on it you currently sit. The consumed run is drawn in full -// color and the remainder in a muted blend of the same gradient, so the -// boundary stays legible — and it is still a heavy stroke against a light one, -// which is what makes the reading survive a monochrome terminal. -func GradientMeter(t theme.Theme, ramp theme.Ramp, width int, fill float64) string { - if width <= 0 { - return "" - } - - fill = math.Min(math.Max(fill, 0), 1) - filled := min(int(math.Round(fill*float64(width))), width) - - var b strings.Builder - - for i := range width { - // A one-cell bar has no length to run a gradient along; sample the - // start rather than dividing by zero. - pos := 0.0 - if width > 1 { - pos = float64(i) / float64(width-1) - } - - c := ramp.At(t, pos) - - if i < filled { - b.WriteString(New().Fg(c).Render("━")) - - continue - } - - b.WriteString(New().Fg(t.Muted(c)).Render("─")) - } - - return b.String() -} diff --git a/internal/tui/ui/style.go b/internal/tui/ui/style.go deleted file mode 100644 index a31167f..0000000 --- a/internal/tui/ui/style.go +++ /dev/null @@ -1,211 +0,0 @@ -package ui - -import ( - "image/color" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" -) - -// Style is a chainable utility builder. Each method sets exactly one property -// and returns a new Style, so appearance composes at the point of use: -// -// ui.New().Bg(t.C.BackgroundPanel).Fg(t.C.TextMuted).Px(theme.Space1).Render(s) -// -// Values are expected to come from the theme's token set and spacing scale -// rather than from literals — that constraint is the whole point. -type Style struct{ s lipgloss.Style } - -// New returns an empty utility style. -func New() Style { return Style{s: lipgloss.NewStyle()} } - -// From wraps an existing Lip Gloss style so painter-derived styles can be -// extended with utilities without being rebuilt. -func From(s lipgloss.Style) Style { return Style{s: s} } - -// Fg sets the foreground color. -func (u Style) Fg(c color.Color) Style { return Style{s: u.s.Foreground(c)} } - -// Bg sets the background color. -func (u Style) Bg(c color.Color) Style { return Style{s: u.s.Background(c)} } - -// P sets padding on all four sides. -func (u Style) P(n int) Style { return Style{s: u.s.Padding(n, n)} } - -// Px sets horizontal padding. -func (u Style) Px(n int) Style { return Style{s: u.s.PaddingLeft(n).PaddingRight(n)} } - -// Py sets vertical padding. -func (u Style) Py(n int) Style { return Style{s: u.s.PaddingTop(n).PaddingBottom(n)} } - -// W sets an exact width, padding or wrapping content to fit. -func (u Style) W(n int) Style { return Style{s: u.s.Width(n)} } - -// H sets an exact height, padding with blank lines to fit. -func (u Style) H(n int) Style { return Style{s: u.s.Height(n)} } - -// MaxW clips content to a width without padding it out to that width. -func (u Style) MaxW(n int) Style { return Style{s: u.s.MaxWidth(n)} } - -// Bold enables bold text. -func (u Style) Bold() Style { return Style{s: u.s.Bold(true)} } - -// Italic enables italic text. -func (u Style) Italic() Style { return Style{s: u.s.Italic(true)} } - -// Underline enables underlined text. -func (u Style) Underline() Style { return Style{s: u.s.Underline(true)} } - -// Align sets horizontal alignment within the style's width. -func (u Style) Align(p lipgloss.Position) Style { return Style{s: u.s.AlignHorizontal(p)} } - -// Render applies the style to a string. -func (u Style) Render(s string) string { return u.s.Render(s) } - -// Lip returns the underlying Lip Gloss style, for the rare call that needs a -// property this builder deliberately does not expose. -func (u Style) Lip() lipgloss.Style { return u.s } - -// Width reports the display width of a rendered string, counting grapheme -// widths and ignoring ANSI escapes. -func Width(s string) int { return lipgloss.Width(s) } - -// TabWidth is how many spaces a tab expands to. -const TabWidth = 4 - -// ExpandTabs replaces tabs with spaces. -// -// This is not cosmetic. Width measurement counts a tab as zero cells, but a -// terminal advances the cursor to the next tab stop when it draws one, so any -// content containing a tab paints wider than it measures — which overflows its -// pane and corrupts every row to its right. Producer content routinely contains -// tabs (Go source, diffs), so expansion happens on the way in, before anything -// measures or wraps. -func ExpandTabs(s string) string { - if !strings.ContainsRune(s, '\t') { - return s - } - - return strings.ReplaceAll(s, "\t", strings.Repeat(" ", TabWidth)) -} - -// Fit forces a single line to exactly width cells, truncating what overflows -// and padding what falls short. It is ANSI-aware, so styled content keeps its -// escapes intact. -func Fit(line string, width int) string { - if width <= 0 { - return "" - } - - line = ExpandTabs(line) - - w := ansi.StringWidth(line) - if w > width { - return ansi.Truncate(line, width, "") - } - - return line + strings.Repeat(" ", width-w) -} - -// Clip truncates a line to width without padding it out, keeping ANSI escapes -// intact. Use it where a shorter string is acceptable but a padded one is not. -func Clip(line string, width int) string { - if width <= 0 { - return "" - } - - line = ExpandTabs(line) - if ansi.StringWidth(line) <= width { - return line - } - - return ansi.Truncate(line, width, "") -} - -// ClipLeft truncates a line from the left, keeping its tail and marking the cut -// with an ellipsis. -// -// Paths and repository names carry their meaning at the end: given too little -// room, "…/aiagent/internal/tui" tells you where you are and -// "/home/steven/code/…" does not. Clip from whichever end preserves the part -// that identifies the thing. -func ClipLeft(line string, width int) string { - if width <= 0 { - return "" - } - - line = ExpandTabs(line) - - w := ansi.StringWidth(line) - if w <= width { - return line - } - - if width <= 1 { - return "…" - } - - return "…" + ansi.TruncateLeft(line, w-width+1, "") -} - -// FitBlock forces a multi-line block to exactly width by height cells, so a -// pane's interior always covers every cell it claims. Uncovered cells are what -// let the terminal's own background show through and break the illusion of a -// full-screen application. -func FitBlock(block string, width, height int, fill Style) string { - lines := strings.Split(block, "\n") - - out := make([]string, 0, height) - for i := range height { - line := "" - if i < len(lines) { - line = lines[i] - } - - out = append(out, fill.Render(Fit(line, width))) - } - - return strings.Join(out, "\n") -} - -// Overlay splices a block on top of a background frame at (x, y), preserving -// the frame around it. -// -// Lip Gloss's Canvas and Layer types cannot do this: Canvas.Compose draws every -// layer at the full canvas bounds and Layer.Draw ignores its own X and Y, so a -// later layer erases the frame beneath it instead of sitting on it. Splicing -// row by row with ANSI-aware truncation is what actually keeps the background -// visible. -func Overlay(frame, block string, x, y int) string { - frameRows := strings.Split(frame, "\n") - blockRows := strings.Split(block, "\n") - - frameWidth := lipgloss.Width(frame) - x = max(x, 0) - - // Clip rather than overflow. A block wider than the space left of x would - // push each spliced row past the terminal width, and an over-wide row wraps - // and shifts everything below it. - blockWidth := min(lipgloss.Width(block), max(frameWidth-x, 0)) - if blockWidth == 0 { - return frame - } - - for i, blockRow := range blockRows { - row := y + i - if row < 0 || row >= len(frameRows) { - continue - } - - base := frameRows[row] - - left := ansi.Truncate(base, x, "") - left += strings.Repeat(" ", max(x-ansi.StringWidth(left), 0)) - right := ansi.TruncateLeft(base, x+blockWidth, "") - - frameRows[row] = left + Fit(blockRow, blockWidth) + right - } - - return strings.Join(frameRows, "\n") -} diff --git a/internal/tui/ui/ui_test.go b/internal/tui/ui/ui_test.go deleted file mode 100644 index a354f1e..0000000 --- a/internal/tui/ui/ui_test.go +++ /dev/null @@ -1,839 +0,0 @@ -package ui_test - -import ( - "regexp" - "strings" - "testing" - - "charm.land/lipgloss/v2" - - "github.com/pluggableharness/agent/internal/tui/theme" - "github.com/pluggableharness/agent/internal/tui/ui" -) - -var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) - -func plain(s string) string { return ansiPattern.ReplaceAllString(s, "") } - -func TestFitPadsAndTruncatesToExactWidth(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in string - width int - want string - }{ - {"pads short", "ab", 5, "ab "}, - {"exact", "abcde", 5, "abcde"}, - {"truncates long", "abcdefgh", 5, "abcde"}, - {"empty pads", "", 3, " "}, - {"zero width", "abc", 0, ""}, - {"negative width", "abc", -2, ""}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := ui.Fit(tc.in, tc.width); got != tc.want { - t.Fatalf("Fit(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want) - } - }) - } -} - -// Fit must measure display width, not byte length, or styled content would be -// truncated by the length of its escape sequences. -func TestFitIsANSIAware(t *testing.T) { - t.Parallel() - - styled := lipgloss.NewStyle().Bold(true).Render("abc") - - got := ui.Fit(styled, 6) - if w := lipgloss.Width(got); w != 6 { - t.Fatalf("styled Fit width = %d, want 6", w) - } - - if !strings.Contains(plain(got), "abc") { - t.Fatalf("styled Fit lost its content: %q", plain(got)) - } -} - -// A tab measures as zero cells but a terminal advances to the next tab stop -// when drawing one, so an unexpanded tab paints wider than it measures. -func TestExpandTabs(t *testing.T) { - t.Parallel() - - if lipgloss.Width("\t") != 0 { - t.Skip("lipgloss now measures tabs; the expansion rationale needs revisiting") - } - - got := ui.ExpandTabs("a\tb") - if want := "a" + strings.Repeat(" ", ui.TabWidth) + "b"; got != want { - t.Fatalf("ExpandTabs = %q, want %q", got, want) - } - - // A string with no tab is returned unchanged. - if got := ui.ExpandTabs("plain"); got != "plain" { - t.Fatalf("ExpandTabs(%q) = %q", "plain", got) - } - - // Fit expands on the way through, so measured and painted widths agree. - if w := lipgloss.Width(ui.Fit("a\tb", 10)); w != 10 { - t.Fatalf("Fit of tabbed content = %d cells, want 10", w) - } -} - -func TestFitBlockCoversExactlyWidthByHeight(t *testing.T) { - t.Parallel() - - got := ui.FitBlock("one\ntwo", 6, 4, ui.New()) - lines := strings.Split(got, "\n") - - if len(lines) != 4 { - t.Fatalf("got %d lines, want 4", len(lines)) - } - - for i, l := range lines { - if w := lipgloss.Width(l); w != 6 { - t.Errorf("line %d width = %d, want 6: %q", i, w, l) - } - } -} - -func TestFitBlockTruncatesExcessLines(t *testing.T) { - t.Parallel() - - got := ui.FitBlock("a\nb\nc\nd", 3, 2, ui.New()) - if lines := strings.Split(got, "\n"); len(lines) != 2 { - t.Fatalf("got %d lines, want 2", len(lines)) - } -} - -func TestOverlayPreservesTheFrameAroundTheBlock(t *testing.T) { - t.Parallel() - - frame := strings.Join([]string{ - "aaaaaaaaaa", - "bbbbbbbbbb", - "cccccccccc", - }, "\n") - - got := ui.Overlay(frame, "XX\nYY", 4, 1) - lines := strings.Split(got, "\n") - - want := []string{"aaaaaaaaaa", "bbbbXXbbbb", "ccccYYcccc"} - for i, w := range want { - if plain(lines[i]) != w { - t.Errorf("line %d = %q, want %q", i, plain(lines[i]), w) - } - } -} - -// A block that would extend past the frame is clipped, never allowed to widen -// a row — an over-wide row wraps and shifts everything below it. -func TestOverlayClipsRatherThanOverflowing(t *testing.T) { - t.Parallel() - - frame := "aaaaaaaaaa" - - got := ui.Overlay(frame, "XXXXXXXX", 6, 0) - if w := lipgloss.Width(got); w != 10 { - t.Fatalf("overlaid row width = %d, want 10: %q", w, plain(got)) - } - - // Entirely off the right edge: the frame is returned untouched. - if got := ui.Overlay(frame, "XX", 20, 0); got != frame { - t.Fatalf("off-frame overlay changed the frame: %q", plain(got)) - } -} - -func TestOverlayIgnoresRowsOutsideTheFrame(t *testing.T) { - t.Parallel() - - frame := "aaaa\nbbbb" - - if got := ui.Overlay(frame, "XX", 0, 5); got != frame { - t.Fatalf("below-frame overlay changed the frame: %q", plain(got)) - } - - if got := ui.Overlay(frame, "XX", 0, -3); got != frame { - t.Fatalf("above-frame overlay changed the frame: %q", plain(got)) - } -} - -func TestPanelRendersExactOuterDimensions(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - for _, size := range [][2]int{{20, 5}, {40, 3}, {12, 8}, {3, 2}} { - w, h := size[0], size[1] - - got := ui.Panel{Title: "title", Body: "body\nmore", Width: w, Height: h}.Render(th) - lines := strings.Split(got, "\n") - - if len(lines) != h { - t.Errorf("%dx%d: got %d lines, want %d", w, h, len(lines), h) - - continue - } - - for i, l := range lines { - if lw := lipgloss.Width(l); lw != w { - t.Errorf("%dx%d line %d: width %d: %q", w, h, i, lw, plain(l)) - } - } - } -} - -func TestPanelShowsItsTitleAndBody(t *testing.T) { - t.Parallel() - - got := plain(ui.Panel{Title: "git", Body: "branch: main", Width: 30, Height: 4}.Render(theme.Dark())) - - for _, want := range []string{"git", "branch: main"} { - if !strings.Contains(got, want) { - t.Errorf("panel missing %q:\n%s", want, got) - } - } -} - -// A focused panel must be visually distinct, and it must not change size doing -// it — a border weight change would reflow the layout on every focus move. -func TestPanelFocusChangesStyleNotGeometry(t *testing.T) { - t.Parallel() - - th := theme.Dark() - base := ui.Panel{Title: "t", Body: "b", Width: 20, Height: 4} - focused := base - focused.Focused = true - - unfocusedOut := base.Render(th) - focusedOut := focused.Render(th) - - if unfocusedOut == focusedOut { - t.Fatal("focused panel rendered identically to unfocused") - } - - if plain(unfocusedOut) != plain(focusedOut) { - t.Fatalf("focus changed panel geometry:\n%s\n---\n%s", plain(unfocusedOut), plain(focusedOut)) - } -} - -// The caption sits in the bottom border against the right corner, opposite the -// title, and neither displaces the other. -func TestPanelCaptionSitsBottomRight(t *testing.T) { - t.Parallel() - - got := plain(ui.Panel{ - Title: "Code", - Caption: "claude-opus-5", - Body: "prompt", - Width: 40, - Height: 3, - }.Render(theme.Dark())) - - lines := strings.Split(got, "\n") - - if !strings.Contains(lines[0], "Code") { - t.Errorf("title not in the top border: %q", lines[0]) - } - - last := lines[len(lines)-1] - if !strings.Contains(last, "claude-opus-5") { - t.Fatalf("caption not in the bottom border: %q", last) - } - - // Right-aligned means exactly one border cell trails the label. - if !strings.HasSuffix(last, "claude-opus-5 ─╯") { - t.Errorf("caption not against the right corner: %q", last) - } - - if strings.Contains(lines[0], "claude-opus-5") { - t.Errorf("caption leaked into the top border: %q", lines[0]) - } -} - -// A caption that cannot fit whole is dropped rather than clipped: an -// abbreviated model name names no model. -func TestPanelDropsCaptionRatherThanClipIt(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - for w := 8; w <= 40; w++ { - got := plain(ui.Panel{Caption: "claude-opus-5", Body: "b", Width: w, Height: 3}.Render(th)) - - last := strings.Split(got, "\n")[2] - if strings.Contains(last, "claude-opus-5") { - continue - } - - // Whatever survives must be border, never a fragment of the caption. - if strings.ContainsAny(last, "abcdefghijklmnopqrstuvwxyz0123456789") { - t.Errorf("width %d: clipped caption fragment: %q", w, last) - } - } -} - -// Too small to frame, the panel still covers its cells rather than letting the -// terminal show through. -func TestPanelDegradesWhenTooSmallToFrame(t *testing.T) { - t.Parallel() - - got := ui.Panel{Title: "t", Body: "body", Width: 2, Height: 1}.Render(theme.Dark()) - if w := lipgloss.Width(got); w != 2 { - t.Fatalf("degenerate panel width = %d, want 2", w) - } -} -func TestBadgeRendersItsText(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - got := ui.Badge(th, "ready", th.C.Success) - if !strings.Contains(plain(got), "ready") { - t.Fatalf("badge lost its text: %q", plain(got)) - } -} - -// The utility builder is the whole point of the package: each method sets one -// property and they compose. -func TestStyleUtilitiesCompose(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - got := ui.New(). - Fg(th.C.Text). - Bg(th.C.BackgroundPanel). - Px(theme.Space2). - W(20). - Bold(). - Italic(). - Underline(). - Align(lipgloss.Center). - Render("hi") - - if w := lipgloss.Width(got); w != 20 { - t.Fatalf("composed width = %d, want 20", w) - } - - if !strings.Contains(plain(got), "hi") { - t.Fatalf("composed style lost its content: %q", plain(got)) - } -} - -func TestStylePaddingHelpers(t *testing.T) { - t.Parallel() - - if got := plain(ui.New().P(theme.Space1).Render("x")); !strings.Contains(got, " x ") { - t.Errorf("P did not pad horizontally: %q", got) - } - - if got := plain(ui.New().Py(theme.Space1).Render("x")); !strings.Contains(got, "\n") { - t.Errorf("Py did not pad vertically: %q", got) - } - - if got := ui.New().H(3).Render("x"); lipgloss.Height(got) != 3 { - t.Errorf("H did not set height: %d", lipgloss.Height(got)) - } - - if got := ui.New().MaxW(3).Render("abcdef"); lipgloss.Width(got) > 3 { - t.Errorf("MaxW did not clip: %q", got) - } -} - -func TestFromAndLipRoundTrip(t *testing.T) { - t.Parallel() - - base := lipgloss.NewStyle().Bold(true) - - if got := ui.From(base).Render("x"); got != base.Render("x") { - t.Fatalf("From changed rendering: %q vs %q", got, base.Render("x")) - } - - if !ui.From(base).Lip().GetBold() { - t.Fatal("Lip did not return the wrapped style") - } -} - -func TestWidthMatchesLipgloss(t *testing.T) { - t.Parallel() - - if ui.Width("abc") != lipgloss.Width("abc") { - t.Fatal("ui.Width disagrees with lipgloss.Width") - } -} - -func TestStatusLineFillsItsWidth(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - for _, w := range []int{20, 40, 80, 120} { - got := ui.StatusLine{ - Width: w, - Segments: []ui.Segment{ - {Label: "model", Value: "opus"}, - {Label: "cost", Value: "$0.42"}, - }, - }.Render(th) - - if lipgloss.Width(got) != w { - t.Errorf("width %d: line rendered %d cells", w, lipgloss.Width(got)) - } - } -} - -// Segments with nothing to show are omitted: a status bar should not advertise -// fields it has no data for. -func TestStatusLineDropsEmptySegments(t *testing.T) { - t.Parallel() - - got := plain(ui.StatusLine{ - Width: 60, - Segments: []ui.Segment{ - {Label: "model", Value: "opus"}, - {Label: "branch", Value: ""}, - {Label: "cost", Value: "$1"}, - }, - }.Render(theme.Dark())) - - if strings.Contains(got, "branch") { - t.Fatalf("empty segment was rendered: %q", got) - } - - for _, want := range []string{"model", "opus", "cost", "$1"} { - if !strings.Contains(got, want) { - t.Errorf("missing %q: %q", want, got) - } - } -} - -// A line that will not fit drops from the right, so the leftmost fields — the -// ones ranked most important — survive a narrow terminal. -func TestStatusLineDropsFromTheRight(t *testing.T) { - t.Parallel() - - line := ui.StatusLine{ - Width: 26, - Segments: []ui.Segment{ - {Label: "model", Value: "claude-opus-5"}, - {Label: "cost", Value: "$0.42"}, - {Label: "elapsed", Value: "22m00s"}, - }, - } - - got := plain(line.Render(theme.Dark())) - - if !strings.Contains(got, "claude-opus-5") { - t.Errorf("leftmost segment was dropped: %q", got) - } - - if strings.Contains(got, "22m00s") { - t.Errorf("rightmost segment survived a too-narrow line: %q", got) - } - - if lipgloss.Width(line.Render(theme.Dark())) != 26 { - t.Error("truncated line does not fill its width") - } -} - -// The filling segment absorbs whatever is left, so the line reflows with the -// terminal rather than leaving a ragged gap. -func TestStatusLineFillSegmentAbsorbsSlack(t *testing.T) { - t.Parallel() - - widths := map[int]int{} - - for _, w := range []int{60, 90, 120} { - ui.StatusLine{ - Width: w, - Segments: []ui.Segment{ - {Label: "model", Value: "opus"}, - { - MinWidth: 10, - Fill: func(width int) string { - widths[w] = width - - return strings.Repeat("=", width) - }, - }, - }, - }.Render(theme.Dark()) - } - - if widths[60] >= widths[90] || widths[90] >= widths[120] { - t.Fatalf("fill segment did not grow with the line: %v", widths) - } -} - -func TestStatusLineHandlesDegenerateInput(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - if got := (ui.StatusLine{Width: 0}).Render(th); got != "" { - t.Errorf("zero width = %q, want empty", got) - } - - if got := (ui.StatusLine{Width: 10}).Render(th); lipgloss.Width(got) != 10 { - t.Errorf("no segments: width %d, want 10", lipgloss.Width(got)) - } -} - -func TestMeterFillsProportionally(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - prev := -1 - - for i := range 11 { - got := plain(ui.Meter(20, float64(i)/10, th.C.Success, th.C.Border)) - - if lipgloss.Width(got) != 20 { - t.Fatalf("meter width = %d, want 20", lipgloss.Width(got)) - } - - filled := strings.Count(got, "━") - if filled < prev { - t.Errorf("fill %d0%%: %d cells, fewer than previous %d", i, filled, prev) - } - - prev = filled - } - - if strings.Count(plain(ui.Meter(20, 0, th.C.Success, th.C.Border)), "━") != 0 { - t.Error("an empty meter drew filled cells") - } - - if strings.Count(plain(ui.Meter(20, 1, th.C.Success, th.C.Border)), "━") != 20 { - t.Error("a full meter did not fill every cell") - } -} - -func TestMeterClampsAndHandlesZeroWidth(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - if got := ui.Meter(0, 0.5, th.C.Success, th.C.Border); got != "" { - t.Errorf("zero-width meter = %q", got) - } - - for _, fill := range []float64{-5, 9} { - if w := lipgloss.Width(ui.Meter(10, fill, th.C.Success, th.C.Border)); w != 10 { - t.Errorf("fill %v: width %d, want 10", fill, w) - } - } -} - -// A wide terminal must not leave the bar packed to one side. -func TestStatusLineSpansWithARightGroup(t *testing.T) { - t.Parallel() - - line := ui.StatusLine{ - Width: 80, - Segments: []ui.Segment{{Label: "model", Value: "opus"}}, - Right: []ui.Segment{{Label: "cache", Value: "89%"}}, - } - - got := plain(line.Render(theme.Dark())) - - if lipgloss.Width(got) != 80 { - t.Fatalf("line width = %d, want 80", lipgloss.Width(got)) - } - - if !strings.HasPrefix(got, " model") { - t.Errorf("left group not at the left edge: %q", got) - } - - if !strings.HasSuffix(got, "89% ") { - t.Errorf("right group not at the right edge: %q", got) - } -} - -// The right group yields first when space runs out: the left is the ranked side. -func TestStatusLineDropsTheRightGroupWhenCrowded(t *testing.T) { - t.Parallel() - - got := plain(ui.StatusLine{ - Width: 30, - Segments: []ui.Segment{{Label: "model", Value: "claude-opus-5"}}, - Right: []ui.Segment{{Label: "elapsed", Value: "22m00s"}}, - }.Render(theme.Dark())) - - if strings.Contains(got, "22m00s") { - t.Errorf("right group survived a crowded line: %q", got) - } - - if !strings.Contains(got, "claude-opus-5") { - t.Errorf("left group was dropped instead: %q", got) - } -} - -// Empty right-hand segments are omitted like any other. -func TestStatusLineRightGroupDropsEmpties(t *testing.T) { - t.Parallel() - - got := plain(ui.StatusLine{ - Width: 60, - Segments: []ui.Segment{{Label: "repo", Value: "agent"}}, - Right: []ui.Segment{{Label: "pr", Value: ""}}, - }.Render(theme.Dark())) - - if strings.Contains(got, "pr") { - t.Errorf("empty right segment was rendered: %q", got) - } -} - -func TestFieldsAlignsValuesIntoAColumn(t *testing.T) { - t.Parallel() - - got := plain(ui.Fields(theme.Dark(), []ui.Field{ - {Label: "pr", Value: "#11"}, - {Label: "branch", Value: "main"}, - }, 40)) - - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("got %d lines, want 2", len(lines)) - } - - // Values start at the same column regardless of label length. - if strings.Index(lines[0], "#11") != strings.Index(lines[1], "main") { - t.Fatalf("values not aligned:\n%s", got) - } -} - -// Fields with no value are dropped, on the same principle as status segments. -func TestFieldsDropsEmptyValues(t *testing.T) { - t.Parallel() - - got := plain(ui.Fields(theme.Dark(), []ui.Field{ - {Label: "repo", Value: "agent"}, - {Label: "pr", Value: ""}, - }, 40)) - - if strings.Contains(got, "pr") { - t.Errorf("empty field rendered: %q", got) - } - - if ui.Fields(theme.Dark(), nil, 40) != "" { - t.Error("empty field list produced output") - } -} - -// A wide value takes its own line rather than being squeezed beside a label. -func TestFieldsWideValuesStack(t *testing.T) { - t.Parallel() - - got := plain(ui.Fields(theme.Dark(), []ui.Field{ - {Label: "dir", Value: "~/code/aiagent", Wide: true}, - }, 40)) - - if len(strings.Split(got, "\n")) != 2 { - t.Fatalf("wide field did not stack:\n%s", got) - } -} - -// A label column wider than half the panel is a wall, not a column, so -// everything stacks instead. -func TestFieldsStackWhenLabelsCrowdTheWidth(t *testing.T) { - t.Parallel() - - got := plain(ui.Fields(theme.Dark(), []ui.Field{ - {Label: "averylonglabel", Value: "x"}, - }, 16)) - - if len(strings.Split(got, "\n")) != 2 { - t.Fatalf("expected stacking on a narrow panel:\n%s", got) - } -} - -func TestFieldsClipsRatherThanOverflowing(t *testing.T) { - t.Parallel() - - got := plain(ui.Fields(theme.Dark(), []ui.Field{ - {Label: "repo", Value: strings.Repeat("x", 200)}, - }, 30)) - - for _, line := range strings.Split(got, "\n") { - if lipgloss.Width(line) > 30 { - t.Errorf("line overflowed: %d cells", lipgloss.Width(line)) - } - } -} - -// The gradient runs across the bar's length rather than recoloring the whole -// bar as the value changes: the point is to show the scale, not just the state. -func TestGradientMeterVariesAlongItsLength(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - got := ui.GradientMeter(th, theme.DefaultGaugeRamp, 24, 1) - if lipgloss.Width(got) != 24 { - t.Fatalf("meter width = %d, want 24", lipgloss.Width(got)) - } - - // Every cell is filled, so any color difference is the gradient itself. - colors := regexp.MustCompile(`38;2;(\d+);(\d+);(\d+)`).FindAllStringSubmatch(got, -1) - if len(colors) < 24 { - t.Fatalf("got %d colored cells, want 24", len(colors)) - } - - if colors[0][0] == colors[len(colors)-1][0] { - t.Error("first and last cell share a color; the bar is not a gradient") - } -} - -func TestGradientMeterFillsProportionally(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - prev := -1 - - for i := range 11 { - got := plain(ui.GradientMeter(th, theme.DefaultGaugeRamp, 20, float64(i)/10)) - - if lipgloss.Width(got) != 20 { - t.Fatalf("width = %d, want 20", lipgloss.Width(got)) - } - - filled := strings.Count(got, "━") - if filled < prev { - t.Errorf("fill %d0%%: %d cells, fewer than previous %d", i, filled, prev) - } - - prev = filled - } -} - -func TestGradientMeterHandlesDegenerateWidths(t *testing.T) { - t.Parallel() - - th := theme.Dark() - - if got := ui.GradientMeter(th, theme.DefaultGaugeRamp, 0, 0.5); got != "" { - t.Errorf("zero width = %q", got) - } - - // A one-cell bar has no length to run a gradient along and must not divide - // by zero. - if w := lipgloss.Width(ui.GradientMeter(th, theme.DefaultGaugeRamp, 1, 0.5)); w != 1 { - t.Errorf("one-cell meter width = %d", w) - } -} - -// A path keeps its tail, which is the part that identifies it. -func TestClipLeftKeepsTheTail(t *testing.T) { - t.Parallel() - - tests := []struct { - in string - width int - want string - }{ - {"/home/steven/code/aiagent", 12, "…ode/aiagent"}, - {"short", 20, "short"}, - {"exactfit", 8, "exactfit"}, - {"abc", 1, "…"}, - {"abc", 0, ""}, - } - - for _, tc := range tests { - got := ui.ClipLeft(tc.in, tc.width) - if got != tc.want { - t.Errorf("ClipLeft(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want) - } - - if tc.width > 0 && lipgloss.Width(got) > tc.width { - t.Errorf("ClipLeft(%q, %d) overflowed: %d cells", tc.in, tc.width, lipgloss.Width(got)) - } - } -} - -// A line with an empty left group still renders its right group: the two are -// independent, and an empty left is a legitimate state. -func TestStatusLineRendersRightWithNoLeftSegments(t *testing.T) { - t.Parallel() - - got := plain(ui.StatusLine{ - Width: 40, - Right: []ui.Segment{{Label: "state", Value: "ready"}}, - }.Render(theme.Dark())) - - if !strings.Contains(got, "ready") { - t.Fatalf("right group discarded with an empty left: %q", got) - } - - if lipgloss.Width(got) != 40 { - t.Errorf("width = %d, want 40", lipgloss.Width(got)) - } -} - -// When a filling segment has taken every spare cell, the space between the -// groups is exactly one separator wide — so draw the separator rather than -// leaving a conspicuous hole. -func TestStatusLineDrawsTheSeparatorBesideAFillSegment(t *testing.T) { - t.Parallel() - - got := plain(ui.StatusLine{ - Width: 60, - Segments: []ui.Segment{{ - MinWidth: 10, - Fill: func(w int) string { return strings.Repeat("=", w) }, - }}, - Right: []ui.Segment{{Label: "cost", Value: "$1"}}, - }.Render(theme.Dark())) - - if !strings.Contains(got, "="+strings.TrimRight(ui.SegmentSeparator, " ")) { - t.Fatalf("no separator between the fill and the right group: %q", got) - } -} - -// The right group sheds one field at a time. Dropping it wholesale makes a -// filling segment lurch by the entire group's width on a single column of -// resize. -func TestStatusLineShedsRightSegmentsOneAtATime(t *testing.T) { - t.Parallel() - - right := []ui.Segment{ - {Label: "cache", Value: "89%"}, - {Label: "cost", Value: "$0.42"}, - {Label: "elapsed", Value: "22m00s"}, - } - - seen := map[int]bool{} - - for w := 40; w <= 120; w++ { - got := plain(ui.StatusLine{ - Width: w, - Segments: []ui.Segment{{MinWidth: 12, Fill: func(n int) string { return strings.Repeat("=", n) }}}, - Right: right, - }.Render(theme.Dark())) - - kept := 0 - for _, s := range []string{"cache", "cost", "elapsed"} { - if strings.Contains(got, s) { - kept++ - } - } - - seen[kept] = true - } - - // Every intermediate count should occur somewhere in the range; an - // all-or-nothing group would only ever show 0 or 3. - for _, want := range []int{1, 2} { - if !seen[want] { - t.Errorf("right group never rendered exactly %d segments; it is dropping wholesale", want) - } - } -} diff --git a/internal/turn/runturn.go b/internal/turn/runturn.go index ebb331b..dec9994 100644 --- a/internal/turn/runturn.go +++ b/internal/turn/runturn.go @@ -166,6 +166,7 @@ func (r *run) execute(ctx context.Context) (Result, error) { Usage: resp.Usage, CostUSD: resp.CostUSD, AssembledTokens: assembled.AssembledTokensLastTurn, + ActualModel: resp.ActualModel, } // Step 6 — the implicit DoneCheck. No tool_use blocks ends the turn @@ -372,6 +373,7 @@ func (r *run) callContext() *commonv1.CallContext { func (r *run) callModel(ctx context.Context, mreq *modelv1.StreamCompletionRequest) (modelcall.Response, error) { resp, err := r.d.model.Complete(ctx, modelcall.Request{ Model: r.req.Model, + SessionID: r.req.SessionID, MessageID: r.d.ids.New(), Request: mreq, }) diff --git a/internal/turn/turn.go b/internal/turn/turn.go index 79fc6f5..5128976 100644 --- a/internal/turn/turn.go +++ b/internal/turn/turn.go @@ -244,6 +244,10 @@ type Result struct { // value the session driver threads back in as the next turn's // Request.AssembledTokensLastTurn. AssembledTokens int64 + // ActualModel is the model the vendor says actually served this + // turn's completion, when it differs from the requested id. Empty + // when the vendor served what was asked for or reported nothing. + ActualModel string // CallHashes are this turn's resource and data_source calls' hashes, // in declaration order, for the caller's step-16 doom-loop check. // Interactive calls are excluded: turn-algorithm.md#doom-loop-detection diff --git a/mkdocs.yml b/mkdocs.yml index 958b23e..7c00c8b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -226,7 +226,6 @@ nav: - xAI: first-party/providers/xai.md - Frontends: - first-party/frontends/README.md - - Reference TUI shell: first-party/frontends/tui.md - Tools: - first-party/tools/README.md - File read: first-party/tools/file-read.md diff --git a/pkg/content/proto/v1/types.pb.go b/pkg/content/proto/v1/types.pb.go index bb64370..fb0429b 100644 --- a/pkg/content/proto/v1/types.pb.go +++ b/pkg/content/proto/v1/types.pb.go @@ -98,6 +98,59 @@ func (Role) EnumDescriptor() ([]byte, []int) { return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{0} } +// ImageDetail names how much resolution a model should spend on an image. +type ImageDetail int32 + +const ( + // The vendor's own default applies. + ImageDetail_IMAGE_DETAIL_UNSPECIFIED ImageDetail = 0 + // Prefer fewer tokens over fidelity. + ImageDetail_IMAGE_DETAIL_LOW ImageDetail = 1 + // Prefer fidelity over token cost. + ImageDetail_IMAGE_DETAIL_HIGH ImageDetail = 2 +) + +// Enum value maps for ImageDetail. +var ( + ImageDetail_name = map[int32]string{ + 0: "IMAGE_DETAIL_UNSPECIFIED", + 1: "IMAGE_DETAIL_LOW", + 2: "IMAGE_DETAIL_HIGH", + } + ImageDetail_value = map[string]int32{ + "IMAGE_DETAIL_UNSPECIFIED": 0, + "IMAGE_DETAIL_LOW": 1, + "IMAGE_DETAIL_HIGH": 2, + } +) + +func (x ImageDetail) Enum() *ImageDetail { + p := new(ImageDetail) + *p = x + return p +} + +func (x ImageDetail) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ImageDetail) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_content_v1_types_proto_enumTypes[1].Descriptor() +} + +func (ImageDetail) Type() protoreflect.EnumType { + return &file_pluggableharness_content_v1_types_proto_enumTypes[1] +} + +func (x ImageDetail) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ImageDetail.Descriptor instead. +func (ImageDetail) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{1} +} + // Stability hints whether a ContextSection's content changes turn to turn, // used both as a context provider's ContextCapabilities-level declaration // (pluggableharness.context.v1.ContextCapabilities.stability) and @@ -144,11 +197,11 @@ func (x Stability) String() string { } func (Stability) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_content_v1_types_proto_enumTypes[1].Descriptor() + return file_pluggableharness_content_v1_types_proto_enumTypes[2].Descriptor() } func (Stability) Type() protoreflect.EnumType { - return &file_pluggableharness_content_v1_types_proto_enumTypes[1] + return &file_pluggableharness_content_v1_types_proto_enumTypes[2] } func (x Stability) Number() protoreflect.EnumNumber { @@ -157,7 +210,7 @@ func (x Stability) Number() protoreflect.EnumNumber { // Deprecated: Use Stability.Descriptor instead. func (Stability) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_content_v1_types_proto_rawDescGZIP(), []int{2} } // Message is one turn in the canonical conversation history: a role plus @@ -633,7 +686,15 @@ type ImageBlock struct { // Raw image bytes. Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` // The image's MIME type, e.g. "image/png". - MediaType string `protobuf:"bytes,2,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` + MediaType string `protobuf:"bytes,2,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` + // How much detail the model should spend on this image, where the + // vendor exposes the choice. + // + // It is a cost control, not a rendering hint: vendors bill high-detail + // image input at a multiple of low, so a caller sending many + // screenshots for a coarse question has a real reason to say so. + // UNSPECIFIED leaves the vendor's own default. + Detail ImageDetail `protobuf:"varint,3,opt,name=detail,proto3,enum=pluggableharness.content.v1.ImageDetail" json:"detail,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -682,6 +743,13 @@ func (x *ImageBlock) GetMediaType() string { return "" } +func (x *ImageBlock) GetDetail() ImageDetail { + if x != nil { + return x.Detail + } + return ImageDetail_IMAGE_DETAIL_UNSPECIFIED +} + // ThinkingBlock is the model's extended-reasoning output, when // ThinkingSpec.supported (model.md §2). Requires the model's // ThinkingSpec.supported to be true. @@ -999,12 +1067,13 @@ const file_pluggableharness_content_v1_types_proto_rawDesc = "" + "\x0fToolResultBlock\x12\x1e\n" + "\vtool_use_id\x18\x01 \x01(\tR\ttoolUseId\x12C\n" + "\acontent\x18\x02 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontent\x12\x19\n" + - "\bis_error\x18\x03 \x01(\bR\aisError\"?\n" + + "\bis_error\x18\x03 \x01(\bR\aisError\"\x81\x01\n" + "\n" + "ImageBlock\x12\x12\n" + "\x04data\x18\x01 \x01(\fR\x04data\x12\x1d\n" + "\n" + - "media_type\x18\x02 \x01(\tR\tmediaType\"A\n" + + "media_type\x18\x02 \x01(\tR\tmediaType\x12@\n" + + "\x06detail\x18\x03 \x01(\x0e2(.pluggableharness.content.v1.ImageDetailR\x06detail\"A\n" + "\rThinkingBlock\x12\x12\n" + "\x04text\x18\x01 \x01(\tR\x04text\x12\x1c\n" + "\tsignature\x18\x02 \x01(\fR\tsignature\"+\n" + @@ -1026,7 +1095,11 @@ const file_pluggableharness_content_v1_types_proto_rawDesc = "" + "\x04Role\x12\x14\n" + "\x10ROLE_UNSPECIFIED\x10\x00\x12\r\n" + "\tROLE_USER\x10\x01\x12\x12\n" + - "\x0eROLE_ASSISTANT\x10\x02*S\n" + + "\x0eROLE_ASSISTANT\x10\x02*X\n" + + "\vImageDetail\x12\x1c\n" + + "\x18IMAGE_DETAIL_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10IMAGE_DETAIL_LOW\x10\x01\x12\x15\n" + + "\x11IMAGE_DETAIL_HIGH\x10\x02*S\n" + "\tStability\x12\x19\n" + "\x15STABILITY_UNSPECIFIED\x10\x00\x12\x14\n" + "\x10STABILITY_STATIC\x10\x01\x12\x15\n" + @@ -1044,42 +1117,44 @@ func file_pluggableharness_content_v1_types_proto_rawDescGZIP() []byte { return file_pluggableharness_content_v1_types_proto_rawDescData } -var file_pluggableharness_content_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_pluggableharness_content_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_pluggableharness_content_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_pluggableharness_content_v1_types_proto_goTypes = []any{ (Role)(0), // 0: pluggableharness.content.v1.Role - (Stability)(0), // 1: pluggableharness.content.v1.Stability - (*Message)(nil), // 2: pluggableharness.content.v1.Message - (*ContentBlock)(nil), // 3: pluggableharness.content.v1.ContentBlock - (*TextBlock)(nil), // 4: pluggableharness.content.v1.TextBlock - (*ToolUseBlock)(nil), // 5: pluggableharness.content.v1.ToolUseBlock - (*ToolResultBlock)(nil), // 6: pluggableharness.content.v1.ToolResultBlock - (*ImageBlock)(nil), // 7: pluggableharness.content.v1.ImageBlock - (*ThinkingBlock)(nil), // 8: pluggableharness.content.v1.ThinkingBlock - (*RedactedThinkingBlock)(nil), // 9: pluggableharness.content.v1.RedactedThinkingBlock - (*DocumentBlock)(nil), // 10: pluggableharness.content.v1.DocumentBlock - (*ContextSection)(nil), // 11: pluggableharness.content.v1.ContextSection - (*structpb.Struct)(nil), // 12: google.protobuf.Struct + (ImageDetail)(0), // 1: pluggableharness.content.v1.ImageDetail + (Stability)(0), // 2: pluggableharness.content.v1.Stability + (*Message)(nil), // 3: pluggableharness.content.v1.Message + (*ContentBlock)(nil), // 4: pluggableharness.content.v1.ContentBlock + (*TextBlock)(nil), // 5: pluggableharness.content.v1.TextBlock + (*ToolUseBlock)(nil), // 6: pluggableharness.content.v1.ToolUseBlock + (*ToolResultBlock)(nil), // 7: pluggableharness.content.v1.ToolResultBlock + (*ImageBlock)(nil), // 8: pluggableharness.content.v1.ImageBlock + (*ThinkingBlock)(nil), // 9: pluggableharness.content.v1.ThinkingBlock + (*RedactedThinkingBlock)(nil), // 10: pluggableharness.content.v1.RedactedThinkingBlock + (*DocumentBlock)(nil), // 11: pluggableharness.content.v1.DocumentBlock + (*ContextSection)(nil), // 12: pluggableharness.content.v1.ContextSection + (*structpb.Struct)(nil), // 13: google.protobuf.Struct } var file_pluggableharness_content_v1_types_proto_depIdxs = []int32{ 0, // 0: pluggableharness.content.v1.Message.role:type_name -> pluggableharness.content.v1.Role - 3, // 1: pluggableharness.content.v1.Message.content:type_name -> pluggableharness.content.v1.ContentBlock - 4, // 2: pluggableharness.content.v1.ContentBlock.text:type_name -> pluggableharness.content.v1.TextBlock - 5, // 3: pluggableharness.content.v1.ContentBlock.tool_use:type_name -> pluggableharness.content.v1.ToolUseBlock - 6, // 4: pluggableharness.content.v1.ContentBlock.tool_result:type_name -> pluggableharness.content.v1.ToolResultBlock - 7, // 5: pluggableharness.content.v1.ContentBlock.image:type_name -> pluggableharness.content.v1.ImageBlock - 8, // 6: pluggableharness.content.v1.ContentBlock.thinking:type_name -> pluggableharness.content.v1.ThinkingBlock - 9, // 7: pluggableharness.content.v1.ContentBlock.redacted_thinking:type_name -> pluggableharness.content.v1.RedactedThinkingBlock - 10, // 8: pluggableharness.content.v1.ContentBlock.document:type_name -> pluggableharness.content.v1.DocumentBlock - 12, // 9: pluggableharness.content.v1.ToolUseBlock.arguments:type_name -> google.protobuf.Struct - 3, // 10: pluggableharness.content.v1.ToolResultBlock.content:type_name -> pluggableharness.content.v1.ContentBlock - 3, // 11: pluggableharness.content.v1.ContextSection.content:type_name -> pluggableharness.content.v1.ContentBlock - 1, // 12: pluggableharness.content.v1.ContextSection.stability:type_name -> pluggableharness.content.v1.Stability - 13, // [13:13] is the sub-list for method output_type - 13, // [13:13] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 4, // 1: pluggableharness.content.v1.Message.content:type_name -> pluggableharness.content.v1.ContentBlock + 5, // 2: pluggableharness.content.v1.ContentBlock.text:type_name -> pluggableharness.content.v1.TextBlock + 6, // 3: pluggableharness.content.v1.ContentBlock.tool_use:type_name -> pluggableharness.content.v1.ToolUseBlock + 7, // 4: pluggableharness.content.v1.ContentBlock.tool_result:type_name -> pluggableharness.content.v1.ToolResultBlock + 8, // 5: pluggableharness.content.v1.ContentBlock.image:type_name -> pluggableharness.content.v1.ImageBlock + 9, // 6: pluggableharness.content.v1.ContentBlock.thinking:type_name -> pluggableharness.content.v1.ThinkingBlock + 10, // 7: pluggableharness.content.v1.ContentBlock.redacted_thinking:type_name -> pluggableharness.content.v1.RedactedThinkingBlock + 11, // 8: pluggableharness.content.v1.ContentBlock.document:type_name -> pluggableharness.content.v1.DocumentBlock + 13, // 9: pluggableharness.content.v1.ToolUseBlock.arguments:type_name -> google.protobuf.Struct + 4, // 10: pluggableharness.content.v1.ToolResultBlock.content:type_name -> pluggableharness.content.v1.ContentBlock + 1, // 11: pluggableharness.content.v1.ImageBlock.detail:type_name -> pluggableharness.content.v1.ImageDetail + 4, // 12: pluggableharness.content.v1.ContextSection.content:type_name -> pluggableharness.content.v1.ContentBlock + 2, // 13: pluggableharness.content.v1.ContextSection.stability:type_name -> pluggableharness.content.v1.Stability + 14, // [14:14] is the sub-list for method output_type + 14, // [14:14] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_pluggableharness_content_v1_types_proto_init() } @@ -1103,7 +1178,7 @@ func file_pluggableharness_content_v1_types_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_content_v1_types_proto_rawDesc), len(file_pluggableharness_content_v1_types_proto_rawDesc)), - NumEnums: 2, + NumEnums: 3, NumMessages: 10, NumExtensions: 0, NumServices: 0, diff --git a/pkg/frontend/attach.go b/pkg/frontend/attach.go deleted file mode 100644 index 6f1731d..0000000 --- a/pkg/frontend/attach.go +++ /dev/null @@ -1,135 +0,0 @@ -package frontend - -import ( - "errors" - "io" - "sync" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" -) - -// Attach implements FrontendServiceServer's one bidirectional RPC — the -// single, connection-scoped, multiplexed event channel a frontend keeps -// open for its connection's whole lifetime (doc.go's "Attach is one stream -// per connection, not one per session"). This is a single dispatch loop, -// never one goroutine per session's own stream: every ClientEvent this -// connection receives, regardless of which session it names, is read from -// the same stream.Recv() call in arrival order and handed to -// svc.provider.HandleEvent, which answers through a connection-scoped -// Emitter shared by every session on this connection — so a session's -// backfill batch (SessionAttached, replayed Renders, BackfillComplete), -// like every other event, is written only to the one *connection this -// Attach call owns, never fanned out to any other connection -// (frontend-protocol.md's "Backfill is unicast to the attaching stream -// only, never broadcast"). -// -// See doc.go's "Wire direction" section for why this method RECEIVES -// ClientEvent and SENDS ServerEvent, the mechanical direction the -// generated frontendv1.FrontendServiceServer interface fixes. -func (svc *Service) Attach(stream frontendv1.FrontendService_AttachServer) error { - ctx := stream.Context() - conn := &connection{stream: stream} - - for { - in, err := stream.Recv() - if err != nil { - return terminal(err) - } - - event, convErr := fromClientEventProto(in) - if convErr != nil { - if sendErr := conn.emitError(nil, &Error{ - Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT, - Message: convErr.Error(), - }); sendErr != nil { - return sendErr - } - continue - } - - if err := svc.provider.HandleEvent(ctx, event, conn); err != nil { - var fatal *FatalErr - if errors.As(err, &fatal) { - return fatal.Err - } - if sendErr := conn.emitError(requestIDOf(event), inBandError(err)); sendErr != nil { - return sendErr - } - } - } -} - -// terminal maps a stream.Recv error to what Attach itself should return: -// nil for ordinary stream closure (io.EOF, signaling the kernel called -// CloseSend) or expected cancellation (codes.Canceled — normal control -// flow per .claude/rules/grpc.md, never logged as a failure), or the error -// itself otherwise — a genuinely fatal transport condition that legitimately -// closes the stream with a gRPC status. -func terminal(err error) error { - if errors.Is(err, io.EOF) { - return nil - } - if status.Code(err) == codes.Canceled { - return nil - } - return err -} - -// connection adapts one Attach stream to the Emitter interface. mu guards -// Send, which grpc.ServerStream does not support calling concurrently from -// more than one goroutine — a real concern here, since a Provider may -// retain the Emitter past its own HandleEvent call to push an unsolicited -// ServerEvent from another goroutine while the dispatch loop above is -// concurrently emitting an in-band error for a later ClientEvent. -type connection struct { - stream frontendv1.FrontendService_AttachServer - mu sync.Mutex -} - -var _ Emitter = (*connection)(nil) - -// Emit sends event to the kernel over this connection's Attach stream. -func (c *connection) Emit(event ServerEvent) error { - out, convErr := toServerEventProto(event) - if convErr != nil { - return convErr - } - c.mu.Lock() - defer c.mu.Unlock() - return c.stream.Send(out) -} - -// emitError sends fe in-band as an ErrorEvent, correlated to requestID -// when non-nil — the mid-Attach error path (doc.go's "Error handling is -// two distinct paths, not one"). Its own Send failure is returned -// unwrapped so Attach's dispatch loop treats it exactly like any other -// broken-stream condition: fatal, closing the RPC with a gRPC status. -func (c *connection) emitError(requestID *string, fe *Error) error { - return c.Emit(ServerEvent{RequestID: requestID, Payload: ErrorEvent{Err: fe}}) -} - -// requestIDOf returns the request_id to correlate an in-band error back to -// the ClientEvent control message that triggered it -// (frontend-protocol.md's ServerEvent.request_id note), or nil for a -// session-scoped variant, which carries no request_id of its own. -func requestIDOf(event ClientEvent) *string { - switch p := event.Payload.(type) { - case CreateSession: - return strPtr(p.RequestID) - case AttachSession: - return strPtr(p.RequestID) - case ResumeSession: - return strPtr(p.RequestID) - case DetachSession: - return strPtr(p.RequestID) - case ListSessions: - return strPtr(p.RequestID) - default: - return nil - } -} - -func strPtr(s string) *string { return &s } diff --git a/pkg/frontend/attach_internal_test.go b/pkg/frontend/attach_internal_test.go deleted file mode 100644 index 0b5eb6b..0000000 --- a/pkg/frontend/attach_internal_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package frontend - -import ( - "errors" - "io" - "testing" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func TestTerminal(t *testing.T) { - t.Parallel() - - if got := terminal(io.EOF); got != nil { - t.Errorf("terminal(io.EOF) = %v, want nil", got) - } - - canceled := status.Error(codes.Canceled, "client canceled") - if got := terminal(canceled); got != nil { - t.Errorf("terminal(Canceled) = %v, want nil", got) - } - - other := errors.New("transport broke") - if got := terminal(other); got != other { //nolint:errorlint // exact identity, not classification, is what's under test - t.Errorf("terminal(other) = %v, want %v", got, other) - } -} - -func TestRequestIDOf(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - event ClientEvent - want *string - }{ - {"create_session", ClientEvent{Payload: CreateSession{RequestID: "r1"}}, strPtr("r1")}, - {"attach_session", ClientEvent{Payload: AttachSession{RequestID: "r2"}}, strPtr("r2")}, - {"resume_session", ClientEvent{Payload: ResumeSession{RequestID: "r3"}}, strPtr("r3")}, - {"detach_session", ClientEvent{Payload: DetachSession{RequestID: "r4"}}, strPtr("r4")}, - {"list_sessions", ClientEvent{Payload: ListSessions{RequestID: "r5"}}, strPtr("r5")}, - {"user_message has no request_id", ClientEvent{Payload: UserMessage{}}, nil}, - {"hello has no request_id", ClientEvent{Payload: Hello{}}, nil}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := requestIDOf(tt.event) - switch { - case got == nil && tt.want == nil: - case got == nil || tt.want == nil: - t.Errorf("requestIDOf() = %v, want %v", got, tt.want) - case *got != *tt.want: - t.Errorf("requestIDOf() = %q, want %q", *got, *tt.want) - } - }) - } -} diff --git a/pkg/frontend/attach_test.go b/pkg/frontend/attach_test.go deleted file mode 100644 index f94d1fc..0000000 --- a/pkg/frontend/attach_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package frontend_test - -import ( - "context" - "errors" - "io" - "sync" - "testing" - "time" - - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - "github.com/pluggableharness/agent/pkg/frontend" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" -) - -// attachClient starts a Service wrapping provider and opens one Attach -// stream against it, mirroring how the kernel — the FrontendServiceClient -// for this RPC, per doc.go's "Wire direction" — would call in. -func attachClient(t *testing.T, provider frontend.Provider) frontendv1.FrontendService_AttachClient { - t.Helper() - - client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) - stream, err := client.Attach(t.Context()) - if err != nil { - t.Fatalf("Attach() error = %v", err) - } - return stream -} - -func userMessageEvent(sessionID, text string) *frontendv1.ClientEvent { - return &frontendv1.ClientEvent{ - SessionId: sessionID, - Event: &frontendv1.ClientEvent_UserMessage_{ - UserMessage: &frontendv1.ClientEvent_UserMessage{ - Content: []*contentv1.ContentBlock{{ - Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: text}}, - }}, - }, - }, - } -} - -func interruptEvent() *frontendv1.ClientEvent { - return &frontendv1.ClientEvent{ - SessionId: "sess-1", - Event: &frontendv1.ClientEvent_Interrupt_{Interrupt: &frontendv1.ClientEvent_Interrupt{}}, - } -} - -// TestAttach_SessionDemux sends ClientEvents for two different sessions -// interleaved on the one Attach stream and checks each is dispatched with -// its own session_id, and each reply is tagged back with the matching -// session_id — frontend-protocol.md's per-session multiplexing over one -// connection-scoped stream. -func TestAttach_SessionDemux(t *testing.T) { - t.Parallel() - - var mu sync.Mutex - seen := map[string]int{} - - provider := &fakeProvider{ - handleEventFunc: func(_ context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { - mu.Lock() - seen[event.SessionID]++ - mu.Unlock() - - um, ok := event.Payload.(frontend.UserMessage) - if !ok { - return nil - } - return emit.Emit(frontend.ServerEvent{ - SessionID: event.SessionID, - Payload: frontend.StreamDelta{TargetID: "t", Text: um.Content[0].GetText().GetText()}, - }) - }, - } - - stream := attachClient(t, provider) - - if err := stream.Send(userMessageEvent("sess-a", "hello-a")); err != nil { - t.Fatalf("Send(sess-a) error = %v", err) - } - if err := stream.Send(userMessageEvent("sess-b", "hello-b")); err != nil { - t.Fatalf("Send(sess-b) error = %v", err) - } - - gotA, gotB := false, false - for range 2 { - resp, err := stream.Recv() - if err != nil { - t.Fatalf("Recv() error = %v", err) - } - delta := resp.GetStreamDelta() - if delta == nil { - t.Fatalf("Recv() = %v, want stream_delta", resp) - } - switch resp.GetSessionId() { - case "sess-a": - if delta.GetText() != "hello-a" { - t.Errorf("sess-a delta text = %q, want hello-a", delta.GetText()) - } - gotA = true - case "sess-b": - if delta.GetText() != "hello-b" { - t.Errorf("sess-b delta text = %q, want hello-b", delta.GetText()) - } - gotB = true - default: - t.Errorf("unexpected session_id %q", resp.GetSessionId()) - } - } - if !gotA || !gotB { - t.Errorf("did not receive replies for both sessions: gotA=%v gotB=%v", gotA, gotB) - } - - mu.Lock() - defer mu.Unlock() - if seen["sess-a"] != 1 || seen["sess-b"] != 1 { - t.Errorf("HandleEvent call counts = %v, want 1 for each session", seen) - } -} - -// TestAttach_RequestIDCorrelation sends a CreateSession control event and -// checks the request_id the Provider echoes back arrives unchanged on the -// ServerEvent that answers it. -func TestAttach_RequestIDCorrelation(t *testing.T) { - t.Parallel() - - provider := &fakeProvider{ - handleEventFunc: func(_ context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { - cs, ok := event.Payload.(frontend.CreateSession) - if !ok { - return nil - } - reqID := cs.RequestID - return emit.Emit(frontend.ServerEvent{ - SessionID: "new-sess", - RequestID: &reqID, - Payload: frontend.SessionCreated{}, - }) - }, - } - - stream := attachClient(t, provider) - - if err := stream.Send(&frontendv1.ClientEvent{ - Event: &frontendv1.ClientEvent_CreateSession_{ - CreateSession: &frontendv1.ClientEvent_CreateSession{RequestId: "req-42"}, - }, - }); err != nil { - t.Fatalf("Send() error = %v", err) - } - - resp, err := stream.Recv() - if err != nil { - t.Fatalf("Recv() error = %v", err) - } - if resp.GetSessionCreated() == nil { - t.Fatalf("Recv() = %v, want session_created", resp) - } - if resp.GetRequestId() != "req-42" { - t.Errorf("RequestId = %q, want req-42", resp.GetRequestId()) - } -} - -// TestAttach_InBandErrorKeepsStreamOpen checks that an ordinary error -// returned from HandleEvent surfaces as an in-band ServerEvent.error and -// the stream remains usable for subsequent events afterward — doc.go's -// "Error handling is two distinct paths, not one". -func TestAttach_InBandErrorKeepsStreamOpen(t *testing.T) { - t.Parallel() - - calls := 0 - provider := &fakeProvider{ - handleEventFunc: func(_ context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { - calls++ - if calls == 1 { - return errors.New("recoverable failure") - } - return emit.Emit(frontend.ServerEvent{SessionID: event.SessionID, Payload: frontend.StreamDelta{Text: "ok"}}) - }, - } - - stream := attachClient(t, provider) - - if err := stream.Send(interruptEvent()); err != nil { - t.Fatalf("Send() (first) error = %v", err) - } - resp1, err := stream.Recv() - if err != nil { - t.Fatalf("Recv() (first) error = %v", err) - } - errEvent := resp1.GetError() - if errEvent == nil { - t.Fatalf("Recv() (first) = %v, want error", resp1) - } - if got := errEvent.GetError().GetCategory(); got != frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN { - t.Errorf("category = %v, want UNKNOWN", got) - } - - // The stream stays open: a second event is still handled normally. - if err := stream.Send(interruptEvent()); err != nil { - t.Fatalf("Send() (second) error = %v", err) - } - resp2, err := stream.Recv() - if err != nil { - t.Fatalf("Recv() (second) error = %v", err) - } - if got := resp2.GetStreamDelta().GetText(); got != "ok" { - t.Errorf("second response = %q, want ok", got) - } -} - -// TestAttach_FatalClosesStream checks that frontend.Fatal from HandleEvent -// closes the Attach RPC with a gRPC status instead of reporting in-band. -func TestAttach_FatalClosesStream(t *testing.T) { - t.Parallel() - - provider := &fakeProvider{ - handleEventFunc: func(context.Context, frontend.ClientEvent, frontend.Emitter) error { - return frontend.Fatal(errors.New("plugin process is dying")) - }, - } - - stream := attachClient(t, provider) - - if err := stream.Send(interruptEvent()); err != nil { - t.Fatalf("Send() error = %v", err) - } - - _, err := stream.Recv() - if err == nil { - t.Fatalf("Recv() = nil error, want the stream to close after a Fatal HandleEvent") - } - if errors.Is(err, io.EOF) { - t.Errorf("Recv() = io.EOF, want a non-EOF error carrying the fatal condition") - } -} - -// TestAttach_InvalidClientEvent checks that a session-scoped ClientEvent -// arriving with an empty session_id is rejected in-band as -// FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT without ever reaching the -// Provider — frontend-protocol.md's error taxonomy. -func TestAttach_InvalidClientEvent(t *testing.T) { - t.Parallel() - - called := false - provider := &fakeProvider{ - handleEventFunc: func(context.Context, frontend.ClientEvent, frontend.Emitter) error { - called = true - return nil - }, - } - - stream := attachClient(t, provider) - - if err := stream.Send(&frontendv1.ClientEvent{ - Event: &frontendv1.ClientEvent_UserMessage_{UserMessage: &frontendv1.ClientEvent_UserMessage{}}, - }); err != nil { - t.Fatalf("Send() error = %v", err) - } - - resp, err := stream.Recv() - if err != nil { - t.Fatalf("Recv() error = %v", err) - } - errEvent := resp.GetError() - if errEvent == nil { - t.Fatalf("Recv() = %v, want error", resp) - } - if got := errEvent.GetError().GetCategory(); got != frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT { - t.Errorf("category = %v, want INVALID_CLIENT_EVENT", got) - } - if called { - t.Errorf("HandleEvent was called for a malformed ClientEvent; want it skipped") - } -} - -// TestAttach_UnicastNotBroadcast opens two independent Attach connections -// against the same Service and checks that a reply emitted on one -// connection is never observed on the other — frontend-protocol.md's -// "Backfill is unicast to the attaching stream only, never broadcast", -// generalized to this package's own connection-scoped Emitter: nothing in -// this SDK fans an emitted ServerEvent out beyond the *connection that -// produced it. -func TestAttach_UnicastNotBroadcast(t *testing.T) { - t.Parallel() - - provider := &fakeProvider{ - handleEventFunc: func(_ context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { - return emit.Emit(frontend.ServerEvent{SessionID: event.SessionID, Payload: frontend.StreamDelta{Text: "reply"}}) - }, - } - - client := newTestServer(t, frontend.NewService(provider, testIdentity, nil)) - - streamA, err := client.Attach(t.Context()) - if err != nil { - t.Fatalf("Attach() (A) error = %v", err) - } - streamB, err := client.Attach(t.Context()) - if err != nil { - t.Fatalf("Attach() (B) error = %v", err) - } - - if err := streamA.Send(interruptEvent()); err != nil { - t.Fatalf("Send() error = %v", err) - } - - respA, err := streamA.Recv() - if err != nil { - t.Fatalf("Recv() (A) error = %v", err) - } - if got := respA.GetStreamDelta().GetText(); got != "reply" { - t.Fatalf("stream A reply = %q, want reply", got) - } - - // stream B must never observe A's reply. Bound the "nothing happened" - // wait with a short, overridable timeout rather than blocking forever. - recvB := make(chan struct{}) - go func() { - _, _ = streamB.Recv() - close(recvB) - }() - select { - case <-recvB: - t.Errorf("stream B received an event that was only ever emitted on stream A") - case <-time.After(150 * time.Millisecond): - // Expected: B never receives anything. - } -} diff --git a/pkg/frontend/capabilities.go b/pkg/frontend/capabilities.go index bd83568..ee1448c 100644 --- a/pkg/frontend/capabilities.go +++ b/pkg/frontend/capabilities.go @@ -4,7 +4,6 @@ import ( commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" ) // CapabilitiesOption configures one optional field of a Capabilities built @@ -17,12 +16,6 @@ func WithSlashCommands(commands ...*commonv1.PromptExpansionSpec) CapabilitiesOp return func(c *Capabilities) { c.SlashCommands = commands } } -// WithSupportedRegions sets the Regions this frontend proactively declares -// it can render into. -func WithSupportedRegions(regions ...renderv1.Region) CapabilitiesOption { - return func(c *Capabilities) { c.SupportedRegions = regions } -} - // WithSupportedHookPoints sets the hook points this frontend can // subscribe to. func WithSupportedHookPoints(points ...commonv1.HookPoint) CapabilitiesOption { @@ -52,7 +45,6 @@ func capabilitiesToProto(c *Capabilities) *frontendv1.FrontendCapabilities { return &frontendv1.FrontendCapabilities{ SlashCommands: c.SlashCommands, ConfigSchema: c.ConfigSchema, - SupportedRegions: c.SupportedRegions, SupportedHookPoints: c.SupportedHookPoints, } } diff --git a/pkg/frontend/capabilities_test.go b/pkg/frontend/capabilities_test.go index 874d01c..527ed11 100644 --- a/pkg/frontend/capabilities_test.go +++ b/pkg/frontend/capabilities_test.go @@ -8,7 +8,6 @@ import ( "github.com/pluggableharness/agent/pkg/config" configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" "github.com/pluggableharness/agent/pkg/frontend" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" ) func TestNewCapabilities(t *testing.T) { @@ -27,7 +26,6 @@ func TestNewCapabilities(t *testing.T) { caps := frontend.NewCapabilities(schema, frontend.WithSlashCommands(slash), - frontend.WithSupportedRegions(renderv1.Region_REGION_MAIN_CHAT, renderv1.Region_REGION_OVERLAY), frontend.WithSupportedHookPoints(commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL), ) @@ -37,10 +35,6 @@ func TestNewCapabilities(t *testing.T) { if len(caps.SlashCommands) != 1 || caps.SlashCommands[0] != slash { t.Errorf("SlashCommands = %v, want [%v]", caps.SlashCommands, slash) } - wantRegions := []renderv1.Region{renderv1.Region_REGION_MAIN_CHAT, renderv1.Region_REGION_OVERLAY} - if !reflect.DeepEqual(caps.SupportedRegions, wantRegions) { - t.Errorf("SupportedRegions = %v, want %v", caps.SupportedRegions, wantRegions) - } wantHooks := []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL} if !reflect.DeepEqual(caps.SupportedHookPoints, wantHooks) { t.Errorf("SupportedHookPoints = %v, want %v", caps.SupportedHookPoints, wantHooks) diff --git a/pkg/frontend/convert.go b/pkg/frontend/convert.go deleted file mode 100644 index a81c4a8..0000000 --- a/pkg/frontend/convert.go +++ /dev/null @@ -1,421 +0,0 @@ -package frontend - -import ( - "errors" - "fmt" - - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" -) - -// Sentinel errors identifying which invariant a malformed ClientEvent or -// ServerEvent violated — compare with errors.Is. -var ( - // ErrMissingSessionID is returned by fromClientEventProto when a - // session-scoped variant (user_message..interrupt) arrives with an - // empty top-level session_id — frontend-protocol.md's error taxonomy - // names this FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT explicitly. - ErrMissingSessionID = errors.New("frontend: session-scoped client event missing session_id") - // ErrUnexpectedSessionID is returned by fromClientEventProto when a - // connection-level control variant (hello..list_sessions) arrives - // with a non-empty top-level session_id, which none of the six ever - // has a session to scope to before its own response arrives. - ErrUnexpectedSessionID = errors.New("frontend: control client event carries unexpected session_id") - // ErrEmptyClientEvent is returned by fromClientEventProto when the - // generated ClientEvent's oneof carries no variant at all. - ErrEmptyClientEvent = errors.New("frontend: client event carries no variant") - // ErrEmptyServerEvent is returned by toServerEventProto/ - // fromServerEventProto when a ServerEvent carries a nil Payload. - ErrEmptyServerEvent = errors.New("frontend: server event carries no payload") - // ErrNilFrontendError is returned when an ErrorEvent's Err field is - // nil — a caller-constructed ServerEvent that skipped the one field - // ErrorEvent exists to carry. - ErrNilFrontendError = errors.New("frontend: error event carries no Error") -) - -// fromClientEventProto converts in into its domain form, validating the -// session_id placement invariant frontend-protocol.md's ClientEvent -// section documents (REQUIRED for session-scoped variants, empty for the -// six connection-level control variants). -func fromClientEventProto(in *frontendv1.ClientEvent) (ClientEvent, error) { - sessionID := in.GetSessionId() - - switch ev := in.GetEvent().(type) { - case *frontendv1.ClientEvent_UserMessage_: - if sessionID == "" { - return ClientEvent{}, ErrMissingSessionID - } - return ClientEvent{SessionID: sessionID, Payload: UserMessage{ - Content: ev.UserMessage.GetContent(), - }}, nil - - case *frontendv1.ClientEvent_SlashCommand_: - if sessionID == "" { - return ClientEvent{}, ErrMissingSessionID - } - return ClientEvent{SessionID: sessionID, Payload: SlashCommand{ - Name: ev.SlashCommand.GetName(), - Args: ev.SlashCommand.GetArgs(), - }}, nil - - case *frontendv1.ClientEvent_PlanDecision_: - if sessionID == "" { - return ClientEvent{}, ErrMissingSessionID - } - pd := ev.PlanDecision - return ClientEvent{SessionID: sessionID, Payload: PlanDecision{ - PlanItemID: pd.GetPlanItemId(), - Decision: pd.GetDecision(), - CorrectedInput: pd.GetCorrectedInput(), - Scope: planScopeFromProto(pd.GetScope()), - }}, nil - - case *frontendv1.ClientEvent_InteractiveResponse_: - if sessionID == "" { - return ClientEvent{}, ErrMissingSessionID - } - ir := ev.InteractiveResponse - return ClientEvent{SessionID: sessionID, Payload: InteractiveResponse{ - CallID: ir.GetCallId(), - Response: ir.GetResponse(), - }}, nil - - case *frontendv1.ClientEvent_ActionTrigger_: - if sessionID == "" { - return ClientEvent{}, ErrMissingSessionID - } - at := ev.ActionTrigger - return ClientEvent{SessionID: sessionID, Payload: ActionTrigger{ - NodeID: at.GetNodeId(), - ToolName: at.GetToolName(), - Args: at.GetArgs(), - Provider: at.GetProvider(), - }}, nil - - case *frontendv1.ClientEvent_Interrupt_: - if sessionID == "" { - return ClientEvent{}, ErrMissingSessionID - } - return ClientEvent{SessionID: sessionID, Payload: Interrupt{}}, nil - - case *frontendv1.ClientEvent_Hello_: - if sessionID != "" { - return ClientEvent{}, ErrUnexpectedSessionID - } - return ClientEvent{Payload: Hello{ProtocolVersion: ev.Hello.GetProtocolVersion()}}, nil - - case *frontendv1.ClientEvent_CreateSession_: - if sessionID != "" { - return ClientEvent{}, ErrUnexpectedSessionID - } - cs := ev.CreateSession - return ClientEvent{Payload: CreateSession{ - RequestID: cs.GetRequestId(), - Profile: cs.Profile, - InitialPrompt: cs.InitialPrompt, - WorkingDirectory: cs.WorkingDirectory, - }}, nil - - case *frontendv1.ClientEvent_AttachSession_: - if sessionID != "" { - return ClientEvent{}, ErrUnexpectedSessionID - } - as := ev.AttachSession - return ClientEvent{Payload: AttachSession{ - RequestID: as.GetRequestId(), - SessionID: as.GetSessionId(), - }}, nil - - case *frontendv1.ClientEvent_ResumeSession_: - if sessionID != "" { - return ClientEvent{}, ErrUnexpectedSessionID - } - rs := ev.ResumeSession - return ClientEvent{Payload: ResumeSession{ - RequestID: rs.GetRequestId(), - SessionID: rs.GetSessionId(), - }}, nil - - case *frontendv1.ClientEvent_DetachSession_: - if sessionID != "" { - return ClientEvent{}, ErrUnexpectedSessionID - } - ds := ev.DetachSession - return ClientEvent{Payload: DetachSession{ - RequestID: ds.GetRequestId(), - SessionID: ds.GetSessionId(), - }}, nil - - case *frontendv1.ClientEvent_ListSessions_: - if sessionID != "" { - return ClientEvent{}, ErrUnexpectedSessionID - } - ls := ev.ListSessions - return ClientEvent{Payload: ListSessions{ - RequestID: ls.GetRequestId(), - Status: ls.Status, - ParentSessionID: ls.ParentSessionId, - RootsOnly: ls.GetRootsOnly(), - }}, nil - - default: - return ClientEvent{}, ErrEmptyClientEvent - } -} - -// toClientEventProto converts ev into its generated wire form. Provided -// for symmetry and so a test (in this package or a plugin author's own) -// can build a ClientEvent to send without importing frontendv1 directly. -func toClientEventProto(ev ClientEvent) (*frontendv1.ClientEvent, error) { - out := &frontendv1.ClientEvent{SessionId: ev.SessionID} - - switch p := ev.Payload.(type) { - case UserMessage: - out.Event = &frontendv1.ClientEvent_UserMessage_{ - UserMessage: &frontendv1.ClientEvent_UserMessage{Content: p.Content}, - } - case SlashCommand: - out.Event = &frontendv1.ClientEvent_SlashCommand_{ - SlashCommand: &frontendv1.ClientEvent_SlashCommand{Name: p.Name, Args: p.Args}, - } - case PlanDecision: - out.Event = &frontendv1.ClientEvent_PlanDecision_{ - PlanDecision: &frontendv1.ClientEvent_PlanDecision{ - PlanItemId: p.PlanItemID, - Decision: p.Decision, - CorrectedInput: p.CorrectedInput, - Scope: planScopeToProto(p.Scope), - }, - } - case InteractiveResponse: - out.Event = &frontendv1.ClientEvent_InteractiveResponse_{ - InteractiveResponse: &frontendv1.ClientEvent_InteractiveResponse{ - CallId: p.CallID, - Response: p.Response, - }, - } - case ActionTrigger: - out.Event = &frontendv1.ClientEvent_ActionTrigger_{ - ActionTrigger: &frontendv1.ClientEvent_ActionTrigger{ - NodeId: p.NodeID, - ToolName: p.ToolName, - Args: p.Args, - Provider: p.Provider, - }, - } - case Interrupt: - out.Event = &frontendv1.ClientEvent_Interrupt_{Interrupt: &frontendv1.ClientEvent_Interrupt{}} - case Hello: - out.Event = &frontendv1.ClientEvent_Hello_{ - Hello: &frontendv1.ClientEvent_Hello{ProtocolVersion: p.ProtocolVersion}, - } - case CreateSession: - out.Event = &frontendv1.ClientEvent_CreateSession_{ - CreateSession: &frontendv1.ClientEvent_CreateSession{ - RequestId: p.RequestID, - Profile: p.Profile, - InitialPrompt: p.InitialPrompt, - WorkingDirectory: p.WorkingDirectory, - }, - } - case AttachSession: - out.Event = &frontendv1.ClientEvent_AttachSession_{ - AttachSession: &frontendv1.ClientEvent_AttachSession{RequestId: p.RequestID, SessionId: p.SessionID}, - } - case ResumeSession: - out.Event = &frontendv1.ClientEvent_ResumeSession_{ - ResumeSession: &frontendv1.ClientEvent_ResumeSession{RequestId: p.RequestID, SessionId: p.SessionID}, - } - case DetachSession: - out.Event = &frontendv1.ClientEvent_DetachSession_{ - DetachSession: &frontendv1.ClientEvent_DetachSession{RequestId: p.RequestID, SessionId: p.SessionID}, - } - case ListSessions: - out.Event = &frontendv1.ClientEvent_ListSessions_{ - ListSessions: &frontendv1.ClientEvent_ListSessions{ - RequestId: p.RequestID, - Status: p.Status, - ParentSessionId: p.ParentSessionID, - RootsOnly: p.RootsOnly, - }, - } - default: - return nil, fmt.Errorf("frontend: to client event proto: %w", ErrEmptyClientEvent) - } - - return out, nil -} - -// fromServerEventProto converts in into its domain form. -func fromServerEventProto(in *frontendv1.ServerEvent) (ServerEvent, error) { - out := ServerEvent{SessionID: in.GetSessionId(), RequestID: in.RequestId} - - switch ev := in.GetEvent().(type) { - case *frontendv1.ServerEvent_StreamDelta_: - out.Payload = StreamDelta{TargetID: ev.StreamDelta.GetTargetId(), Text: ev.StreamDelta.GetText()} - case *frontendv1.ServerEvent_Render_: - out.Payload = Render{Content: ev.Render.GetContent()} - case *frontendv1.ServerEvent_PermissionRequest_: - out.Payload = PermissionRequest{PlanItem: ev.PermissionRequest.GetPlanItem()} - case *frontendv1.ServerEvent_PlanReady_: - out.Payload = PlanReady{Plan: ev.PlanReady.GetPlan()} - case *frontendv1.ServerEvent_InteractiveRequest_: - out.Payload = InteractiveRequest{ - CallID: ev.InteractiveRequest.GetCallId(), - ToolName: ev.InteractiveRequest.GetToolName(), - Prompt: ev.InteractiveRequest.GetPrompt(), - } - case *frontendv1.ServerEvent_SessionTreeUpdate_: - out.Payload = SessionTreeUpdate{ - ParentSessionID: ev.SessionTreeUpdate.GetParentSessionId(), - ChildSessionID: ev.SessionTreeUpdate.GetChildSessionId(), - Status: ev.SessionTreeUpdate.GetStatus(), - } - case *frontendv1.ServerEvent_Error_: - fe := ev.Error.GetError() - out.Payload = ErrorEvent{Err: &Error{Category: fe.GetCategory(), Message: fe.GetMessage()}} - case *frontendv1.ServerEvent_SessionCreated_: - out.Payload = SessionCreated{Info: ev.SessionCreated.GetInfo()} - case *frontendv1.ServerEvent_SessionAttached_: - out.Payload = SessionAttached{Info: ev.SessionAttached.GetInfo()} - case *frontendv1.ServerEvent_BackfillComplete_: - out.Payload = BackfillComplete{LastSequence: ev.BackfillComplete.GetLastSequence()} - case *frontendv1.ServerEvent_SessionDetached_: - out.Payload = SessionDetached{} - case *frontendv1.ServerEvent_SessionList_: - out.Payload = SessionList{Sessions: ev.SessionList.GetSessions()} - case *frontendv1.ServerEvent_SlashCommandRegistry_: - out.Payload = SlashCommandRegistry{ - DirectInvokeCommands: ev.SlashCommandRegistry.GetDirectInvokeCommands(), - PromptExpansionCommands: ev.SlashCommandRegistry.GetPromptExpansionCommands(), - } - case *frontendv1.ServerEvent_UsageUpdate_: - out.Payload = UsageUpdate{ - Turn: ev.UsageUpdate.GetTurn(), - CumulativeCostUSD: ev.UsageUpdate.GetCumulativeCostUsd(), - UsedTokens: ev.UsageUpdate.GetUsedTokens(), - EffectiveCeiling: ev.UsageUpdate.GetEffectiveCeiling(), - } - case *frontendv1.ServerEvent_SessionStatusUpdate_: - out.Payload = SessionStatusUpdate{Status: ev.SessionStatusUpdate.GetStatus()} - default: - return ServerEvent{}, ErrEmptyServerEvent - } - - return out, nil -} - -// toServerEventProto converts ev into its generated wire form. -func toServerEventProto(ev ServerEvent) (*frontendv1.ServerEvent, error) { - out := &frontendv1.ServerEvent{SessionId: ev.SessionID, RequestId: ev.RequestID} - - switch p := ev.Payload.(type) { - case StreamDelta: - out.Event = &frontendv1.ServerEvent_StreamDelta_{ - StreamDelta: &frontendv1.ServerEvent_StreamDelta{TargetId: p.TargetID, Text: p.Text}, - } - case Render: - out.Event = &frontendv1.ServerEvent_Render_{Render: &frontendv1.ServerEvent_Render{Content: p.Content}} - case PermissionRequest: - out.Event = &frontendv1.ServerEvent_PermissionRequest_{ - PermissionRequest: &frontendv1.ServerEvent_PermissionRequest{PlanItem: p.PlanItem}, - } - case PlanReady: - out.Event = &frontendv1.ServerEvent_PlanReady_{PlanReady: &frontendv1.ServerEvent_PlanReady{Plan: p.Plan}} - case InteractiveRequest: - out.Event = &frontendv1.ServerEvent_InteractiveRequest_{ - InteractiveRequest: &frontendv1.ServerEvent_InteractiveRequest{ - CallId: p.CallID, - ToolName: p.ToolName, - Prompt: p.Prompt, - }, - } - case SessionTreeUpdate: - out.Event = &frontendv1.ServerEvent_SessionTreeUpdate_{ - SessionTreeUpdate: &frontendv1.ServerEvent_SessionTreeUpdate{ - ParentSessionId: p.ParentSessionID, - ChildSessionId: p.ChildSessionID, - Status: p.Status, - }, - } - case ErrorEvent: - if p.Err == nil { - return nil, ErrNilFrontendError - } - out.Event = &frontendv1.ServerEvent_Error_{ - Error: &frontendv1.ServerEvent_Error{ - Error: &frontendv1.FrontendError{Category: p.Err.Category, Message: p.Err.Message}, - }, - } - case SessionCreated: - out.Event = &frontendv1.ServerEvent_SessionCreated_{ - SessionCreated: &frontendv1.ServerEvent_SessionCreated{Info: p.Info}, - } - case SessionAttached: - out.Event = &frontendv1.ServerEvent_SessionAttached_{ - SessionAttached: &frontendv1.ServerEvent_SessionAttached{Info: p.Info}, - } - case BackfillComplete: - out.Event = &frontendv1.ServerEvent_BackfillComplete_{ - BackfillComplete: &frontendv1.ServerEvent_BackfillComplete{LastSequence: p.LastSequence}, - } - case SessionDetached: - out.Event = &frontendv1.ServerEvent_SessionDetached_{SessionDetached: &frontendv1.ServerEvent_SessionDetached{}} - case SessionList: - out.Event = &frontendv1.ServerEvent_SessionList_{ - SessionList: &frontendv1.ServerEvent_SessionList{Sessions: p.Sessions}, - } - case SlashCommandRegistry: - out.Event = &frontendv1.ServerEvent_SlashCommandRegistry_{ - SlashCommandRegistry: &frontendv1.ServerEvent_SlashCommandRegistry{ - DirectInvokeCommands: p.DirectInvokeCommands, - PromptExpansionCommands: p.PromptExpansionCommands, - }, - } - case UsageUpdate: - out.Event = &frontendv1.ServerEvent_UsageUpdate_{ - UsageUpdate: &frontendv1.ServerEvent_UsageUpdate{ - Turn: p.Turn, - CumulativeCostUsd: p.CumulativeCostUSD, - UsedTokens: p.UsedTokens, - EffectiveCeiling: p.EffectiveCeiling, - }, - } - case SessionStatusUpdate: - out.Event = &frontendv1.ServerEvent_SessionStatusUpdate_{ - SessionStatusUpdate: &frontendv1.ServerEvent_SessionStatusUpdate{Status: p.Status}, - } - default: - return nil, ErrEmptyServerEvent - } - - return out, nil -} - -// planScopeFromProto converts a wire PlanDecisionScope to its domain -// PlanScope, mapping both PLAN_DECISION_SCOPE_UNSPECIFIED and -// PLAN_DECISION_SCOPE_ONCE to PlanScopeOnce — see PlanScope's doc comment. -func planScopeFromProto(s frontendv1.PlanDecisionScope) PlanScope { - switch s { - case frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION: - return PlanScopeSession - case frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS: - return PlanScopeAlways - default: - return PlanScopeOnce - } -} - -// planScopeToProto converts a domain PlanScope to its wire -// PlanDecisionScope. Never produces PLAN_DECISION_SCOPE_UNSPECIFIED — the -// domain type's zero value, PlanScopeOnce, already carries the -// spec-mandated default. -func planScopeToProto(s PlanScope) frontendv1.PlanDecisionScope { - switch s { - case PlanScopeSession: - return frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION - case PlanScopeAlways: - return frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS - default: - return frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE - } -} diff --git a/pkg/frontend/convert_test.go b/pkg/frontend/convert_test.go deleted file mode 100644 index 70fa9fd..0000000 --- a/pkg/frontend/convert_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package frontend - -import ( - "errors" - "reflect" - "testing" - - "google.golang.org/protobuf/types/known/structpb" - - commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" - sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" - slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" -) - -func strp(s string) *string { return &s } - -func TestClientEventRoundTrip(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - event ClientEvent - }{ - {"user_message", ClientEvent{SessionID: "sess-1", Payload: UserMessage{ - Content: []*contentv1.ContentBlock{{Block: &contentv1.ContentBlock_Text{Text: &contentv1.TextBlock{Text: "hi"}}}}, - }}}, - {"slash_command", ClientEvent{SessionID: "sess-1", Payload: SlashCommand{Name: "help", Args: "me"}}}, - {"plan_decision", ClientEvent{SessionID: "sess-1", Payload: PlanDecision{ - PlanItemID: "item-1", - Decision: frontendv1.ClientDecision_CLIENT_DECISION_ALLOW, - CorrectedInput: &structpb.Struct{}, - Scope: PlanScopeSession, - }}}, - {"interactive_response", ClientEvent{SessionID: "sess-1", Payload: InteractiveResponse{ - CallID: "call-1", Response: &structpb.Struct{}, - }}}, - {"action_trigger", ClientEvent{SessionID: "sess-1", Payload: ActionTrigger{ - NodeID: "node-1", ToolName: "grep", Args: &structpb.Struct{}, Provider: "ripgrep", - }}}, - {"interrupt", ClientEvent{SessionID: "sess-1", Payload: Interrupt{}}}, - {"hello", ClientEvent{Payload: Hello{ProtocolVersion: 3}}}, - {"create_session", ClientEvent{Payload: CreateSession{ - RequestID: "req-1", Profile: strp("default"), InitialPrompt: strp("hi"), WorkingDirectory: strp("/tmp"), - }}}, - {"attach_session", ClientEvent{Payload: AttachSession{RequestID: "req-2", SessionID: "sess-2"}}}, - {"resume_session", ClientEvent{Payload: ResumeSession{RequestID: "req-3", SessionID: "sess-3"}}}, - {"detach_session", ClientEvent{Payload: DetachSession{RequestID: "req-4", SessionID: "sess-4"}}}, - {"list_sessions", ClientEvent{Payload: ListSessions{ - RequestID: "req-5", ParentSessionID: strp("sess-0"), RootsOnly: true, - }}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - proto, err := toClientEventProto(tt.event) - if err != nil { - t.Fatalf("toClientEventProto() error = %v", err) - } - got, err := fromClientEventProto(proto) - if err != nil { - t.Fatalf("fromClientEventProto() error = %v", err) - } - if got.SessionID != tt.event.SessionID { - t.Errorf("SessionID = %q, want %q", got.SessionID, tt.event.SessionID) - } - if !reflect.DeepEqual(got.Payload, tt.event.Payload) { - t.Errorf("Payload = %#v, want %#v", got.Payload, tt.event.Payload) - } - }) - } -} - -func TestClientEvent_SessionIDValidation(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in *frontendv1.ClientEvent - wantErr error - }{ - { - name: "session-scoped missing session_id", - in: &frontendv1.ClientEvent{ - Event: &frontendv1.ClientEvent_UserMessage_{UserMessage: &frontendv1.ClientEvent_UserMessage{}}, - }, - wantErr: ErrMissingSessionID, - }, - { - name: "control variant with unexpected session_id", - in: &frontendv1.ClientEvent{ - SessionId: "sess-1", - Event: &frontendv1.ClientEvent_Hello_{Hello: &frontendv1.ClientEvent_Hello{}}, - }, - wantErr: ErrUnexpectedSessionID, - }, - { - name: "empty oneof", - in: &frontendv1.ClientEvent{}, - wantErr: ErrEmptyClientEvent, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - _, err := fromClientEventProto(tt.in) - if !errors.Is(err, tt.wantErr) { - t.Errorf("fromClientEventProto() error = %v, want %v", err, tt.wantErr) - } - }) - } -} - -func TestToClientEventProto_EmptyPayload(t *testing.T) { - t.Parallel() - - _, err := toClientEventProto(ClientEvent{}) - if !errors.Is(err, ErrEmptyClientEvent) { - t.Errorf("toClientEventProto(empty) error = %v, want %v", err, ErrEmptyClientEvent) - } -} - -func TestServerEventRoundTrip(t *testing.T) { - t.Parallel() - - requestID := strp("req-9") - - tests := []struct { - name string - event ServerEvent - }{ - {"stream_delta", ServerEvent{SessionID: "s1", Payload: StreamDelta{TargetID: "t1", Text: "chunk"}}}, - {"render", ServerEvent{SessionID: "s1", Payload: Render{Content: &renderv1.PlacedContent{Region: renderv1.Region_REGION_MAIN_CHAT}}}}, - {"permission_request", ServerEvent{SessionID: "s1", Payload: PermissionRequest{}}}, - {"plan_ready", ServerEvent{SessionID: "s1", Payload: PlanReady{}}}, - {"interactive_request", ServerEvent{SessionID: "s1", Payload: InteractiveRequest{CallID: "c1", ToolName: "ask"}}}, - {"session_tree_update", ServerEvent{SessionID: "s1", Payload: SessionTreeUpdate{ - ParentSessionID: "p1", ChildSessionID: "c1", Status: sessionv1.SessionStatus_SESSION_STATUS_RUNNING, - }}}, - {"error", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: ErrorEvent{ - Err: &Error{Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_RENDER_FAILED, Message: "bad diff"}, - }}}, - {"session_created", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: SessionCreated{Info: &sessionv1.SessionInfo{SessionId: "s1"}}}}, - {"session_attached", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: SessionAttached{Info: &sessionv1.SessionInfo{SessionId: "s1"}}}}, - {"backfill_complete", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: BackfillComplete{LastSequence: 42}}}, - {"session_detached", ServerEvent{SessionID: "s1", RequestID: requestID, Payload: SessionDetached{}}}, - {"session_list", ServerEvent{RequestID: requestID, Payload: SessionList{Sessions: []*sessionv1.SessionInfo{{SessionId: "s1"}}}}}, - {"slash_command_registry", ServerEvent{SessionID: "s1", Payload: SlashCommandRegistry{ - DirectInvokeCommands: []*slashcommandv1.SlashCommandSpec{{Name: "run"}}, - PromptExpansionCommands: []*commonv1.PromptExpansionSpec{{Name: "help"}}, - }}}, - {"usage_update", ServerEvent{SessionID: "s1", Payload: UsageUpdate{ - CumulativeCostUSD: 1.5, UsedTokens: 100, EffectiveCeiling: 200, - }}}, - {"session_status_update", ServerEvent{SessionID: "s1", Payload: SessionStatusUpdate{Status: sessionv1.SessionStatus_SESSION_STATUS_COMPLETED}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - proto, err := toServerEventProto(tt.event) - if err != nil { - t.Fatalf("toServerEventProto() error = %v", err) - } - got, err := fromServerEventProto(proto) - if err != nil { - t.Fatalf("fromServerEventProto() error = %v", err) - } - if got.SessionID != tt.event.SessionID { - t.Errorf("SessionID = %q, want %q", got.SessionID, tt.event.SessionID) - } - gotReq, wantReq := got.RequestID, tt.event.RequestID - switch { - case gotReq == nil && wantReq == nil: - case gotReq == nil || wantReq == nil: - t.Errorf("RequestID = %v, want %v", gotReq, wantReq) - case *gotReq != *wantReq: - t.Errorf("RequestID = %q, want %q", *gotReq, *wantReq) - } - }) - } -} - -func TestToServerEventProto_EmptyPayload(t *testing.T) { - t.Parallel() - - _, err := toServerEventProto(ServerEvent{}) - if !errors.Is(err, ErrEmptyServerEvent) { - t.Errorf("toServerEventProto(empty) error = %v, want %v", err, ErrEmptyServerEvent) - } -} - -func TestToServerEventProto_NilFrontendError(t *testing.T) { - t.Parallel() - - _, err := toServerEventProto(ServerEvent{Payload: ErrorEvent{}}) - if !errors.Is(err, ErrNilFrontendError) { - t.Errorf("toServerEventProto(ErrorEvent{}) error = %v, want %v", err, ErrNilFrontendError) - } -} - -func TestFromServerEventProto_EmptyOneof(t *testing.T) { - t.Parallel() - - _, err := fromServerEventProto(&frontendv1.ServerEvent{}) - if !errors.Is(err, ErrEmptyServerEvent) { - t.Errorf("fromServerEventProto(empty) error = %v, want %v", err, ErrEmptyServerEvent) - } -} - -func TestPlanScopeConversion(t *testing.T) { - t.Parallel() - - tests := []struct { - domain PlanScope - proto frontendv1.PlanDecisionScope - }{ - {PlanScopeOnce, frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE}, - {PlanScopeSession, frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION}, - {PlanScopeAlways, frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS}, - } - for _, tt := range tests { - if got := planScopeToProto(tt.domain); got != tt.proto { - t.Errorf("planScopeToProto(%v) = %v, want %v", tt.domain, got, tt.proto) - } - if got := planScopeFromProto(tt.proto); got != tt.domain { - t.Errorf("planScopeFromProto(%v) = %v, want %v", tt.proto, got, tt.domain) - } - } - - // The generated enum's own zero value, UNSPECIFIED, maps to - // PlanScopeOnce too — doc.go's "PlanDecisionScope defaults to ONCE". - if got := planScopeFromProto(frontendv1.PlanDecisionScope_PLAN_DECISION_SCOPE_UNSPECIFIED); got != PlanScopeOnce { - t.Errorf("planScopeFromProto(UNSPECIFIED) = %v, want PlanScopeOnce", got) - } - if PlanScopeOnce != 0 { - t.Errorf("PlanScopeOnce = %d, want 0 (the Go zero value)", PlanScopeOnce) - } -} - -func TestPlanScope_String(t *testing.T) { - t.Parallel() - - tests := map[PlanScope]string{ - PlanScopeOnce: "once", - PlanScopeSession: "session", - PlanScopeAlways: "always", - PlanScope(99): "unknown", - } - for scope, want := range tests { - if got := scope.String(); got != want { - t.Errorf("PlanScope(%d).String() = %q, want %q", scope, got, want) - } - } -} diff --git a/pkg/frontend/doc.go b/pkg/frontend/doc.go index bb4527b..a454cf9 100644 --- a/pkg/frontend/doc.go +++ b/pkg/frontend/doc.go @@ -1,129 +1,39 @@ -// Package frontend is the hand-written, ergonomic SDK layer over the -// generated pluggableharness.frontend.v1 types in ./proto/v1, for a plugin -// author implementing a frontend provider — the plugin that owns the -// terminal (or window, or voice channel) and mediates between an operator -// and the kernel. The protocol this package implements is specified in -// docs/specifications/frontend/frontend-protocol.md, -// docs/specifications/frontend/render-tree.md (the RenderTree a frontend -// MUST be able to paint, including a node variant added after this -// package's own build — see FallbackText), and -// docs/specifications/frontend/conformance.md. -// -// # Shape -// -// A plugin author implements [Provider] — GetCapabilities, Configure, and -// event handling for the one connection-scoped, bidirectional Attach -// stream — and passes it to [NewService], which adapts it into the -// generated frontendv1.FrontendServiceServer and satisfies -// github.com/pluggableharness/agent/pkg/plugin's Service interface for use -// with plugin.Config.Services. -// -// # Attach is one stream per connection, not one per session -// -// Per frontend-protocol.md's "Transport" section, a frontend opens exactly -// one Attach stream for its connection's whole lifetime; individual -// sessions are subscribed and unsubscribed onto that single stream via the -// six session-control ClientEvent variants (hello, create_session, -// attach_session, resume_session, detach_session, list_sessions), -// correlated to their ServerEvent acknowledgments by a client-generated -// request_id. Every event on the wire, both directions, carries a -// top-level session_id that multiplexes which session it belongs to — -// [ClientEvent] and [ServerEvent] both carry SessionID as a first-class -// field for exactly this reason. This package's Attach adapter -// (attach.go) is a single per-connection dispatch loop, never one -// goroutine per session's own stream, and never fans a session's backfill -// batch out to any connection other than the one that requested it. -// -// # Wire direction: this package receives ClientEvent, sends ServerEvent -// -// The generated frontendv1.FrontendServiceServer.Attach signature — -// Attach(grpc.BidiStreamingServer[ClientEvent, ServerEvent]) error — fixes -// the mechanical wire direction for whichever process implements it: per -// architecture.md's "Transport" section ("category-client construction" is -// a kernel-side launch step for every plugin category, no exception for -// frontend) and github.com/pluggableharness/agent/pkg/plugin's -// Serve/Config (a plugin subprocess only ever runs as a gRPC server — -// its GRPCClient path is unconditionally unsupported), the kernel is the -// FrontendServiceClient that calls Attach, and this package's Service, -// registered as a plugin.Service on the frontend subprocess's own -// grpc.Server, is the FrontendServiceServer. Mechanically that means this -// package's Attach implementation RECEIVES *ClientEvent via stream.Recv() -// and SENDS *ServerEvent via stream.Send() — the reverse of -// frontend-protocol.md's plain-English framing ("the plugin ... sends -// operator input to the kernel as ClientEvents ... and receives ... -// ServerEvents"), which describes the logical origin/destination of each -// event's content rather than which side of this specific bidirectional -// RPC calls Send versus Recv. [Provider.HandleEvent] and [Emitter] are -// built around the actual, compiling mechanical direction: HandleEvent is -// invoked once per ClientEvent this adapter receives, and a Provider -// answers via the Emitter's Emit method, which sends a ServerEvent. -// -// # Fast path vs. full render -// -// [StreamDelta] and [Render] are deliberately distinct Go types (not a -// single "text update" type with a live/replayed flag) per -// frontend-protocol.md's "Fast path vs. full render" section: live -// token-by-token text streaming arrives only as StreamDelta and is never -// replayed as one on backfill, while a finished render — live or replayed -// — always arrives as Render, never as a sequence of deltas. Keeping them -// structurally separate in the [ServerEventPayload] oneof means an -// author's dispatch code cannot accidentally treat a backfilled Render as -// a live StreamDelta or vice versa. -// -// # PlanDecisionScope defaults to ONCE -// -// The generated PlanDecisionScope enum's zero value is -// PLAN_DECISION_SCOPE_UNSPECIFIED, a wire state that means "the sender -// forgot to set this." Per frontend-protocol.md's -// "plan_decision.corrected_input" section, PLAN_DECISION_SCOPE_ONCE is the -// default a frontend SHOULD send absent explicit operator intent — so this -// package's own [PlanScope] domain type reorders the values so its Go -// zero value is PlanScopeOnce, letting a zero-value [PlanDecision] already -// carry the spec-mandated default rather than an invalid UNSPECIFIED. -// -// # Error handling is two distinct paths, not one -// -// A Configure-time error surfaces as a gRPC status carrying a -// [Error] in structured detail, built via -// github.com/pluggableharness/agent/pkg/plugin's StatusError — see -// [Error.StatusErr]. An error encountered mid-Attach (a bad -// render, a malformed ClientEvent, a Provider.HandleEvent failure) instead -// surfaces in-band as ServerEvent.error, keeping the long-lived stream -// open, since tearing down the whole connection over one recoverable -// error would be far more disruptive than the single event it invalidated -// — this is the path an author's HandleEvent naturally reaches by simply -// returning an ordinary error. Only a genuinely fatal condition — the -// plugin process itself failing — legitimately closes the stream with a -// gRPC status, and doing so requires the deliberate, differently-named -// [Fatal] wrapper (see attach.go and errors.go). Conflating these two -// paths — closing the stream over an ordinary recoverable error, or -// silently swallowing a fatal one in-band — is the single most common way -// to get this package's contract wrong. -// -// # ContentBlocks, not a bare string -// -// [UserMessage] carries the same repeated content.v1.ContentBlock -// vocabulary as everywhere else in this protocol series, never a plain -// string — see github.com/pluggableharness/agent/pkg/content's Text, -// Image, Document, and other builders. Field 1 of the generated -// ClientEvent_UserMessage (the protocol's original bare text field) is -// reserved and MUST NOT be reused. -// -// # Author-side UI discipline this package cannot enforce in Go -// -// Two MUST-level rules bind a conforming frontend's own UI code, not -// anything this SDK can check at compile time or runtime, so they are -// documented prominently here instead: an [InteractiveRequest]'s Prompt -// MUST be rendered in the REGION_OVERLAY region, the same visual treatment -// as an ordinary plan-apply-gate "ask" prompt -// (render-tree.md#placement--regions); and a rendered ActionNode MUST be -// made interactive, dispatching that node's tool_name/args/provider -// unchanged as an [ActionTrigger] on activation, never rewritten -// (render-tree.md#interactive-content-the-action-node). +// Package frontend implements the hand-written, plugin-author-facing Go +// SDK for the frontend provider category — the process that owns how the +// operator sees and types (TUI, web, CLI, voice), without owning the +// agent loop (docs/specifications/frontend/). +// +// # The category triple only +// +// FrontendService exposes GetCapabilities, Configure, and Describe — the +// same three RPCs every other category has. There is no Attach stream: +// under go-plugin the plugin is the gRPC server, so the only direction +// that lets the kernel push streams into a frontend is the kernel +// callback channel (docs/specifications/kernel-callbacks.md), where the +// plugin is the client. +// +// # Four surfaces, one callback channel +// +// A frontend consumes four kernel-held surfaces over that channel: +// +// - Input — SubmitInput, ResolvePlanDecision, ResolveInteractive, +// Interrupt, InvokeSlashCommand, TriggerAction (unary) +// - State — GetSessionState snapshot + Subscribe on topic kernel.state +// - Metadata — ListMetadata snapshot, PublishMetadata/RetractMetadata, +// and Subscribe on topic kernel.metadata +// - Transcript — ReadEvents backfill + Subscribe on kernel.event.*; +// StreamDeltas for the live token fast path (not on the bus) +// +// Session lifecycle (CreateSession/AttachSession/ResumeSession/ +// DetachSession/ListSessions) is also on the callback channel. +// +// See docs/specifications/frontend/frontend-protocol.md for the full +// contract and docs/specifications/frontend/render-tree.md for the +// transcript-only RenderTree IR. package frontend -// ProtocolVersion is the version of the frontend category's own protocol this -// SDK implements — the "v1" in pluggableharness.frontend.v1. +// ProtocolVersion is the version of the frontend category's own protocol +// this SDK implements — the "v1" in pluggableharness.frontend.v1. // // Deliberately NOT pkg/common.ProtocolVersion, which versions the // go-plugin runtime contract shared by every category. The two move diff --git a/pkg/frontend/errors.go b/pkg/frontend/errors.go index a9c51e4..ca97b70 100644 --- a/pkg/frontend/errors.go +++ b/pkg/frontend/errors.go @@ -15,26 +15,19 @@ import ( // gRPC status, per .claude/rules/grpc.md's error taxonomy. const errorDomain = "frontend.pluggableharness.dev" -// Error is the domain form of frontendv1.FrontendError — the -// structured error type for this category, carried in ServerEvent.error -// mid-Attach and in the structured detail of a Configure-time gRPC status -// (doc.go's "Error handling is two distinct paths, not one"). +// Error is the domain form of frontendv1.FrontendError — the structured +// error type for this category, carried in the structured detail of a +// gRPC status (Configure, and residual frontend-local failures). // -// The ten FrontendErrorCategory values, and this package's mapping to -// grpc/codes.Code (used only for the Configure-time gRPC-status path; -// mid-Attach errors carry the category in-band and never touch a -// grpc/codes.Code at all): -// -// FRONTEND_ERROR_CATEGORY_UNSPECIFIED codes.Internal (never a valid category to send; treated as an internal bug) -// FRONTEND_ERROR_CATEGORY_RENDER_FAILED codes.Internal (a RenderTree/PlacedContent could not be painted; reported in-band in the ordinary case, never expected at Configure time) -// FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT codes.InvalidArgument (malformed input on the operator-facing side) -// FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED codes.FailedPrecondition (this frontend has no fallback behavior at all for the targeted Region) -// FRONTEND_ERROR_CATEGORY_UNKNOWN codes.Internal (anything else; never codes.Unknown, per .claude/rules/grpc.md) -// FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND codes.NotFound (attach/resume/detach/list named a session_id the kernel has no record of) -// FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED codes.InvalidArgument (create_session failed: an invalid profile or unusable working_directory) -// FRONTEND_ERROR_CATEGORY_SESSION_BUSY codes.FailedPrecondition (reserved; no variant in this protocol revision currently triggers it) -// FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW codes.FailedPrecondition (resume_session named a session file newer than this kernel understands) -// FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY codes.FailedPrecondition (a new-turn-inducing event targeted a session attached replay-only) +// FRONTEND_ERROR_CATEGORY_UNSPECIFIED codes.Internal +// FRONTEND_ERROR_CATEGORY_RENDER_FAILED codes.Internal +// FRONTEND_ERROR_CATEGORY_INVALID_REQUEST codes.InvalidArgument +// FRONTEND_ERROR_CATEGORY_UNKNOWN codes.Internal +// FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND codes.NotFound +// FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED codes.InvalidArgument +// FRONTEND_ERROR_CATEGORY_SESSION_BUSY codes.FailedPrecondition +// FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW codes.FailedPrecondition +// FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY codes.FailedPrecondition type Error struct { Category frontendv1.FrontendErrorCategory Message string @@ -45,32 +38,25 @@ func (e *Error) Error() string { return fmt.Sprintf("frontend: %s: %s", e.Category, e.Message) } -// grpcCode maps e.Category to the grpc/codes.Code a Configure-time status -// built from e carries, per the table on Error's own doc comment. +// grpcCode maps e.Category to the grpc/codes.Code a status built from e +// carries, per the table on Error's own doc comment. func (e *Error) grpcCode() codes.Code { switch e.Category { - case frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT, + case frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_REQUEST, frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED: return codes.InvalidArgument case frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND: return codes.NotFound - case frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED, - frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_BUSY, + case frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_BUSY, frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW, frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY: return codes.FailedPrecondition default: - // FRONTEND_ERROR_CATEGORY_UNSPECIFIED, FRONTEND_ERROR_CATEGORY_UNKNOWN, - // FRONTEND_ERROR_CATEGORY_RENDER_FAILED, and any future category this - // build predates: codes.Internal, never codes.Unknown - // (.claude/rules/grpc.md's error taxonomy). return codes.Internal } } -// StatusErr builds the gRPC status a Configure-time e surfaces as, per -// frontend-protocol.md's "ConfigureResponse errors surface as a gRPC -// status carrying a Error in its structured detail." +// StatusErr builds the gRPC status e surfaces as. func (e *Error) StatusErr() error { return plugin.StatusError(e.grpcCode(), errorDomain, e.Category.String(), e.Message, nil) } @@ -78,8 +64,11 @@ func (e *Error) StatusErr() error { // statusErr converts an arbitrary error returned by Provider.Capabilities // or Provider.Configure into the gRPC status NewService's unary handlers // return: err's own Error when it carries one, or a generic -// FRONTEND_ERROR_CATEGORY_UNKNOWN status otherwise. +// FRONTEND_ERROR_CATEGORY_UNKNOWN wrapper otherwise. func statusErr(err error) error { + if err == nil { + return nil + } var fe *Error if errors.As(err, &fe) { return fe.StatusErr() @@ -89,51 +78,3 @@ func statusErr(err error) error { Message: err.Error(), }).StatusErr() } - -// inBandError converts an arbitrary error returned by Provider.HandleEvent -// into the Error attach.go reports in-band via ErrorEvent: err's -// own Error when it carries one, or a generic -// FRONTEND_ERROR_CATEGORY_UNKNOWN otherwise. -func inBandError(err error) *Error { - var fe *Error - if errors.As(err, &fe) { - return fe - } - return &Error{ - Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN, - Message: err.Error(), - } -} - -// FatalErr signals, when returned by Fatal, that attach.go's dispatch loop -// should treat Err as a genuinely fatal condition — the plugin process -// itself failing — so the Attach stream MUST close with a gRPC status -// rather than take the ordinary in-band ServerEvent.error path every other -// Provider.HandleEvent error takes (doc.go's "Error handling is two -// distinct paths, not one"; frontend-protocol.md's error-taxonomy -// asymmetry). -type FatalErr struct { - Err error -} - -// Fatal wraps err so attach.go's dispatch loop closes the Attach stream -// with a gRPC status instead of reporting err in-band. Deliberately named -// and shaped differently from an ordinary returned error, so closing the -// long-lived stream requires an author's own deliberate choice rather than -// happening by accident. Fatal(nil) returns nil. -func Fatal(err error) error { - if err == nil { - return nil - } - return &FatalErr{Err: err} -} - -// Error implements the error interface. -func (f *FatalErr) Error() string { - return "frontend: fatal: " + f.Err.Error() -} - -// Unwrap supports errors.Is/errors.As against the wrapped error. -func (f *FatalErr) Unwrap() error { - return f.Err -} diff --git a/pkg/frontend/errors_test.go b/pkg/frontend/errors_test.go index 876b96d..6f22751 100644 --- a/pkg/frontend/errors_test.go +++ b/pkg/frontend/errors_test.go @@ -1,7 +1,6 @@ package frontend_test import ( - "errors" "testing" "google.golang.org/grpc/codes" @@ -35,8 +34,7 @@ func TestFrontendError_StatusErr(t *testing.T) { }{ {"unspecified", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNSPECIFIED, codes.Internal}, {"render_failed", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_RENDER_FAILED, codes.Internal}, - {"invalid_client_event", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT, codes.InvalidArgument}, - {"region_unsupported", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED, codes.FailedPrecondition}, + {"invalid_request", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_REQUEST, codes.InvalidArgument}, {"unknown", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN, codes.Internal}, {"session_not_found", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND, codes.NotFound}, {"session_create_failed", frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED, codes.InvalidArgument}, @@ -65,25 +63,3 @@ func TestFrontendError_StatusErr(t *testing.T) { }) } } - -func TestFatal(t *testing.T) { - t.Parallel() - - if got := frontend.Fatal(nil); got != nil { - t.Errorf("Fatal(nil) = %v, want nil", got) - } - - inner := errors.New("process died") - wrapped := frontend.Fatal(inner) - - var fatal *frontend.FatalErr - if !errors.As(wrapped, &fatal) { - t.Fatalf("Fatal(err) does not unwrap to *FatalErr: %v", wrapped) - } - if !errors.Is(wrapped, inner) { - t.Errorf("Fatal(err) does not wrap the original error via errors.Is") - } - if wrapped.Error() == "" { - t.Errorf("FatalErr.Error() returned empty string") - } -} diff --git a/pkg/frontend/frontend.go b/pkg/frontend/frontend.go index a406522..8ff8a7e 100644 --- a/pkg/frontend/frontend.go +++ b/pkg/frontend/frontend.go @@ -7,34 +7,18 @@ import ( commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" - contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" - frontendv1 "github.com/pluggableharness/agent/pkg/frontend/proto/v1" - modelv1 "github.com/pluggableharness/agent/pkg/model/proto/v1" - planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" - sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" - slashcommandv1 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" ) // Capabilities is this frontend's static self-description, returned by -// GetCapabilities (frontend-protocol.md's "Transport" section). It MUST be -// cheaply re-derivable and MUST NOT require a network call — Provider. -// Capabilities should build it from data already resident in the plugin -// process, never fetch it remotely. +// GetCapabilities. It MUST be cheaply re-derivable and MUST NOT require a +// network call. type Capabilities struct { // SlashCommands are the prompt-expansion commands this frontend // itself contributes. A direct-invoke command is declared exclusively // by a slashcommand.v1 provider instead, never here. SlashCommands []*commonv1.PromptExpansionSpec - // ConfigSchema is this provider's agent.hcl configuration schema. See - // NewCapabilities and github.com/pluggableharness/agent/pkg/config's - // Schema/Attribute builders. + // ConfigSchema is this provider's agent.hcl configuration schema. ConfigSchema *configv1.ConfigSchema - // SupportedRegions are the Regions this frontend proactively declares - // it can render into — a complement to, not a replacement for, the - // reactive FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED error a - // placement it can't honor still produces. - SupportedRegions []renderv1.Region // SupportedHookPoints are the hook points this frontend can subscribe // to, so a mis-declared agent.hcl hook{} block naming an unsupported // point is rejected at config-load time. @@ -43,414 +27,19 @@ type Capabilities struct { // Provider is the interface a frontend plugin author implements. NewService // adapts a Provider into the generated frontendv1.FrontendServiceServer. +// +// Kernel-to-frontend traffic (session state, metadata, transcript events, +// token deltas) and frontend-to-kernel control (SubmitInput, session +// lifecycle, plan/interactive resolution) ride the kernel callback +// channel — this interface is only the standard category triple. type Provider interface { // Capabilities returns this frontend's static self-description. MUST // be cheaply re-queryable and MUST NOT require a network call. Capabilities(ctx context.Context) (*Capabilities, error) // Configure applies this provider's agent.hcl configuration, already // validated by the kernel against the ConfigSchema Capabilities - // returned. A returned *Error becomes the structured detail - // of the resulting gRPC status (see Error.StatusErr); any - // other error is wrapped as FRONTEND_ERROR_CATEGORY_UNKNOWN. + // returned. A returned *Error becomes the structured detail of the + // resulting gRPC status; any other error is wrapped as + // FRONTEND_ERROR_CATEGORY_UNKNOWN. Configure(ctx context.Context, config *structpb.Struct) error - // HandleEvent is invoked once per ClientEvent this connection's - // Attach adapter receives, in arrival order, on the connection's - // single dispatch goroutine (doc.go's "Attach is one stream per - // connection, not one per session"). Implementations reply through - // emit, which is valid for the remainder of the connection's - // lifetime — including from another goroutine, after this call - // returns, e.g. to push an unsolicited render triggered by activity - // elsewhere in the plugin process — not just while HandleEvent is - // executing. - // - // A returned error surfaces in-band as ServerEvent.error and keeps - // the stream open — the path this method should reach for by default. - // Wrap it with Fatal only for a genuinely fatal condition that must - // close the stream with a gRPC status (doc.go's "Error handling is - // two distinct paths, not one"). - HandleEvent(ctx context.Context, event ClientEvent, emit Emitter) error } - -// Emitter sends one ServerEvent at a time to the kernel over the Attach -// connection that produced the ClientEvent currently (or most recently) -// being handled. Safe for concurrent use. -type Emitter interface { - // Emit sends event. Returns a non-nil error only when the underlying - // stream itself has failed — never for an application-level condition, - // which a Provider instead reports by returning an error from - // HandleEvent (or by constructing its own ErrorEvent payload and - // calling Emit directly, for finer control over SessionID/RequestID). - Emit(event ServerEvent) error -} - -// ClientEvent is the domain form of frontendv1.ClientEvent — one event this -// connection's Attach adapter received. SessionID is REQUIRED (non-empty) -// for every session-scoped Payload variant (UserMessage, SlashCommand, -// PlanDecision, InteractiveResponse, ActionTrigger, Interrupt) and empty -// for the six connection-level control variants (Hello, CreateSession, -// AttachSession, ResumeSession, DetachSession, ListSessions), which either -// have no session yet or operate across sessions. -type ClientEvent struct { - SessionID string - Payload ClientEventPayload -} - -// ClientEventPayload is the oneof of every ClientEvent variant. Exactly -// one concrete type — UserMessage, SlashCommand, PlanDecision, -// InteractiveResponse, ActionTrigger, Interrupt, Hello, CreateSession, -// AttachSession, ResumeSession, DetachSession, or ListSessions — is ever -// assigned to ClientEvent.Payload. -type ClientEventPayload interface { - isClientEventPayload() -} - -// UserMessage is ordinary chat input. Content MUST contain at least one -// block; see github.com/pluggableharness/agent/pkg/content's Text, Image, -// Document, and other builders — never a bare string (doc.go's -// "ContentBlocks, not a bare string"). -type UserMessage struct { - Content []*contentv1.ContentBlock -} - -func (UserMessage) isClientEventPayload() {} - -// SlashCommand is a dispatched slash command invocation, resolved by the -// frontend against the kernel-supplied SlashCommandRegistry (see -// SlashCommandRegistry below) before being sent — resolution itself is -// author-side logic this package does not implement. -type SlashCommand struct { - // Name is the command name, without its leading slash. - Name string - // Args is the raw argument string following the command name. - Args string -} - -func (SlashCommand) isClientEventPayload() {} - -// PlanScope is the domain form of frontendv1.PlanDecisionScope, reordered -// so its Go zero value is PlanScopeOnce — the default a frontend SHOULD -// send absent explicit operator intent (frontend-protocol.md's -// "plan_decision.corrected_input" section) — rather than the generated -// enum's own zero value, PLAN_DECISION_SCOPE_UNSPECIFIED, which is never a -// valid decision. -type PlanScope int32 - -const ( - // PlanScopeOnce applies the decision to the named plan item only. The - // zero value, and the spec-mandated default. - PlanScopeOnce PlanScope = iota - // PlanScopeSession applies the decision to the rest of the current - // session for matching calls. - PlanScopeSession - // PlanScopeAlways asks the kernel to persist the decision as policy, - // outliving the session. The kernel MUST reject this distinctly if it - // cannot persist policy, never silently downgrading it. - PlanScopeAlways -) - -// String returns a human-readable name for s, for logging. -func (s PlanScope) String() string { - switch s { - case PlanScopeOnce: - return "once" - case PlanScopeSession: - return "session" - case PlanScopeAlways: - return "always" - default: - return "unknown" - } -} - -// PlanDecision resolves a pending PermissionRequest (see PermissionRequest -// below). CorrectedInput, when present, is an opencode-style corrected- -// argument redirect the kernel MUST re-validate against the tool's -// input_schema before treating the item as allowed. -type PlanDecision struct { - PlanItemID string - Decision frontendv1.ClientDecision - // CorrectedInput, when non-nil, replaces the plan item's tool input - // rather than a plain allow/deny. - CorrectedInput *structpb.Struct - // Scope says how durably this decision applies beyond the named item. - // The zero value is PlanScopeOnce. - Scope PlanScope -} - -func (PlanDecision) isClientEventPayload() {} - -// InteractiveResponse resolves a pending InteractiveRequest (see -// InteractiveRequest below), correlated by CallID. -type InteractiveResponse struct { - CallID string - Response *structpb.Struct -} - -func (InteractiveResponse) isClientEventPayload() {} - -// ActionTrigger is what a frontend dispatches when the operator activates -// a RenderTree's ActionNode. NodeID, ToolName, Args, and Provider MUST be -// echoed unchanged from the originating ActionNode — never rewritten -// (render-tree.md#interactive-content-the-action-node) — this is -// author-side UI discipline this package documents but cannot enforce. -type ActionTrigger struct { - NodeID string - ToolName string - Args *structpb.Struct - // Provider is the declared name of the tool provider plugin ToolName - // belongs to — ToolName is only unique per provider. - Provider string -} - -func (ActionTrigger) isClientEventPayload() {} - -// Interrupt signals that the operator wants to interrupt the current turn. -// It carries no fields. -type Interrupt struct{} - -func (Interrupt) isClientEventPayload() {} - -// Hello MAY be sent first on a newly opened Attach connection, asserting -// only the protocol version — it does not bind any session. -type Hello struct { - ProtocolVersion uint32 -} - -func (Hello) isClientEventPayload() {} - -// CreateSession creates a new session under Profile (or the kernel's -// default profile, when nil) and auto-attaches this connection to it. -// Answered by SessionCreated, correlated by RequestID. -type CreateSession struct { - RequestID string - // Profile is the agent.hcl profile to create the session under. Nil - // means the kernel's configured default profile. - Profile *string - // InitialPrompt, when non-nil, seeds the session's first turn. - InitialPrompt *string - // WorkingDirectory, when non-nil, overrides the kernel's own working - // directory at creation time. - WorkingDirectory *string -} - -func (CreateSession) isClientEventPayload() {} - -// AttachSession subscribes an existing (live or terminal) session onto -// this connection, triggering a backfill replay. Answered by -// SessionAttached, bracketing a batch closed by BackfillComplete. -type AttachSession struct { - RequestID string - SessionID string -} - -func (AttachSession) isClientEventPayload() {} - -// ResumeSession attaches a historical session for continuation or replay — -// see frontend-protocol.md's "Resume and re-open semantics". Answered -// identically to AttachSession. -type ResumeSession struct { - RequestID string - SessionID string -} - -func (ResumeSession) isClientEventPayload() {} - -// DetachSession unsubscribes a session from this connection without -// affecting the session itself or any other connection attached to it. -// Answered by SessionDetached. -type DetachSession struct { - RequestID string - SessionID string -} - -func (DetachSession) isClientEventPayload() {} - -// ListSessions requests the connection-scoped session summary list. -// Answered by SessionList. There is no DeleteSession — see -// frontend-protocol.md's "No session deletion". -type ListSessions struct { - RequestID string - // Status, when non-nil, restricts the result to sessions in this - // status. - Status *sessionv1.SessionStatus - // ParentSessionID, when non-nil, restricts the result to children of - // this session. - ParentSessionID *string - // RootsOnly restricts the result to sessions with no - // parent_session_id, ignored when false. - RootsOnly bool -} - -func (ListSessions) isClientEventPayload() {} - -// ServerEvent is the domain form of frontendv1.ServerEvent — one event -// this connection's Attach adapter sends. SessionID is set for every -// session-scoped Payload variant; empty only for the one connection-level -// variant, SessionList. RequestID, when non-nil, correlates this event -// back to the ClientEvent control message that triggered it (set on -// SessionCreated, SessionAttached, BackfillComplete, SessionDetached, -// SessionList, and on ErrorEvent when it answers a specific control -// request; nil for an ordinary live session event not triggered by a -// specific request). -type ServerEvent struct { - SessionID string - RequestID *string - Payload ServerEventPayload -} - -// ServerEventPayload is the oneof of every ServerEvent variant. -type ServerEventPayload interface { - isServerEventPayload() -} - -// StreamDelta is the fast path for incremental text display, skipping a -// full Render round trip — live-only, never used for replayed/backfilled -// text (doc.go's "Fast path vs. full render"). TargetID correlates -// consecutive deltas into one growing piece of text. -type StreamDelta struct { - TargetID string - Text string -} - -func (StreamDelta) isServerEventPayload() {} - -// Render carries one placed RenderTree to paint — a finished unit, live or -// replayed, never a partial delta (doc.go's "Fast path vs. full render"). -// A frontend MUST render every RenderNode type gracefully, including a -// variant added after this package shipped — see FallbackText. -type Render struct { - Content *renderv1.PlacedContent -} - -func (Render) isServerEventPayload() {} - -// PermissionRequest asks the operator to resolve a pending plan-apply-gate -// "ask" decision: the kernel blocks that plan item's apply until a -// matching PlanDecision resolves it. -type PermissionRequest struct { - PlanItem *planv1.PlanItem -} - -func (PermissionRequest) isServerEventPayload() {} - -// PlanReady announces a complete plan for display, e.g. before execution -// begins or after a replan. -type PlanReady struct { - Plan *planv1.Plan -} - -func (PlanReady) isServerEventPayload() {} - -// InteractiveRequest carries a kind:interactive tool call's own prompt -// content across the frontend boundary, correlated by CallID with the -// eventual InteractiveResponse. A conforming frontend MUST render Prompt -// in the REGION_OVERLAY region, the same visual treatment as an ordinary -// "ask" prompt (render-tree.md#placement--regions) — author-side UI -// discipline this package documents but cannot enforce. -type InteractiveRequest struct { - CallID string - ToolName string - Prompt *renderv1.RenderTree -} - -func (InteractiveRequest) isServerEventPayload() {} - -// SessionTreeUpdate reports a CHILD session's status change (e.g. a -// RunSession-spawned sub-agent), so a frontend can keep a SubSessionNode's -// displayed status current. For the attached session's OWN status, see -// SessionStatusUpdate below, a deliberately distinct variant. -type SessionTreeUpdate struct { - ParentSessionID string - ChildSessionID string - Status sessionv1.SessionStatus -} - -func (SessionTreeUpdate) isServerEventPayload() {} - -// ErrorEvent carries a structured, non-fatal Error for display — -// the in-band error path (doc.go's "Error handling is two distinct paths, -// not one"). -type ErrorEvent struct { - Err *Error -} - -func (ErrorEvent) isServerEventPayload() {} - -// SessionCreated acknowledges a successful CreateSession, carrying the new -// session's info. -type SessionCreated struct { - Info *sessionv1.SessionInfo -} - -func (SessionCreated) isServerEventPayload() {} - -// SessionAttached acknowledges a successful AttachSession or -// ResumeSession, carrying the session's current info and opening its -// backfill batch — the replayed Render events that follow, bracketed by -// the eventual BackfillComplete. -type SessionAttached struct { - Info *sessionv1.SessionInfo -} - -func (SessionAttached) isServerEventPayload() {} - -// BackfillComplete is the done-marker closing a backfill batch opened by -// SessionAttached. Live events with sequence > LastSequence follow. Per -// frontend-protocol.md's "Backfill" section, a backfill batch is unicast -// to the attaching connection only, never broadcast to any other frontend -// subscribed to the same session. -type BackfillComplete struct { - LastSequence int64 -} - -func (BackfillComplete) isServerEventPayload() {} - -// SessionDetached acknowledges a successful DetachSession. It carries no -// fields. -type SessionDetached struct{} - -func (SessionDetached) isServerEventPayload() {} - -// SessionList answers a ListSessions request, most-recently-started first. -// This is the one connection-level ServerEvent variant — ServerEvent's own -// SessionID is empty for it. -type SessionList struct { - Sessions []*sessionv1.SessionInfo -} - -func (SessionList) isServerEventPayload() {} - -// SlashCommandRegistry is the profile-scoped aggregate of every loaded -// provider's declared slash commands, sent on session attach and again -// whenever the registry changes. DirectInvokeCommands is declared -// exclusively by slashcommand.v1 providers; PromptExpansionCommands is -// shared vocabulary any category MAY declare. A command name MUST be -// unique jointly across both lists — the kernel enforces this at -// config-load time, not this package. -type SlashCommandRegistry struct { - DirectInvokeCommands []*slashcommandv1.SlashCommandSpec - PromptExpansionCommands []*commonv1.PromptExpansionSpec -} - -func (SlashCommandRegistry) isServerEventPayload() {} - -// UsageUpdate carries one turn's token/cost accounting and the session's -// running totals, for a context-budget indicator or similar. -type UsageUpdate struct { - Turn *modelv1.Usage - CumulativeCostUSD float64 - UsedTokens int64 - EffectiveCeiling int64 -} - -func (UsageUpdate) isServerEventPayload() {} - -// SessionStatusUpdate reports the attached session's OWN lifecycle status -// transition (e.g. RUNNING -> COMPLETED, or a bound-exhausted re-open). -// Deliberately distinct from SessionTreeUpdate above, which reports a -// CHILD session's status. -type SessionStatusUpdate struct { - Status sessionv1.SessionStatus -} - -func (SessionStatusUpdate) isServerEventPayload() {} diff --git a/pkg/frontend/helpers_test.go b/pkg/frontend/helpers_test.go index 4adbd3b..8c76e71 100644 --- a/pkg/frontend/helpers_test.go +++ b/pkg/frontend/helpers_test.go @@ -16,9 +16,7 @@ import ( ) // newTestServer starts svc on an in-memory bufconn listener and returns a -// frontendv1.FrontendServiceClient dialed against it — a real gRPC round -// trip, not a hand-rolled interface fake, mirroring -// pkg/kernel/helpers_test.go's newTestClient. +// frontendv1.FrontendServiceClient dialed against it. func newTestServer(t *testing.T, svc *frontend.Service) frontendv1.FrontendServiceClient { t.Helper() @@ -40,14 +38,10 @@ func newTestServer(t *testing.T, svc *frontend.Service) frontendv1.FrontendServi return frontendv1.NewFrontendServiceClient(conn) } -// fakeProvider is a hand-written frontend.Provider fake (go-testing.md: -// fakes, not mocking frameworks). Each method's behavior is controlled by -// a caller-set func field; a nil field falls through to a harmless zero -// value. +// fakeProvider is a hand-written frontend.Provider fake. type fakeProvider struct { capabilitiesFunc func(ctx context.Context) (*frontend.Capabilities, error) configureFunc func(ctx context.Context, config *structpb.Struct) error - handleEventFunc func(ctx context.Context, event frontend.ClientEvent, emit frontend.Emitter) error } var _ frontend.Provider = (*fakeProvider)(nil) @@ -66,12 +60,5 @@ func (f *fakeProvider) Configure(ctx context.Context, config *structpb.Struct) e return nil } -func (f *fakeProvider) HandleEvent(ctx context.Context, event frontend.ClientEvent, emit frontend.Emitter) error { - if f.handleEventFunc != nil { - return f.handleEventFunc(ctx, event, emit) - } - return nil -} - -// testIdentity is a fixed plugin.Identity used across server/attach tests. +// testIdentity is a fixed plugin.Identity used across server tests. var testIdentity = plugin.Identity{Name: "test-frontend", Version: "1.0.0", Source: "github.com/pluggableharness/agent/pkg/frontend"} diff --git a/pkg/frontend/proto/v1/errors.pb.go b/pkg/frontend/proto/v1/errors.pb.go index af34b35..ce994fc 100644 --- a/pkg/frontend/proto/v1/errors.pb.go +++ b/pkg/frontend/proto/v1/errors.pb.go @@ -21,23 +21,19 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// FrontendErrorCategory classifies a FrontendError, per the error taxonomy -// in frontend.md §7. +// FrontendErrorCategory classifies a FrontendError. type FrontendErrorCategory int32 const ( // Zero value. Never valid for a real error; its presence on the wire // means a caller forgot to set the field. FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNSPECIFIED FrontendErrorCategory = 0 - // A RenderTree or PlacedContent could not be displayed. + // A RenderTree could not be displayed. FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_RENDER_FAILED FrontendErrorCategory = 1 - // A ClientEvent was malformed or referenced an unknown/already-resolved - // id (e.g. a plan_decision or interactive_response naming an item that - // was already resolved by another attached frontend, per frontend.md - // §3.3's first-response-wins arbitration). - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT FrontendErrorCategory = 2 - // A PlacedContent named a Region this frontend cannot honor. - FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED FrontendErrorCategory = 3 + // A request was malformed or referenced an unknown/already-resolved + // id (e.g. ResolvePlanDecision naming an item already resolved by + // another attached frontend — first-response-wins arbitration). + FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_REQUEST FrontendErrorCategory = 2 // An error that does not fit any other category. FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_UNKNOWN FrontendErrorCategory = 4 // AttachSession, ResumeSession, DetachSession, or ListSessions' @@ -47,20 +43,19 @@ const ( // CreateSession failed — an invalid profile, or an unusable // working_directory. FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED FrontendErrorCategory = 6 - // A session-mutating control event targeted a session currently + // A session-mutating control targeted a session currently // SESSION_STATUS_RUNNING in a way that conflicts with that (reserved - // for future session-mutating control events; no current variant in - // this protocol revision triggers it, since DetachSession is always - // safe on a running session). + // for future control events; DetachSession is always safe on a running + // session). FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_BUSY FrontendErrorCategory = 7 // ResumeSession named a session file with a newer PRAGMA user_version - // than this kernel understands (state-backend.md §"Schema migration"). + // than this kernel understands (state-backend.md schema migration). FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW FrontendErrorCategory = 8 - // A user_message (or any other new-turn-inducing event) targeted a - // session attached replay-only per frontend.md §"Resume and re-open - // semantics" — a bound-exhausted (error_max_*) or FAILED session - // resumed via ResumeSession. Distinct from SESSION_BUSY: the session - // isn't running, it's terminal and specifically barred from new turns. + // SubmitInput (or any other new-turn-inducing RPC) targeted a session + // attached replay-only — a bound-exhausted (error_max_*) or FAILED + // session resumed via ResumeSession. Distinct from SESSION_BUSY: the + // session is not running, it is terminal and specifically barred from + // new turns. FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY FrontendErrorCategory = 9 ) @@ -69,8 +64,7 @@ var ( FrontendErrorCategory_name = map[int32]string{ 0: "FRONTEND_ERROR_CATEGORY_UNSPECIFIED", 1: "FRONTEND_ERROR_CATEGORY_RENDER_FAILED", - 2: "FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT", - 3: "FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED", + 2: "FRONTEND_ERROR_CATEGORY_INVALID_REQUEST", 4: "FRONTEND_ERROR_CATEGORY_UNKNOWN", 5: "FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND", 6: "FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED", @@ -81,8 +75,7 @@ var ( FrontendErrorCategory_value = map[string]int32{ "FRONTEND_ERROR_CATEGORY_UNSPECIFIED": 0, "FRONTEND_ERROR_CATEGORY_RENDER_FAILED": 1, - "FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT": 2, - "FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED": 3, + "FRONTEND_ERROR_CATEGORY_INVALID_REQUEST": 2, "FRONTEND_ERROR_CATEGORY_UNKNOWN": 4, "FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND": 5, "FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED": 6, @@ -119,9 +112,8 @@ func (FrontendErrorCategory) EnumDescriptor() ([]byte, []int) { return file_pluggableharness_frontend_v1_errors_proto_rawDescGZIP(), []int{0} } -// FrontendError is the structured error type for this category, per -// frontend.md §7. Carried in ServerEvent.Error and in the structured detail -// of a gRPC status returned from Configure. +// FrontendError is the structured error type for this category. Carried +// in the structured detail of a gRPC status. type FrontendError struct { state protoimpl.MessageState `protogen:"open.v1"` // The error's category. @@ -183,18 +175,17 @@ const file_pluggableharness_frontend_v1_errors_proto_rawDesc = "" + ")pluggableharness/frontend/v1/errors.proto\x12\x1cpluggableharness.frontend.v1\"z\n" + "\rFrontendError\x12O\n" + "\bcategory\x18\x01 \x01(\x0e23.pluggableharness.frontend.v1.FrontendErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage*\xdb\x03\n" + + "\amessage\x18\x02 \x01(\tR\amessage*\xd8\x03\n" + "\x15FrontendErrorCategory\x12'\n" + "#FRONTEND_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12)\n" + - "%FRONTEND_ERROR_CATEGORY_RENDER_FAILED\x10\x01\x120\n" + - ",FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT\x10\x02\x12.\n" + - "*FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED\x10\x03\x12#\n" + + "%FRONTEND_ERROR_CATEGORY_RENDER_FAILED\x10\x01\x12+\n" + + "'FRONTEND_ERROR_CATEGORY_INVALID_REQUEST\x10\x02\x12#\n" + "\x1fFRONTEND_ERROR_CATEGORY_UNKNOWN\x10\x04\x12-\n" + ")FRONTEND_ERROR_CATEGORY_SESSION_NOT_FOUND\x10\x05\x121\n" + "-FRONTEND_ERROR_CATEGORY_SESSION_CREATE_FAILED\x10\x06\x12(\n" + "$FRONTEND_ERROR_CATEGORY_SESSION_BUSY\x10\a\x12*\n" + "&FRONTEND_ERROR_CATEGORY_SCHEMA_TOO_NEW\x10\b\x12/\n" + - "+FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY\x10\tBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" + "+FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY\x10\t\"\x04\b\x03\x10\x03**FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTEDBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" var ( file_pluggableharness_frontend_v1_errors_proto_rawDescOnce sync.Once diff --git a/pkg/frontend/proto/v1/events.pb.go b/pkg/frontend/proto/v1/events.pb.go deleted file mode 100644 index dd4c8db..0000000 --- a/pkg/frontend/proto/v1/events.pb.go +++ /dev/null @@ -1,2639 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/frontend/v1/events.proto - -package frontendv1 - -import ( - v14 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v16 "github.com/pluggableharness/agent/pkg/content/proto/v1" - v15 "github.com/pluggableharness/agent/pkg/model/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/plan/proto/v1" - v1 "github.com/pluggableharness/agent/pkg/render/proto/v1" - v12 "github.com/pluggableharness/agent/pkg/session/proto/v1" - v13 "github.com/pluggableharness/agent/pkg/slashcommand/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - structpb "google.golang.org/protobuf/types/known/structpb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// ClientDecision is the user's resolution of a pending PermissionRequest, -// per agent-loop.md §5.2's `ask` decision. -type ClientDecision int32 - -const ( - // Zero value. Never valid for a real decision; its presence on the wire - // means a caller forgot to set the field. - ClientDecision_CLIENT_DECISION_UNSPECIFIED ClientDecision = 0 - // The user approved the plan item as proposed (or as corrected, see - // ClientEvent.PlanDecision.corrected_input). - ClientDecision_CLIENT_DECISION_ALLOW ClientDecision = 1 - // The user rejected the plan item. - ClientDecision_CLIENT_DECISION_DENY ClientDecision = 2 -) - -// Enum value maps for ClientDecision. -var ( - ClientDecision_name = map[int32]string{ - 0: "CLIENT_DECISION_UNSPECIFIED", - 1: "CLIENT_DECISION_ALLOW", - 2: "CLIENT_DECISION_DENY", - } - ClientDecision_value = map[string]int32{ - "CLIENT_DECISION_UNSPECIFIED": 0, - "CLIENT_DECISION_ALLOW": 1, - "CLIENT_DECISION_DENY": 2, - } -) - -func (x ClientDecision) Enum() *ClientDecision { - p := new(ClientDecision) - *p = x - return p -} - -func (x ClientDecision) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ClientDecision) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_frontend_v1_events_proto_enumTypes[0].Descriptor() -} - -func (ClientDecision) Type() protoreflect.EnumType { - return &file_pluggableharness_frontend_v1_events_proto_enumTypes[0] -} - -func (x ClientDecision) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ClientDecision.Descriptor instead. -func (ClientDecision) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0} -} - -// PlanDecisionScope is how durably a ClientEvent.PlanDecision applies, -// beyond just the one PlanItem it names. Orthogonal to ClientDecision: -// decision says allow/deny, scope says how long that verdict is -// remembered. agent-loop/plan-apply-gate.md's "PlanDecisionScope -// semantics" documents evaluation order and the ALWAYS persistence -// obligation. -type PlanDecisionScope int32 - -const ( - // Zero value. Never valid for a real decision; its presence on the wire - // means a caller forgot to set the field. - PlanDecisionScope_PLAN_DECISION_SCOPE_UNSPECIFIED PlanDecisionScope = 0 - // Applies to this PlanItem only. The default a frontend SHOULD send - // when the user hasn't explicitly asked for a broader scope. - PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE PlanDecisionScope = 1 - // Applies to the rest of this session, for calls matching the same - // provider/tool_name (and, where policy's match schema supports it, - // narrower criteria) — an in-memory, session-lifetime rule, not - // written to agent.hcl or any persisted policy store. - PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION PlanDecisionScope = 2 - // The kernel persists this as policy, applying beyond this session to - // future sessions under the same profile. Requires kernel-side policy - // persistence (agent-loop/plan-apply-gate.md#plandecisionscope-semantics) - // — a frontend MUST NOT assume ALWAYS is honored merely because it was - // sent; a kernel that cannot persist policy MUST reject it as a - // distinct error rather than silently downgrading to SESSION or ONCE. - PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS PlanDecisionScope = 3 -) - -// Enum value maps for PlanDecisionScope. -var ( - PlanDecisionScope_name = map[int32]string{ - 0: "PLAN_DECISION_SCOPE_UNSPECIFIED", - 1: "PLAN_DECISION_SCOPE_ONCE", - 2: "PLAN_DECISION_SCOPE_SESSION", - 3: "PLAN_DECISION_SCOPE_ALWAYS", - } - PlanDecisionScope_value = map[string]int32{ - "PLAN_DECISION_SCOPE_UNSPECIFIED": 0, - "PLAN_DECISION_SCOPE_ONCE": 1, - "PLAN_DECISION_SCOPE_SESSION": 2, - "PLAN_DECISION_SCOPE_ALWAYS": 3, - } -) - -func (x PlanDecisionScope) Enum() *PlanDecisionScope { - p := new(PlanDecisionScope) - *p = x - return p -} - -func (x PlanDecisionScope) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (PlanDecisionScope) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_frontend_v1_events_proto_enumTypes[1].Descriptor() -} - -func (PlanDecisionScope) Type() protoreflect.EnumType { - return &file_pluggableharness_frontend_v1_events_proto_enumTypes[1] -} - -func (x PlanDecisionScope) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use PlanDecisionScope.Descriptor instead. -func (PlanDecisionScope) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1} -} - -// ServerEvent is one message the kernel sends to an attached frontend over -// the single multiplexed Attach stream, described in frontend.md §3.2. -// Exactly one variant is set. -type ServerEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The session this event is scoped to. Set for every session-scoped - // variant (stream_delta .. session_status_update); empty for the one - // connection-level variant, session_list. - SessionId string `protobuf:"bytes,100,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // Correlates a response to the ClientEvent control message that - // triggered it, echoing that message's own request_id - // (hello/create_session/attach_session/resume_session/detach_session/ - // list_sessions all carry one). Set on session_created, session_attached, - // backfill_complete, session_detached, session_list, and on error when - // it answers one of those control requests. Unset for ordinary live - // session events that were not triggered by a specific client request - // (stream_delta, render, plan_ready, ...). - RequestId *string `protobuf:"bytes,101,opt,name=request_id,json=requestId,proto3,oneof" json:"request_id,omitempty"` - // Types that are valid to be assigned to Event: - // - // *ServerEvent_StreamDelta_ - // *ServerEvent_Render_ - // *ServerEvent_PermissionRequest_ - // *ServerEvent_PlanReady_ - // *ServerEvent_InteractiveRequest_ - // *ServerEvent_SessionTreeUpdate_ - // *ServerEvent_Error_ - // *ServerEvent_SessionCreated_ - // *ServerEvent_SessionAttached_ - // *ServerEvent_BackfillComplete_ - // *ServerEvent_SessionDetached_ - // *ServerEvent_SessionList_ - // *ServerEvent_SlashCommandRegistry_ - // *ServerEvent_UsageUpdate_ - // *ServerEvent_SessionStatusUpdate_ - Event isServerEvent_Event `protobuf_oneof:"event"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent) Reset() { - *x = ServerEvent{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent) ProtoMessage() {} - -func (x *ServerEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent.ProtoReflect.Descriptor instead. -func (*ServerEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0} -} - -func (x *ServerEvent) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *ServerEvent) GetRequestId() string { - if x != nil && x.RequestId != nil { - return *x.RequestId - } - return "" -} - -func (x *ServerEvent) GetEvent() isServerEvent_Event { - if x != nil { - return x.Event - } - return nil -} - -func (x *ServerEvent) GetStreamDelta() *ServerEvent_StreamDelta { - if x != nil { - if x, ok := x.Event.(*ServerEvent_StreamDelta_); ok { - return x.StreamDelta - } - } - return nil -} - -func (x *ServerEvent) GetRender() *ServerEvent_Render { - if x != nil { - if x, ok := x.Event.(*ServerEvent_Render_); ok { - return x.Render - } - } - return nil -} - -func (x *ServerEvent) GetPermissionRequest() *ServerEvent_PermissionRequest { - if x != nil { - if x, ok := x.Event.(*ServerEvent_PermissionRequest_); ok { - return x.PermissionRequest - } - } - return nil -} - -func (x *ServerEvent) GetPlanReady() *ServerEvent_PlanReady { - if x != nil { - if x, ok := x.Event.(*ServerEvent_PlanReady_); ok { - return x.PlanReady - } - } - return nil -} - -func (x *ServerEvent) GetInteractiveRequest() *ServerEvent_InteractiveRequest { - if x != nil { - if x, ok := x.Event.(*ServerEvent_InteractiveRequest_); ok { - return x.InteractiveRequest - } - } - return nil -} - -func (x *ServerEvent) GetSessionTreeUpdate() *ServerEvent_SessionTreeUpdate { - if x != nil { - if x, ok := x.Event.(*ServerEvent_SessionTreeUpdate_); ok { - return x.SessionTreeUpdate - } - } - return nil -} - -func (x *ServerEvent) GetError() *ServerEvent_Error { - if x != nil { - if x, ok := x.Event.(*ServerEvent_Error_); ok { - return x.Error - } - } - return nil -} - -func (x *ServerEvent) GetSessionCreated() *ServerEvent_SessionCreated { - if x != nil { - if x, ok := x.Event.(*ServerEvent_SessionCreated_); ok { - return x.SessionCreated - } - } - return nil -} - -func (x *ServerEvent) GetSessionAttached() *ServerEvent_SessionAttached { - if x != nil { - if x, ok := x.Event.(*ServerEvent_SessionAttached_); ok { - return x.SessionAttached - } - } - return nil -} - -func (x *ServerEvent) GetBackfillComplete() *ServerEvent_BackfillComplete { - if x != nil { - if x, ok := x.Event.(*ServerEvent_BackfillComplete_); ok { - return x.BackfillComplete - } - } - return nil -} - -func (x *ServerEvent) GetSessionDetached() *ServerEvent_SessionDetached { - if x != nil { - if x, ok := x.Event.(*ServerEvent_SessionDetached_); ok { - return x.SessionDetached - } - } - return nil -} - -func (x *ServerEvent) GetSessionList() *ServerEvent_SessionList { - if x != nil { - if x, ok := x.Event.(*ServerEvent_SessionList_); ok { - return x.SessionList - } - } - return nil -} - -func (x *ServerEvent) GetSlashCommandRegistry() *ServerEvent_SlashCommandRegistry { - if x != nil { - if x, ok := x.Event.(*ServerEvent_SlashCommandRegistry_); ok { - return x.SlashCommandRegistry - } - } - return nil -} - -func (x *ServerEvent) GetUsageUpdate() *ServerEvent_UsageUpdate { - if x != nil { - if x, ok := x.Event.(*ServerEvent_UsageUpdate_); ok { - return x.UsageUpdate - } - } - return nil -} - -func (x *ServerEvent) GetSessionStatusUpdate() *ServerEvent_SessionStatusUpdate { - if x != nil { - if x, ok := x.Event.(*ServerEvent_SessionStatusUpdate_); ok { - return x.SessionStatusUpdate - } - } - return nil -} - -type isServerEvent_Event interface { - isServerEvent_Event() -} - -type ServerEvent_StreamDelta_ struct { - StreamDelta *ServerEvent_StreamDelta `protobuf:"bytes,1,opt,name=stream_delta,json=streamDelta,proto3,oneof"` -} - -type ServerEvent_Render_ struct { - Render *ServerEvent_Render `protobuf:"bytes,2,opt,name=render,proto3,oneof"` -} - -type ServerEvent_PermissionRequest_ struct { - PermissionRequest *ServerEvent_PermissionRequest `protobuf:"bytes,3,opt,name=permission_request,json=permissionRequest,proto3,oneof"` -} - -type ServerEvent_PlanReady_ struct { - PlanReady *ServerEvent_PlanReady `protobuf:"bytes,4,opt,name=plan_ready,json=planReady,proto3,oneof"` -} - -type ServerEvent_InteractiveRequest_ struct { - InteractiveRequest *ServerEvent_InteractiveRequest `protobuf:"bytes,5,opt,name=interactive_request,json=interactiveRequest,proto3,oneof"` -} - -type ServerEvent_SessionTreeUpdate_ struct { - SessionTreeUpdate *ServerEvent_SessionTreeUpdate `protobuf:"bytes,6,opt,name=session_tree_update,json=sessionTreeUpdate,proto3,oneof"` -} - -type ServerEvent_Error_ struct { - Error *ServerEvent_Error `protobuf:"bytes,7,opt,name=error,proto3,oneof"` -} - -type ServerEvent_SessionCreated_ struct { - // Acknowledges a ClientEvent.CreateSession, carrying the new - // session's info. The kernel auto-attaches the creating stream to - // this session (frontend.md §"Session lifecycle"). - SessionCreated *ServerEvent_SessionCreated `protobuf:"bytes,8,opt,name=session_created,json=sessionCreated,proto3,oneof"` -} - -type ServerEvent_SessionAttached_ struct { - // Acknowledges a ClientEvent.AttachSession or ResumeSession, carrying - // the session's current info. Opens the backfill batch: the - // replayed `render` events that follow, bracketed by the eventual - // backfill_complete (frontend.md §"Backfill"). - SessionAttached *ServerEvent_SessionAttached `protobuf:"bytes,9,opt,name=session_attached,json=sessionAttached,proto3,oneof"` -} - -type ServerEvent_BackfillComplete_ struct { - // The done-marker closing a backfill batch opened by session_attached - // above. Live events with sequence > last_sequence follow this. - // Unicast to the attaching stream only, never broadcast. - BackfillComplete *ServerEvent_BackfillComplete `protobuf:"bytes,10,opt,name=backfill_complete,json=backfillComplete,proto3,oneof"` -} - -type ServerEvent_SessionDetached_ struct { - // Acknowledges a ClientEvent.DetachSession. - SessionDetached *ServerEvent_SessionDetached `protobuf:"bytes,11,opt,name=session_detached,json=sessionDetached,proto3,oneof"` -} - -type ServerEvent_SessionList_ struct { - // Answers a ClientEvent.ListSessions. Connection-scoped — session_id - // above is empty for this variant. - SessionList *ServerEvent_SessionList `protobuf:"bytes,12,opt,name=session_list,json=sessionList,proto3,oneof"` -} - -type ServerEvent_SlashCommandRegistry_ struct { - // The profile-scoped aggregate slash-command registry across every - // provider loaded for this session, sent on session_attached and - // again whenever the registry changes. - SlashCommandRegistry *ServerEvent_SlashCommandRegistry `protobuf:"bytes,14,opt,name=slash_command_registry,json=slashCommandRegistry,proto3,oneof"` -} - -type ServerEvent_UsageUpdate_ struct { - // Per-turn token/cost accounting and context-budget pressure. - UsageUpdate *ServerEvent_UsageUpdate `protobuf:"bytes,15,opt,name=usage_update,json=usageUpdate,proto3,oneof"` -} - -type ServerEvent_SessionStatusUpdate_ struct { - // This session's OWN lifecycle status. Deliberately distinct from - // session_tree_update above, which reports a CHILD session's status - // — this variant is the attached session's own transition (e.g. - // RUNNING -> COMPLETED, or a bound-exhausted re-open per - // frontend.md §"Resume and re-open semantics"). - SessionStatusUpdate *ServerEvent_SessionStatusUpdate `protobuf:"bytes,16,opt,name=session_status_update,json=sessionStatusUpdate,proto3,oneof"` -} - -func (*ServerEvent_StreamDelta_) isServerEvent_Event() {} - -func (*ServerEvent_Render_) isServerEvent_Event() {} - -func (*ServerEvent_PermissionRequest_) isServerEvent_Event() {} - -func (*ServerEvent_PlanReady_) isServerEvent_Event() {} - -func (*ServerEvent_InteractiveRequest_) isServerEvent_Event() {} - -func (*ServerEvent_SessionTreeUpdate_) isServerEvent_Event() {} - -func (*ServerEvent_Error_) isServerEvent_Event() {} - -func (*ServerEvent_SessionCreated_) isServerEvent_Event() {} - -func (*ServerEvent_SessionAttached_) isServerEvent_Event() {} - -func (*ServerEvent_BackfillComplete_) isServerEvent_Event() {} - -func (*ServerEvent_SessionDetached_) isServerEvent_Event() {} - -func (*ServerEvent_SessionList_) isServerEvent_Event() {} - -func (*ServerEvent_SlashCommandRegistry_) isServerEvent_Event() {} - -func (*ServerEvent_UsageUpdate_) isServerEvent_Event() {} - -func (*ServerEvent_SessionStatusUpdate_) isServerEvent_Event() {} - -// ClientEvent is one message a frontend sends to the kernel over the -// single multiplexed Attach stream, described in frontend.md §3.2. -// Exactly one variant is set. -type ClientEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The session this event is scoped to. REQUIRED for every - // session-scoped variant (user_message, slash_command, plan_decision, - // interactive_response, action_trigger, interrupt) — the kernel MUST - // reject one of these arriving with an empty session_id as - // FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT. Empty for the - // connection-level control variants (hello, create_session, - // attach_session, resume_session, detach_session, list_sessions), which - // either don't yet have a bound session or operate across sessions — - // attach_session/resume_session instead name the target session inside - // their own nested message. - SessionId string `protobuf:"bytes,100,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // Types that are valid to be assigned to Event: - // - // *ClientEvent_UserMessage_ - // *ClientEvent_SlashCommand_ - // *ClientEvent_PlanDecision_ - // *ClientEvent_InteractiveResponse_ - // *ClientEvent_ActionTrigger_ - // *ClientEvent_Interrupt_ - // *ClientEvent_Hello_ - // *ClientEvent_CreateSession_ - // *ClientEvent_AttachSession_ - // *ClientEvent_ResumeSession_ - // *ClientEvent_DetachSession_ - // *ClientEvent_ListSessions_ - Event isClientEvent_Event `protobuf_oneof:"event"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent) Reset() { - *x = ClientEvent{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent) ProtoMessage() {} - -func (x *ClientEvent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent.ProtoReflect.Descriptor instead. -func (*ClientEvent) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1} -} - -func (x *ClientEvent) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *ClientEvent) GetEvent() isClientEvent_Event { - if x != nil { - return x.Event - } - return nil -} - -func (x *ClientEvent) GetUserMessage() *ClientEvent_UserMessage { - if x != nil { - if x, ok := x.Event.(*ClientEvent_UserMessage_); ok { - return x.UserMessage - } - } - return nil -} - -func (x *ClientEvent) GetSlashCommand() *ClientEvent_SlashCommand { - if x != nil { - if x, ok := x.Event.(*ClientEvent_SlashCommand_); ok { - return x.SlashCommand - } - } - return nil -} - -func (x *ClientEvent) GetPlanDecision() *ClientEvent_PlanDecision { - if x != nil { - if x, ok := x.Event.(*ClientEvent_PlanDecision_); ok { - return x.PlanDecision - } - } - return nil -} - -func (x *ClientEvent) GetInteractiveResponse() *ClientEvent_InteractiveResponse { - if x != nil { - if x, ok := x.Event.(*ClientEvent_InteractiveResponse_); ok { - return x.InteractiveResponse - } - } - return nil -} - -func (x *ClientEvent) GetActionTrigger() *ClientEvent_ActionTrigger { - if x != nil { - if x, ok := x.Event.(*ClientEvent_ActionTrigger_); ok { - return x.ActionTrigger - } - } - return nil -} - -func (x *ClientEvent) GetInterrupt() *ClientEvent_Interrupt { - if x != nil { - if x, ok := x.Event.(*ClientEvent_Interrupt_); ok { - return x.Interrupt - } - } - return nil -} - -func (x *ClientEvent) GetHello() *ClientEvent_Hello { - if x != nil { - if x, ok := x.Event.(*ClientEvent_Hello_); ok { - return x.Hello - } - } - return nil -} - -func (x *ClientEvent) GetCreateSession() *ClientEvent_CreateSession { - if x != nil { - if x, ok := x.Event.(*ClientEvent_CreateSession_); ok { - return x.CreateSession - } - } - return nil -} - -func (x *ClientEvent) GetAttachSession() *ClientEvent_AttachSession { - if x != nil { - if x, ok := x.Event.(*ClientEvent_AttachSession_); ok { - return x.AttachSession - } - } - return nil -} - -func (x *ClientEvent) GetResumeSession() *ClientEvent_ResumeSession { - if x != nil { - if x, ok := x.Event.(*ClientEvent_ResumeSession_); ok { - return x.ResumeSession - } - } - return nil -} - -func (x *ClientEvent) GetDetachSession() *ClientEvent_DetachSession { - if x != nil { - if x, ok := x.Event.(*ClientEvent_DetachSession_); ok { - return x.DetachSession - } - } - return nil -} - -func (x *ClientEvent) GetListSessions() *ClientEvent_ListSessions { - if x != nil { - if x, ok := x.Event.(*ClientEvent_ListSessions_); ok { - return x.ListSessions - } - } - return nil -} - -type isClientEvent_Event interface { - isClientEvent_Event() -} - -type ClientEvent_UserMessage_ struct { - UserMessage *ClientEvent_UserMessage `protobuf:"bytes,1,opt,name=user_message,json=userMessage,proto3,oneof"` -} - -type ClientEvent_SlashCommand_ struct { - SlashCommand *ClientEvent_SlashCommand `protobuf:"bytes,2,opt,name=slash_command,json=slashCommand,proto3,oneof"` -} - -type ClientEvent_PlanDecision_ struct { - PlanDecision *ClientEvent_PlanDecision `protobuf:"bytes,3,opt,name=plan_decision,json=planDecision,proto3,oneof"` -} - -type ClientEvent_InteractiveResponse_ struct { - InteractiveResponse *ClientEvent_InteractiveResponse `protobuf:"bytes,4,opt,name=interactive_response,json=interactiveResponse,proto3,oneof"` -} - -type ClientEvent_ActionTrigger_ struct { - ActionTrigger *ClientEvent_ActionTrigger `protobuf:"bytes,5,opt,name=action_trigger,json=actionTrigger,proto3,oneof"` -} - -type ClientEvent_Interrupt_ struct { - Interrupt *ClientEvent_Interrupt `protobuf:"bytes,6,opt,name=interrupt,proto3,oneof"` -} - -type ClientEvent_Hello_ struct { - // MAY be sent as the first message on a newly opened Attach stream; - // asserts the protocol version only. Not required to bind any - // session — that happens via the variants below. - Hello *ClientEvent_Hello `protobuf:"bytes,7,opt,name=hello,proto3,oneof"` -} - -type ClientEvent_CreateSession_ struct { - // Creates a new session and auto-attaches this stream to it. - CreateSession *ClientEvent_CreateSession `protobuf:"bytes,8,opt,name=create_session,json=createSession,proto3,oneof"` -} - -type ClientEvent_AttachSession_ struct { - // Subscribes an existing (possibly live) session onto this stream, - // triggering a backfill replay (frontend.md §"Backfill"). - AttachSession *ClientEvent_AttachSession `protobuf:"bytes,9,opt,name=attach_session,json=attachSession,proto3,oneof"` -} - -type ClientEvent_ResumeSession_ struct { - // Attaches a historical session for continuation or replay, per - // frontend.md §"Resume and re-open semantics" — a COMPLETED or - // CANCELLED session MAY be re-opened to RUNNING for new turns; a - // bound-exhausted (error_max_*) or FAILED session attaches - // replay-only and rejects any subsequent user_message for it. - ResumeSession *ClientEvent_ResumeSession `protobuf:"bytes,10,opt,name=resume_session,json=resumeSession,proto3,oneof"` -} - -type ClientEvent_DetachSession_ struct { - // Unsubscribes a session from this stream without affecting the - // session itself. - DetachSession *ClientEvent_DetachSession `protobuf:"bytes,11,opt,name=detach_session,json=detachSession,proto3,oneof"` -} - -type ClientEvent_ListSessions_ struct { - // Requests the connection-scoped session summary list. - ListSessions *ClientEvent_ListSessions `protobuf:"bytes,12,opt,name=list_sessions,json=listSessions,proto3,oneof"` -} - -func (*ClientEvent_UserMessage_) isClientEvent_Event() {} - -func (*ClientEvent_SlashCommand_) isClientEvent_Event() {} - -func (*ClientEvent_PlanDecision_) isClientEvent_Event() {} - -func (*ClientEvent_InteractiveResponse_) isClientEvent_Event() {} - -func (*ClientEvent_ActionTrigger_) isClientEvent_Event() {} - -func (*ClientEvent_Interrupt_) isClientEvent_Event() {} - -func (*ClientEvent_Hello_) isClientEvent_Event() {} - -func (*ClientEvent_CreateSession_) isClientEvent_Event() {} - -func (*ClientEvent_AttachSession_) isClientEvent_Event() {} - -func (*ClientEvent_ResumeSession_) isClientEvent_Event() {} - -func (*ClientEvent_DetachSession_) isClientEvent_Event() {} - -func (*ClientEvent_ListSessions_) isClientEvent_Event() {} - -// StreamDelta is the fast path for incremental text display, skipping a -// full Render() round trip. frontend.md §3.2. -type ServerEvent_StreamDelta struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Identifies which displayed element this delta appends to (e.g. a - // RenderNode id from a prior Render), for correlating consecutive - // deltas into one growing piece of text. - TargetId string `protobuf:"bytes,1,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` - // The incremental text to append. - Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_StreamDelta) Reset() { - *x = ServerEvent_StreamDelta{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_StreamDelta) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_StreamDelta) ProtoMessage() {} - -func (x *ServerEvent_StreamDelta) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_StreamDelta.ProtoReflect.Descriptor instead. -func (*ServerEvent_StreamDelta) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 0} -} - -func (x *ServerEvent_StreamDelta) GetTargetId() string { - if x != nil { - return x.TargetId - } - return "" -} - -func (x *ServerEvent_StreamDelta) GetText() string { - if x != nil { - return x.Text - } - return "" -} - -// Render carries one placed RenderTree to Paint. frontend.md §3.2. -type ServerEvent_Render struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The content, its target region, and its replace/append behavior. - Content *v1.PlacedContent `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_Render) Reset() { - *x = ServerEvent_Render{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_Render) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_Render) ProtoMessage() {} - -func (x *ServerEvent_Render) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_Render.ProtoReflect.Descriptor instead. -func (*ServerEvent_Render) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 1} -} - -func (x *ServerEvent_Render) GetContent() *v1.PlacedContent { - if x != nil { - return x.Content - } - return nil -} - -// PermissionRequest asks the user to resolve an agent-loop.md §5.2 `ask` -// decision: the kernel blocks this plan item's apply until a -// ClientEvent.plan_decision resolves it. -type ServerEvent_PermissionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The plan item awaiting a decision. - PlanItem *v11.PlanItem `protobuf:"bytes,1,opt,name=plan_item,json=planItem,proto3" json:"plan_item,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_PermissionRequest) Reset() { - *x = ServerEvent_PermissionRequest{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_PermissionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_PermissionRequest) ProtoMessage() {} - -func (x *ServerEvent_PermissionRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_PermissionRequest.ProtoReflect.Descriptor instead. -func (*ServerEvent_PermissionRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 2} -} - -func (x *ServerEvent_PermissionRequest) GetPlanItem() *v11.PlanItem { - if x != nil { - return x.PlanItem - } - return nil -} - -// PlanReady announces a complete plan for display, e.g. before execution -// begins or after a replan. -type ServerEvent_PlanReady struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The plan to display. - Plan *v11.Plan `protobuf:"bytes,1,opt,name=plan,proto3" json:"plan,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_PlanReady) Reset() { - *x = ServerEvent_PlanReady{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_PlanReady) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_PlanReady) ProtoMessage() {} - -func (x *ServerEvent_PlanReady) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_PlanReady.ProtoReflect.Descriptor instead. -func (*ServerEvent_PlanReady) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 3} -} - -func (x *ServerEvent_PlanReady) GetPlan() *v11.Plan { - if x != nil { - return x.Plan - } - return nil -} - -// InteractiveRequest corresponds to a tool.md §2.1 -// TOOL_KIND_INTERACTIVE call. A frontend MUST render this in the -// REGION_OVERLAY region (frontend.md §2), the same visual treatment as an -// ordinary `ask` prompt. -type ServerEvent_InteractiveRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Identifies the pending interactive tool call, echoed back in the - // resolving ClientEvent.interactive_response. - CallId string `protobuf:"bytes,1,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` - // The tool being invoked (tool.md §2.1 ToolSchema.name). - ToolName string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` - // The prompt to render. - Prompt *v1.RenderTree `protobuf:"bytes,3,opt,name=prompt,proto3" json:"prompt,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_InteractiveRequest) Reset() { - *x = ServerEvent_InteractiveRequest{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_InteractiveRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_InteractiveRequest) ProtoMessage() {} - -func (x *ServerEvent_InteractiveRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_InteractiveRequest.ProtoReflect.Descriptor instead. -func (*ServerEvent_InteractiveRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 4} -} - -func (x *ServerEvent_InteractiveRequest) GetCallId() string { - if x != nil { - return x.CallId - } - return "" -} - -func (x *ServerEvent_InteractiveRequest) GetToolName() string { - if x != nil { - return x.ToolName - } - return "" -} - -func (x *ServerEvent_InteractiveRequest) GetPrompt() *v1.RenderTree { - if x != nil { - return x.Prompt - } - return nil -} - -// SessionTreeUpdate reports a change in a nested sub-session's lifecycle -// (e.g. a RunSession-spawned child), so a frontend can keep a -// SubSessionNode's displayed status current. Reports a CHILD session's -// status — for the attached session's OWN status, see -// SessionStatusUpdate below, a deliberately distinct variant. -type ServerEvent_SessionTreeUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The parent session's id. - ParentSessionId string `protobuf:"bytes,1,opt,name=parent_session_id,json=parentSessionId,proto3" json:"parent_session_id,omitempty"` - // The child session's id. - ChildSessionId string `protobuf:"bytes,2,opt,name=child_session_id,json=childSessionId,proto3" json:"child_session_id,omitempty"` - // The child session's current status. - Status v12.SessionStatus `protobuf:"varint,3,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_SessionTreeUpdate) Reset() { - *x = ServerEvent_SessionTreeUpdate{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_SessionTreeUpdate) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_SessionTreeUpdate) ProtoMessage() {} - -func (x *ServerEvent_SessionTreeUpdate) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_SessionTreeUpdate.ProtoReflect.Descriptor instead. -func (*ServerEvent_SessionTreeUpdate) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 5} -} - -func (x *ServerEvent_SessionTreeUpdate) GetParentSessionId() string { - if x != nil { - return x.ParentSessionId - } - return "" -} - -func (x *ServerEvent_SessionTreeUpdate) GetChildSessionId() string { - if x != nil { - return x.ChildSessionId - } - return "" -} - -func (x *ServerEvent_SessionTreeUpdate) GetStatus() v12.SessionStatus { - if x != nil { - return x.Status - } - return v12.SessionStatus(0) -} - -// Error carries a structured, non-fatal frontend error for display. -type ServerEvent_Error struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The error's category and message. - Error *FrontendError `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_Error) Reset() { - *x = ServerEvent_Error{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_Error) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_Error) ProtoMessage() {} - -func (x *ServerEvent_Error) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_Error.ProtoReflect.Descriptor instead. -func (*ServerEvent_Error) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 6} -} - -func (x *ServerEvent_Error) GetError() *FrontendError { - if x != nil { - return x.Error - } - return nil -} - -// SessionCreated acknowledges a successful ClientEvent.CreateSession. -type ServerEvent_SessionCreated struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The newly created session's info. - Info *v12.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_SessionCreated) Reset() { - *x = ServerEvent_SessionCreated{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_SessionCreated) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_SessionCreated) ProtoMessage() {} - -func (x *ServerEvent_SessionCreated) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_SessionCreated.ProtoReflect.Descriptor instead. -func (*ServerEvent_SessionCreated) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 7} -} - -func (x *ServerEvent_SessionCreated) GetInfo() *v12.SessionInfo { - if x != nil { - return x.Info - } - return nil -} - -// SessionAttached acknowledges a successful ClientEvent.AttachSession or -// ResumeSession, and opens that session's backfill batch. -type ServerEvent_SessionAttached struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The attached session's current info. - Info *v12.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_SessionAttached) Reset() { - *x = ServerEvent_SessionAttached{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_SessionAttached) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_SessionAttached) ProtoMessage() {} - -func (x *ServerEvent_SessionAttached) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_SessionAttached.ProtoReflect.Descriptor instead. -func (*ServerEvent_SessionAttached) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 8} -} - -func (x *ServerEvent_SessionAttached) GetInfo() *v12.SessionInfo { - if x != nil { - return x.Info - } - return nil -} - -// BackfillComplete is the done-marker closing a backfill batch, per -// frontend.md §"Backfill". Unicast to the attaching stream only. -type ServerEvent_BackfillComplete struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The persisted sequence number of the last event replayed in this - // batch. Live events with sequence > last_sequence follow. - LastSequence int64 `protobuf:"varint,1,opt,name=last_sequence,json=lastSequence,proto3" json:"last_sequence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_BackfillComplete) Reset() { - *x = ServerEvent_BackfillComplete{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_BackfillComplete) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_BackfillComplete) ProtoMessage() {} - -func (x *ServerEvent_BackfillComplete) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_BackfillComplete.ProtoReflect.Descriptor instead. -func (*ServerEvent_BackfillComplete) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 9} -} - -func (x *ServerEvent_BackfillComplete) GetLastSequence() int64 { - if x != nil { - return x.LastSequence - } - return 0 -} - -// SessionDetached acknowledges a successful ClientEvent.DetachSession. -type ServerEvent_SessionDetached struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_SessionDetached) Reset() { - *x = ServerEvent_SessionDetached{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_SessionDetached) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_SessionDetached) ProtoMessage() {} - -func (x *ServerEvent_SessionDetached) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_SessionDetached.ProtoReflect.Descriptor instead. -func (*ServerEvent_SessionDetached) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 10} -} - -// SessionList answers a ClientEvent.ListSessions. -type ServerEvent_SessionList struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The matching sessions, most-recently-started first. - Sessions []*v12.SessionInfo `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_SessionList) Reset() { - *x = ServerEvent_SessionList{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_SessionList) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_SessionList) ProtoMessage() {} - -func (x *ServerEvent_SessionList) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_SessionList.ProtoReflect.Descriptor instead. -func (*ServerEvent_SessionList) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 11} -} - -func (x *ServerEvent_SessionList) GetSessions() []*v12.SessionInfo { - if x != nil { - return x.Sessions - } - return nil -} - -// SlashCommandRegistry is the profile-scoped aggregate of every loaded -// provider's declared slash commands for this session, per -// frontend.md §"Slash commands" and specifications/slashcommand/. Two -// separate lists rather than one, since the two kinds are declared by -// different provider categories and dispatched differently — a -// frontend distinguishes them the same way it renders them (a `/name` -// lookup checks both), but the kernel keeps their namespaces distinct -// per-list while still enforcing one combined collision check across -// both at config-load time. -type ServerEvent_SlashCommandRegistry struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Every registered direct-invoke command, declared by a - // slashcommand.v1 provider's own GetCapabilities response. - // Name-collision-checked (jointly with prompt_expansion_commands - // below) at config-load time (frontend.md §"Slash commands"). - DirectInvokeCommands []*v13.SlashCommandSpec `protobuf:"bytes,1,rep,name=direct_invoke_commands,json=directInvokeCommands,proto3" json:"direct_invoke_commands,omitempty"` - // Every registered prompt-expansion command, declared by any - // category's own capability response. Name-collision-checked - // (jointly with direct_invoke_commands above) at config-load time. - PromptExpansionCommands []*v14.PromptExpansionSpec `protobuf:"bytes,2,rep,name=prompt_expansion_commands,json=promptExpansionCommands,proto3" json:"prompt_expansion_commands,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_SlashCommandRegistry) Reset() { - *x = ServerEvent_SlashCommandRegistry{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_SlashCommandRegistry) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_SlashCommandRegistry) ProtoMessage() {} - -func (x *ServerEvent_SlashCommandRegistry) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[14] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_SlashCommandRegistry.ProtoReflect.Descriptor instead. -func (*ServerEvent_SlashCommandRegistry) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 12} -} - -func (x *ServerEvent_SlashCommandRegistry) GetDirectInvokeCommands() []*v13.SlashCommandSpec { - if x != nil { - return x.DirectInvokeCommands - } - return nil -} - -func (x *ServerEvent_SlashCommandRegistry) GetPromptExpansionCommands() []*v14.PromptExpansionSpec { - if x != nil { - return x.PromptExpansionCommands - } - return nil -} - -// UsageUpdate carries one turn's token/cost accounting and the -// session's running totals, for a context-budget indicator or similar. -type ServerEvent_UsageUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - // This turn's token accounting. - Turn *v15.Usage `protobuf:"bytes,1,opt,name=turn,proto3" json:"turn,omitempty"` - // The session's running total spend in USD, mirroring - // session.v1.SessionInfo.cost_usd. - CumulativeCostUsd float64 `protobuf:"fixed64,2,opt,name=cumulative_cost_usd,json=cumulativeCostUsd,proto3" json:"cumulative_cost_usd,omitempty"` - // The session's running total token count against `effective_ceiling` - // below — the pair a context-budget indicator divides to get - // pressure (e.g. "51,204 / 200,000"). - UsedTokens int64 `protobuf:"varint,3,opt,name=used_tokens,json=usedTokens,proto3" json:"used_tokens,omitempty"` - // The usable context budget this session's turns are measured - // against (model.v1.ModelTarget.effective_ceiling). - EffectiveCeiling int64 `protobuf:"varint,4,opt,name=effective_ceiling,json=effectiveCeiling,proto3" json:"effective_ceiling,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_UsageUpdate) Reset() { - *x = ServerEvent_UsageUpdate{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_UsageUpdate) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_UsageUpdate) ProtoMessage() {} - -func (x *ServerEvent_UsageUpdate) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[15] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_UsageUpdate.ProtoReflect.Descriptor instead. -func (*ServerEvent_UsageUpdate) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 13} -} - -func (x *ServerEvent_UsageUpdate) GetTurn() *v15.Usage { - if x != nil { - return x.Turn - } - return nil -} - -func (x *ServerEvent_UsageUpdate) GetCumulativeCostUsd() float64 { - if x != nil { - return x.CumulativeCostUsd - } - return 0 -} - -func (x *ServerEvent_UsageUpdate) GetUsedTokens() int64 { - if x != nil { - return x.UsedTokens - } - return 0 -} - -func (x *ServerEvent_UsageUpdate) GetEffectiveCeiling() int64 { - if x != nil { - return x.EffectiveCeiling - } - return 0 -} - -// SessionStatusUpdate reports the attached session's OWN lifecycle -// status transition. Distinct from SessionTreeUpdate above, which -// reports a CHILD session's status. -type ServerEvent_SessionStatusUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The session's new status. - Status v12.SessionStatus `protobuf:"varint,1,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServerEvent_SessionStatusUpdate) Reset() { - *x = ServerEvent_SessionStatusUpdate{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServerEvent_SessionStatusUpdate) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerEvent_SessionStatusUpdate) ProtoMessage() {} - -func (x *ServerEvent_SessionStatusUpdate) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[16] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerEvent_SessionStatusUpdate.ProtoReflect.Descriptor instead. -func (*ServerEvent_SessionStatusUpdate) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{0, 14} -} - -func (x *ServerEvent_SessionStatusUpdate) GetStatus() v12.SessionStatus { - if x != nil { - return x.Status - } - return v12.SessionStatus(0) -} - -// UserMessage is ordinary chat input from the user. -type ClientEvent_UserMessage struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The message content, in emission order. MUST contain at least one - // block. - Content []*v16.ContentBlock `protobuf:"bytes,2,rep,name=content,proto3" json:"content,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_UserMessage) Reset() { - *x = ClientEvent_UserMessage{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_UserMessage) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_UserMessage) ProtoMessage() {} - -func (x *ClientEvent_UserMessage) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[17] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_UserMessage.ProtoReflect.Descriptor instead. -func (*ClientEvent_UserMessage) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 0} -} - -func (x *ClientEvent_UserMessage) GetContent() []*v16.ContentBlock { - if x != nil { - return x.Content - } - return nil -} - -// SlashCommand is a dispatched slash command invocation (frontend.md §5). -type ClientEvent_SlashCommand struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The command name, without its leading slash. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // The raw argument string following the command name. - Args string `protobuf:"bytes,2,opt,name=args,proto3" json:"args,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_SlashCommand) Reset() { - *x = ClientEvent_SlashCommand{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_SlashCommand) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_SlashCommand) ProtoMessage() {} - -func (x *ClientEvent_SlashCommand) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_SlashCommand.ProtoReflect.Descriptor instead. -func (*ClientEvent_SlashCommand) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 1} -} - -func (x *ClientEvent_SlashCommand) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ClientEvent_SlashCommand) GetArgs() string { - if x != nil { - return x.Args - } - return "" -} - -// PlanDecision resolves a pending ServerEvent.PermissionRequest. -type ClientEvent_PlanDecision struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The plan item being resolved, matching - // ServerEvent.PermissionRequest.plan_item's id. - PlanItemId string `protobuf:"bytes,1,opt,name=plan_item_id,json=planItemId,proto3" json:"plan_item_id,omitempty"` - // The user's decision. - Decision ClientDecision `protobuf:"varint,2,opt,name=decision,proto3,enum=pluggableharness.frontend.v1.ClientDecision" json:"decision,omitempty"` - // When present, a user-edited replacement for the plan item's tool - // input. The kernel MUST re-validate this against the tool's - // input_schema (tool.md §6); an invalid correction is rejected as a - // distinct error, not silently coerced. - CorrectedInput *structpb.Struct `protobuf:"bytes,3,opt,name=corrected_input,json=correctedInput,proto3,oneof" json:"corrected_input,omitempty"` - // How durably this decision applies beyond this one item — see - // PlanDecisionScope. - Scope PlanDecisionScope `protobuf:"varint,4,opt,name=scope,proto3,enum=pluggableharness.frontend.v1.PlanDecisionScope" json:"scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_PlanDecision) Reset() { - *x = ClientEvent_PlanDecision{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_PlanDecision) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_PlanDecision) ProtoMessage() {} - -func (x *ClientEvent_PlanDecision) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_PlanDecision.ProtoReflect.Descriptor instead. -func (*ClientEvent_PlanDecision) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 2} -} - -func (x *ClientEvent_PlanDecision) GetPlanItemId() string { - if x != nil { - return x.PlanItemId - } - return "" -} - -func (x *ClientEvent_PlanDecision) GetDecision() ClientDecision { - if x != nil { - return x.Decision - } - return ClientDecision_CLIENT_DECISION_UNSPECIFIED -} - -func (x *ClientEvent_PlanDecision) GetCorrectedInput() *structpb.Struct { - if x != nil { - return x.CorrectedInput - } - return nil -} - -func (x *ClientEvent_PlanDecision) GetScope() PlanDecisionScope { - if x != nil { - return x.Scope - } - return PlanDecisionScope_PLAN_DECISION_SCOPE_UNSPECIFIED -} - -// InteractiveResponse resolves a pending ServerEvent.InteractiveRequest. -type ClientEvent_InteractiveResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Correlates to ServerEvent.InteractiveRequest.call_id. - CallId string `protobuf:"bytes,1,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` - // The user's response, becoming the interactive call's - // ToolResult.payload. - Response *structpb.Struct `protobuf:"bytes,2,opt,name=response,proto3" json:"response,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_InteractiveResponse) Reset() { - *x = ClientEvent_InteractiveResponse{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_InteractiveResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_InteractiveResponse) ProtoMessage() {} - -func (x *ClientEvent_InteractiveResponse) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[20] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_InteractiveResponse.ProtoReflect.Descriptor instead. -func (*ClientEvent_InteractiveResponse) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 3} -} - -func (x *ClientEvent_InteractiveResponse) GetCallId() string { - if x != nil { - return x.CallId - } - return "" -} - -func (x *ClientEvent_InteractiveResponse) GetResponse() *structpb.Struct { - if x != nil { - return x.Response - } - return nil -} - -// ActionTrigger is dispatched when a user activates a RenderNode's -// ActionNode (render/v1/types.proto's ActionNode, frontend.md §5.1). The kernel -// handles this identically to a direct_invoke slash command: the normal -// Invoke/plan-apply pipeline including policy evaluation, with no model -// turn. -type ClientEvent_ActionTrigger struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The originating ActionNode's id (render/v1/types.proto's ActionNode.id). - NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - // The tool operation to invoke (tool.md §2 ToolSchema.name), echoed - // unchanged from the originating ActionNode.tool_name. - ToolName string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` - // The arguments to invoke it with, echoed unchanged from the - // originating ActionNode.args. - Args *structpb.Struct `protobuf:"bytes,3,opt,name=args,proto3" json:"args,omitempty"` - // The declared name of the tool provider plugin `tool_name` belongs - // to, echoed unchanged from the originating ActionNode.provider - // (render/v1/types.proto) — tool_name is only unique per provider. - Provider string `protobuf:"bytes,4,opt,name=provider,proto3" json:"provider,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_ActionTrigger) Reset() { - *x = ClientEvent_ActionTrigger{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_ActionTrigger) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_ActionTrigger) ProtoMessage() {} - -func (x *ClientEvent_ActionTrigger) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[21] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_ActionTrigger.ProtoReflect.Descriptor instead. -func (*ClientEvent_ActionTrigger) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 4} -} - -func (x *ClientEvent_ActionTrigger) GetNodeId() string { - if x != nil { - return x.NodeId - } - return "" -} - -func (x *ClientEvent_ActionTrigger) GetToolName() string { - if x != nil { - return x.ToolName - } - return "" -} - -func (x *ClientEvent_ActionTrigger) GetArgs() *structpb.Struct { - if x != nil { - return x.Args - } - return nil -} - -func (x *ClientEvent_ActionTrigger) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -// Interrupt carries no fields; it signals that the user wants to -// interrupt the current turn. -type ClientEvent_Interrupt struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_Interrupt) Reset() { - *x = ClientEvent_Interrupt{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_Interrupt) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_Interrupt) ProtoMessage() {} - -func (x *ClientEvent_Interrupt) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[22] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_Interrupt.ProtoReflect.Descriptor instead. -func (*ClientEvent_Interrupt) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 5} -} - -// Hello MAY be sent as the first ClientEvent on a newly opened Attach -// stream, to assert the protocol version. Binding a session happens via -// the session-control variants, not via Hello. -type ClientEvent_Hello struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The protocol version this frontend was built against. - ProtocolVersion uint32 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_Hello) Reset() { - *x = ClientEvent_Hello{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_Hello) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_Hello) ProtoMessage() {} - -func (x *ClientEvent_Hello) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[23] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_Hello.ProtoReflect.Descriptor instead. -func (*ClientEvent_Hello) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 6} -} - -func (x *ClientEvent_Hello) GetProtocolVersion() uint32 { - if x != nil { - return x.ProtocolVersion - } - return 0 -} - -// CreateSession creates a new session and auto-attaches this stream to -// it, per frontend.md §"Session lifecycle". Answered by -// ServerEvent.SessionCreated. -type ClientEvent_CreateSession struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Client-generated, echoed back on ServerEvent.request_id. - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // The agent.hcl profile to create the session under. Absent means - // the kernel's configured default profile. - Profile *string `protobuf:"bytes,2,opt,name=profile,proto3,oneof" json:"profile,omitempty"` - // An initial user message to seed the session with, submitted as the - // first turn once the session is created. Absent creates an empty - // session awaiting the first ordinary user_message. - InitialPrompt *string `protobuf:"bytes,3,opt,name=initial_prompt,json=initialPrompt,proto3,oneof" json:"initial_prompt,omitempty"` - // The session's working directory. Absent means the kernel's own - // working directory at creation time. - WorkingDirectory *string `protobuf:"bytes,4,opt,name=working_directory,json=workingDirectory,proto3,oneof" json:"working_directory,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_CreateSession) Reset() { - *x = ClientEvent_CreateSession{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[24] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_CreateSession) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_CreateSession) ProtoMessage() {} - -func (x *ClientEvent_CreateSession) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[24] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_CreateSession.ProtoReflect.Descriptor instead. -func (*ClientEvent_CreateSession) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 7} -} - -func (x *ClientEvent_CreateSession) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *ClientEvent_CreateSession) GetProfile() string { - if x != nil && x.Profile != nil { - return *x.Profile - } - return "" -} - -func (x *ClientEvent_CreateSession) GetInitialPrompt() string { - if x != nil && x.InitialPrompt != nil { - return *x.InitialPrompt - } - return "" -} - -func (x *ClientEvent_CreateSession) GetWorkingDirectory() string { - if x != nil && x.WorkingDirectory != nil { - return *x.WorkingDirectory - } - return "" -} - -// AttachSession subscribes an existing session (live or terminal) onto -// this stream, triggering a backfill replay. Answered by -// ServerEvent.SessionAttached, bracketing a replay batch closed by -// ServerEvent.BackfillComplete. -type ClientEvent_AttachSession struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Client-generated, echoed back on ServerEvent.request_id. - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // The session to attach. - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_AttachSession) Reset() { - *x = ClientEvent_AttachSession{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[25] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_AttachSession) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_AttachSession) ProtoMessage() {} - -func (x *ClientEvent_AttachSession) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[25] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_AttachSession.ProtoReflect.Descriptor instead. -func (*ClientEvent_AttachSession) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 8} -} - -func (x *ClientEvent_AttachSession) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *ClientEvent_AttachSession) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -// ResumeSession attaches a historical (possibly terminal) session, per -// frontend.md §"Resume and re-open semantics". A COMPLETED or -// CANCELLED session MAY be re-opened to SESSION_STATUS_RUNNING for new -// turns; a bound-exhausted (error_max_*) or FAILED session attaches -// replay-only — the kernel MUST reject a subsequent user_message -// against it with FRONTEND_ERROR_CATEGORY_SESSION_REPLAY_ONLY. Answered -// identically to AttachSession: SessionAttached bracketing a backfill -// batch. -type ClientEvent_ResumeSession struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Client-generated, echoed back on ServerEvent.request_id. - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // The session to resume. - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_ResumeSession) Reset() { - *x = ClientEvent_ResumeSession{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[26] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_ResumeSession) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_ResumeSession) ProtoMessage() {} - -func (x *ClientEvent_ResumeSession) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[26] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_ResumeSession.ProtoReflect.Descriptor instead. -func (*ClientEvent_ResumeSession) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 9} -} - -func (x *ClientEvent_ResumeSession) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *ClientEvent_ResumeSession) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -// DetachSession unsubscribes a session from this stream without -// affecting the session itself — other streams attached to the same -// session, and the session's own execution, are unaffected. Answered -// by ServerEvent.SessionDetached. -type ClientEvent_DetachSession struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Client-generated, echoed back on ServerEvent.request_id. - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // The session to detach. - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_DetachSession) Reset() { - *x = ClientEvent_DetachSession{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[27] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_DetachSession) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_DetachSession) ProtoMessage() {} - -func (x *ClientEvent_DetachSession) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[27] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_DetachSession.ProtoReflect.Descriptor instead. -func (*ClientEvent_DetachSession) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 10} -} - -func (x *ClientEvent_DetachSession) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *ClientEvent_DetachSession) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -// ListSessions requests the connection-scoped session summary list. -// Answered by ServerEvent.SessionList. -type ClientEvent_ListSessions struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Client-generated, echoed back on ServerEvent.request_id. - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // Restricts the result to sessions in this status. Absent means no - // status filter. - Status *v12.SessionStatus `protobuf:"varint,2,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus,oneof" json:"status,omitempty"` - // Restricts the result to children of this session. Absent means no - // parent filter. - ParentSessionId *string `protobuf:"bytes,3,opt,name=parent_session_id,json=parentSessionId,proto3,oneof" json:"parent_session_id,omitempty"` - // True: only root sessions (no parent_session_id). False: all - // sessions matching the other filters, at any depth. - RootsOnly bool `protobuf:"varint,4,opt,name=roots_only,json=rootsOnly,proto3" json:"roots_only,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientEvent_ListSessions) Reset() { - *x = ClientEvent_ListSessions{} - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[28] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientEvent_ListSessions) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientEvent_ListSessions) ProtoMessage() {} - -func (x *ClientEvent_ListSessions) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_frontend_v1_events_proto_msgTypes[28] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientEvent_ListSessions.ProtoReflect.Descriptor instead. -func (*ClientEvent_ListSessions) Descriptor() ([]byte, []int) { - return file_pluggableharness_frontend_v1_events_proto_rawDescGZIP(), []int{1, 11} -} - -func (x *ClientEvent_ListSessions) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *ClientEvent_ListSessions) GetStatus() v12.SessionStatus { - if x != nil && x.Status != nil { - return *x.Status - } - return v12.SessionStatus(0) -} - -func (x *ClientEvent_ListSessions) GetParentSessionId() string { - if x != nil && x.ParentSessionId != nil { - return *x.ParentSessionId - } - return "" -} - -func (x *ClientEvent_ListSessions) GetRootsOnly() bool { - if x != nil { - return x.RootsOnly - } - return false -} - -var File_pluggableharness_frontend_v1_events_proto protoreflect.FileDescriptor - -const file_pluggableharness_frontend_v1_events_proto_rawDesc = "" + - "\n" + - ")pluggableharness/frontend/v1/events.proto\x12\x1cpluggableharness.frontend.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a)pluggableharness/frontend/v1/errors.proto\x1a%pluggableharness/model/v1/types.proto\x1a$pluggableharness/plan/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\x1a'pluggableharness/session/v1/types.proto\x1a,pluggableharness/slashcommand/v1/types.proto\"\xd9\x18\n" + - "\vServerEvent\x12\x1d\n" + - "\n" + - "session_id\x18d \x01(\tR\tsessionId\x12\"\n" + - "\n" + - "request_id\x18e \x01(\tH\x01R\trequestId\x88\x01\x01\x12Z\n" + - "\fstream_delta\x18\x01 \x01(\v25.pluggableharness.frontend.v1.ServerEvent.StreamDeltaH\x00R\vstreamDelta\x12J\n" + - "\x06render\x18\x02 \x01(\v20.pluggableharness.frontend.v1.ServerEvent.RenderH\x00R\x06render\x12l\n" + - "\x12permission_request\x18\x03 \x01(\v2;.pluggableharness.frontend.v1.ServerEvent.PermissionRequestH\x00R\x11permissionRequest\x12T\n" + - "\n" + - "plan_ready\x18\x04 \x01(\v23.pluggableharness.frontend.v1.ServerEvent.PlanReadyH\x00R\tplanReady\x12o\n" + - "\x13interactive_request\x18\x05 \x01(\v2<.pluggableharness.frontend.v1.ServerEvent.InteractiveRequestH\x00R\x12interactiveRequest\x12m\n" + - "\x13session_tree_update\x18\x06 \x01(\v2;.pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdateH\x00R\x11sessionTreeUpdate\x12G\n" + - "\x05error\x18\a \x01(\v2/.pluggableharness.frontend.v1.ServerEvent.ErrorH\x00R\x05error\x12c\n" + - "\x0fsession_created\x18\b \x01(\v28.pluggableharness.frontend.v1.ServerEvent.SessionCreatedH\x00R\x0esessionCreated\x12f\n" + - "\x10session_attached\x18\t \x01(\v29.pluggableharness.frontend.v1.ServerEvent.SessionAttachedH\x00R\x0fsessionAttached\x12i\n" + - "\x11backfill_complete\x18\n" + - " \x01(\v2:.pluggableharness.frontend.v1.ServerEvent.BackfillCompleteH\x00R\x10backfillComplete\x12f\n" + - "\x10session_detached\x18\v \x01(\v29.pluggableharness.frontend.v1.ServerEvent.SessionDetachedH\x00R\x0fsessionDetached\x12Z\n" + - "\fsession_list\x18\f \x01(\v25.pluggableharness.frontend.v1.ServerEvent.SessionListH\x00R\vsessionList\x12v\n" + - "\x16slash_command_registry\x18\x0e \x01(\v2>.pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistryH\x00R\x14slashCommandRegistry\x12Z\n" + - "\fusage_update\x18\x0f \x01(\v25.pluggableharness.frontend.v1.ServerEvent.UsageUpdateH\x00R\vusageUpdate\x12s\n" + - "\x15session_status_update\x18\x10 \x01(\v2=.pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdateH\x00R\x13sessionStatusUpdate\x1a>\n" + - "\vStreamDelta\x12\x1b\n" + - "\ttarget_id\x18\x01 \x01(\tR\btargetId\x12\x12\n" + - "\x04text\x18\x02 \x01(\tR\x04text\x1aM\n" + - "\x06Render\x12C\n" + - "\acontent\x18\x01 \x01(\v2).pluggableharness.render.v1.PlacedContentR\acontent\x1aT\n" + - "\x11PermissionRequest\x12?\n" + - "\tplan_item\x18\x01 \x01(\v2\".pluggableharness.plan.v1.PlanItemR\bplanItem\x1a?\n" + - "\tPlanReady\x122\n" + - "\x04plan\x18\x01 \x01(\v2\x1e.pluggableharness.plan.v1.PlanR\x04plan\x1a\x8a\x01\n" + - "\x12InteractiveRequest\x12\x17\n" + - "\acall_id\x18\x01 \x01(\tR\x06callId\x12\x1b\n" + - "\ttool_name\x18\x02 \x01(\tR\btoolName\x12>\n" + - "\x06prompt\x18\x03 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x06prompt\x1a\xad\x01\n" + - "\x11SessionTreeUpdate\x12*\n" + - "\x11parent_session_id\x18\x01 \x01(\tR\x0fparentSessionId\x12(\n" + - "\x10child_session_id\x18\x02 \x01(\tR\x0echildSessionId\x12B\n" + - "\x06status\x18\x03 \x01(\x0e2*.pluggableharness.session.v1.SessionStatusR\x06status\x1aJ\n" + - "\x05Error\x12A\n" + - "\x05error\x18\x01 \x01(\v2+.pluggableharness.frontend.v1.FrontendErrorR\x05error\x1aN\n" + - "\x0eSessionCreated\x12<\n" + - "\x04info\x18\x01 \x01(\v2(.pluggableharness.session.v1.SessionInfoR\x04info\x1aO\n" + - "\x0fSessionAttached\x12<\n" + - "\x04info\x18\x01 \x01(\v2(.pluggableharness.session.v1.SessionInfoR\x04info\x1a7\n" + - "\x10BackfillComplete\x12#\n" + - "\rlast_sequence\x18\x01 \x01(\x03R\flastSequence\x1a\x11\n" + - "\x0fSessionDetached\x1aS\n" + - "\vSessionList\x12D\n" + - "\bsessions\x18\x01 \x03(\v2(.pluggableharness.session.v1.SessionInfoR\bsessions\x1a\xed\x01\n" + - "\x14SlashCommandRegistry\x12h\n" + - "\x16direct_invoke_commands\x18\x01 \x03(\v22.pluggableharness.slashcommand.v1.SlashCommandSpecR\x14directInvokeCommands\x12k\n" + - "\x19prompt_expansion_commands\x18\x02 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\x17promptExpansionCommands\x1a\xc1\x01\n" + - "\vUsageUpdate\x124\n" + - "\x04turn\x18\x01 \x01(\v2 .pluggableharness.model.v1.UsageR\x04turn\x12.\n" + - "\x13cumulative_cost_usd\x18\x02 \x01(\x01R\x11cumulativeCostUsd\x12\x1f\n" + - "\vused_tokens\x18\x03 \x01(\x03R\n" + - "usedTokens\x12+\n" + - "\x11effective_ceiling\x18\x04 \x01(\x03R\x10effectiveCeiling\x1aY\n" + - "\x13SessionStatusUpdate\x12B\n" + - "\x06status\x18\x01 \x01(\x0e2*.pluggableharness.session.v1.SessionStatusR\x06statusB\a\n" + - "\x05eventB\r\n" + - "\v_request_idJ\x04\b\r\x10\x0eR\x0fsession_deleted\"\xd2\x14\n" + - "\vClientEvent\x12\x1d\n" + - "\n" + - "session_id\x18d \x01(\tR\tsessionId\x12Z\n" + - "\fuser_message\x18\x01 \x01(\v25.pluggableharness.frontend.v1.ClientEvent.UserMessageH\x00R\vuserMessage\x12]\n" + - "\rslash_command\x18\x02 \x01(\v26.pluggableharness.frontend.v1.ClientEvent.SlashCommandH\x00R\fslashCommand\x12]\n" + - "\rplan_decision\x18\x03 \x01(\v26.pluggableharness.frontend.v1.ClientEvent.PlanDecisionH\x00R\fplanDecision\x12r\n" + - "\x14interactive_response\x18\x04 \x01(\v2=.pluggableharness.frontend.v1.ClientEvent.InteractiveResponseH\x00R\x13interactiveResponse\x12`\n" + - "\x0eaction_trigger\x18\x05 \x01(\v27.pluggableharness.frontend.v1.ClientEvent.ActionTriggerH\x00R\ractionTrigger\x12S\n" + - "\tinterrupt\x18\x06 \x01(\v23.pluggableharness.frontend.v1.ClientEvent.InterruptH\x00R\tinterrupt\x12G\n" + - "\x05hello\x18\a \x01(\v2/.pluggableharness.frontend.v1.ClientEvent.HelloH\x00R\x05hello\x12`\n" + - "\x0ecreate_session\x18\b \x01(\v27.pluggableharness.frontend.v1.ClientEvent.CreateSessionH\x00R\rcreateSession\x12`\n" + - "\x0eattach_session\x18\t \x01(\v27.pluggableharness.frontend.v1.ClientEvent.AttachSessionH\x00R\rattachSession\x12`\n" + - "\x0eresume_session\x18\n" + - " \x01(\v27.pluggableharness.frontend.v1.ClientEvent.ResumeSessionH\x00R\rresumeSession\x12`\n" + - "\x0edetach_session\x18\v \x01(\v27.pluggableharness.frontend.v1.ClientEvent.DetachSessionH\x00R\rdetachSession\x12]\n" + - "\rlist_sessions\x18\f \x01(\v26.pluggableharness.frontend.v1.ClientEvent.ListSessionsH\x00R\flistSessions\x1a^\n" + - "\vUserMessage\x12C\n" + - "\acontent\x18\x02 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontentJ\x04\b\x01\x10\x02R\x04text\x1a6\n" + - "\fSlashCommand\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + - "\x04args\x18\x02 \x01(\tR\x04args\x1a\x9c\x02\n" + - "\fPlanDecision\x12 \n" + - "\fplan_item_id\x18\x01 \x01(\tR\n" + - "planItemId\x12H\n" + - "\bdecision\x18\x02 \x01(\x0e2,.pluggableharness.frontend.v1.ClientDecisionR\bdecision\x12E\n" + - "\x0fcorrected_input\x18\x03 \x01(\v2\x17.google.protobuf.StructH\x00R\x0ecorrectedInput\x88\x01\x01\x12E\n" + - "\x05scope\x18\x04 \x01(\x0e2/.pluggableharness.frontend.v1.PlanDecisionScopeR\x05scopeB\x12\n" + - "\x10_corrected_input\x1ac\n" + - "\x13InteractiveResponse\x12\x17\n" + - "\acall_id\x18\x01 \x01(\tR\x06callId\x123\n" + - "\bresponse\x18\x02 \x01(\v2\x17.google.protobuf.StructR\bresponse\x1a\x8e\x01\n" + - "\rActionTrigger\x12\x17\n" + - "\anode_id\x18\x01 \x01(\tR\x06nodeId\x12\x1b\n" + - "\ttool_name\x18\x02 \x01(\tR\btoolName\x12+\n" + - "\x04args\x18\x03 \x01(\v2\x17.google.protobuf.StructR\x04args\x12\x1a\n" + - "\bprovider\x18\x04 \x01(\tR\bprovider\x1a\v\n" + - "\tInterrupt\x1a2\n" + - "\x05Hello\x12)\n" + - "\x10protocol_version\x18\x01 \x01(\rR\x0fprotocolVersion\x1a\xe0\x01\n" + - "\rCreateSession\x12\x1d\n" + - "\n" + - "request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" + - "\aprofile\x18\x02 \x01(\tH\x00R\aprofile\x88\x01\x01\x12*\n" + - "\x0einitial_prompt\x18\x03 \x01(\tH\x01R\rinitialPrompt\x88\x01\x01\x120\n" + - "\x11working_directory\x18\x04 \x01(\tH\x02R\x10workingDirectory\x88\x01\x01B\n" + - "\n" + - "\b_profileB\x11\n" + - "\x0f_initial_promptB\x14\n" + - "\x12_working_directory\x1aM\n" + - "\rAttachSession\x12\x1d\n" + - "\n" + - "request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" + - "\n" + - "session_id\x18\x02 \x01(\tR\tsessionId\x1aM\n" + - "\rResumeSession\x12\x1d\n" + - "\n" + - "request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" + - "\n" + - "session_id\x18\x02 \x01(\tR\tsessionId\x1aM\n" + - "\rDetachSession\x12\x1d\n" + - "\n" + - "request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" + - "\n" + - "session_id\x18\x02 \x01(\tR\tsessionId\x1a\xe7\x01\n" + - "\fListSessions\x12\x1d\n" + - "\n" + - "request_id\x18\x01 \x01(\tR\trequestId\x12G\n" + - "\x06status\x18\x02 \x01(\x0e2*.pluggableharness.session.v1.SessionStatusH\x00R\x06status\x88\x01\x01\x12/\n" + - "\x11parent_session_id\x18\x03 \x01(\tH\x01R\x0fparentSessionId\x88\x01\x01\x12\x1d\n" + - "\n" + - "roots_only\x18\x04 \x01(\bR\trootsOnlyB\t\n" + - "\a_statusB\x14\n" + - "\x12_parent_session_idB\a\n" + - "\x05event*f\n" + - "\x0eClientDecision\x12\x1f\n" + - "\x1bCLIENT_DECISION_UNSPECIFIED\x10\x00\x12\x19\n" + - "\x15CLIENT_DECISION_ALLOW\x10\x01\x12\x18\n" + - "\x14CLIENT_DECISION_DENY\x10\x02*\x97\x01\n" + - "\x11PlanDecisionScope\x12#\n" + - "\x1fPLAN_DECISION_SCOPE_UNSPECIFIED\x10\x00\x12\x1c\n" + - "\x18PLAN_DECISION_SCOPE_ONCE\x10\x01\x12\x1f\n" + - "\x1bPLAN_DECISION_SCOPE_SESSION\x10\x02\x12\x1e\n" + - "\x1aPLAN_DECISION_SCOPE_ALWAYS\x10\x03BDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" - -var ( - file_pluggableharness_frontend_v1_events_proto_rawDescOnce sync.Once - file_pluggableharness_frontend_v1_events_proto_rawDescData []byte -) - -func file_pluggableharness_frontend_v1_events_proto_rawDescGZIP() []byte { - file_pluggableharness_frontend_v1_events_proto_rawDescOnce.Do(func() { - file_pluggableharness_frontend_v1_events_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_events_proto_rawDesc), len(file_pluggableharness_frontend_v1_events_proto_rawDesc))) - }) - return file_pluggableharness_frontend_v1_events_proto_rawDescData -} - -var file_pluggableharness_frontend_v1_events_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_pluggableharness_frontend_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 29) -var file_pluggableharness_frontend_v1_events_proto_goTypes = []any{ - (ClientDecision)(0), // 0: pluggableharness.frontend.v1.ClientDecision - (PlanDecisionScope)(0), // 1: pluggableharness.frontend.v1.PlanDecisionScope - (*ServerEvent)(nil), // 2: pluggableharness.frontend.v1.ServerEvent - (*ClientEvent)(nil), // 3: pluggableharness.frontend.v1.ClientEvent - (*ServerEvent_StreamDelta)(nil), // 4: pluggableharness.frontend.v1.ServerEvent.StreamDelta - (*ServerEvent_Render)(nil), // 5: pluggableharness.frontend.v1.ServerEvent.Render - (*ServerEvent_PermissionRequest)(nil), // 6: pluggableharness.frontend.v1.ServerEvent.PermissionRequest - (*ServerEvent_PlanReady)(nil), // 7: pluggableharness.frontend.v1.ServerEvent.PlanReady - (*ServerEvent_InteractiveRequest)(nil), // 8: pluggableharness.frontend.v1.ServerEvent.InteractiveRequest - (*ServerEvent_SessionTreeUpdate)(nil), // 9: pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate - (*ServerEvent_Error)(nil), // 10: pluggableharness.frontend.v1.ServerEvent.Error - (*ServerEvent_SessionCreated)(nil), // 11: pluggableharness.frontend.v1.ServerEvent.SessionCreated - (*ServerEvent_SessionAttached)(nil), // 12: pluggableharness.frontend.v1.ServerEvent.SessionAttached - (*ServerEvent_BackfillComplete)(nil), // 13: pluggableharness.frontend.v1.ServerEvent.BackfillComplete - (*ServerEvent_SessionDetached)(nil), // 14: pluggableharness.frontend.v1.ServerEvent.SessionDetached - (*ServerEvent_SessionList)(nil), // 15: pluggableharness.frontend.v1.ServerEvent.SessionList - (*ServerEvent_SlashCommandRegistry)(nil), // 16: pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry - (*ServerEvent_UsageUpdate)(nil), // 17: pluggableharness.frontend.v1.ServerEvent.UsageUpdate - (*ServerEvent_SessionStatusUpdate)(nil), // 18: pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate - (*ClientEvent_UserMessage)(nil), // 19: pluggableharness.frontend.v1.ClientEvent.UserMessage - (*ClientEvent_SlashCommand)(nil), // 20: pluggableharness.frontend.v1.ClientEvent.SlashCommand - (*ClientEvent_PlanDecision)(nil), // 21: pluggableharness.frontend.v1.ClientEvent.PlanDecision - (*ClientEvent_InteractiveResponse)(nil), // 22: pluggableharness.frontend.v1.ClientEvent.InteractiveResponse - (*ClientEvent_ActionTrigger)(nil), // 23: pluggableharness.frontend.v1.ClientEvent.ActionTrigger - (*ClientEvent_Interrupt)(nil), // 24: pluggableharness.frontend.v1.ClientEvent.Interrupt - (*ClientEvent_Hello)(nil), // 25: pluggableharness.frontend.v1.ClientEvent.Hello - (*ClientEvent_CreateSession)(nil), // 26: pluggableharness.frontend.v1.ClientEvent.CreateSession - (*ClientEvent_AttachSession)(nil), // 27: pluggableharness.frontend.v1.ClientEvent.AttachSession - (*ClientEvent_ResumeSession)(nil), // 28: pluggableharness.frontend.v1.ClientEvent.ResumeSession - (*ClientEvent_DetachSession)(nil), // 29: pluggableharness.frontend.v1.ClientEvent.DetachSession - (*ClientEvent_ListSessions)(nil), // 30: pluggableharness.frontend.v1.ClientEvent.ListSessions - (*v1.PlacedContent)(nil), // 31: pluggableharness.render.v1.PlacedContent - (*v11.PlanItem)(nil), // 32: pluggableharness.plan.v1.PlanItem - (*v11.Plan)(nil), // 33: pluggableharness.plan.v1.Plan - (*v1.RenderTree)(nil), // 34: pluggableharness.render.v1.RenderTree - (v12.SessionStatus)(0), // 35: pluggableharness.session.v1.SessionStatus - (*FrontendError)(nil), // 36: pluggableharness.frontend.v1.FrontendError - (*v12.SessionInfo)(nil), // 37: pluggableharness.session.v1.SessionInfo - (*v13.SlashCommandSpec)(nil), // 38: pluggableharness.slashcommand.v1.SlashCommandSpec - (*v14.PromptExpansionSpec)(nil), // 39: pluggableharness.common.v1.PromptExpansionSpec - (*v15.Usage)(nil), // 40: pluggableharness.model.v1.Usage - (*v16.ContentBlock)(nil), // 41: pluggableharness.content.v1.ContentBlock - (*structpb.Struct)(nil), // 42: google.protobuf.Struct -} -var file_pluggableharness_frontend_v1_events_proto_depIdxs = []int32{ - 4, // 0: pluggableharness.frontend.v1.ServerEvent.stream_delta:type_name -> pluggableharness.frontend.v1.ServerEvent.StreamDelta - 5, // 1: pluggableharness.frontend.v1.ServerEvent.render:type_name -> pluggableharness.frontend.v1.ServerEvent.Render - 6, // 2: pluggableharness.frontend.v1.ServerEvent.permission_request:type_name -> pluggableharness.frontend.v1.ServerEvent.PermissionRequest - 7, // 3: pluggableharness.frontend.v1.ServerEvent.plan_ready:type_name -> pluggableharness.frontend.v1.ServerEvent.PlanReady - 8, // 4: pluggableharness.frontend.v1.ServerEvent.interactive_request:type_name -> pluggableharness.frontend.v1.ServerEvent.InteractiveRequest - 9, // 5: pluggableharness.frontend.v1.ServerEvent.session_tree_update:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate - 10, // 6: pluggableharness.frontend.v1.ServerEvent.error:type_name -> pluggableharness.frontend.v1.ServerEvent.Error - 11, // 7: pluggableharness.frontend.v1.ServerEvent.session_created:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionCreated - 12, // 8: pluggableharness.frontend.v1.ServerEvent.session_attached:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionAttached - 13, // 9: pluggableharness.frontend.v1.ServerEvent.backfill_complete:type_name -> pluggableharness.frontend.v1.ServerEvent.BackfillComplete - 14, // 10: pluggableharness.frontend.v1.ServerEvent.session_detached:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionDetached - 15, // 11: pluggableharness.frontend.v1.ServerEvent.session_list:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionList - 16, // 12: pluggableharness.frontend.v1.ServerEvent.slash_command_registry:type_name -> pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry - 17, // 13: pluggableharness.frontend.v1.ServerEvent.usage_update:type_name -> pluggableharness.frontend.v1.ServerEvent.UsageUpdate - 18, // 14: pluggableharness.frontend.v1.ServerEvent.session_status_update:type_name -> pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate - 19, // 15: pluggableharness.frontend.v1.ClientEvent.user_message:type_name -> pluggableharness.frontend.v1.ClientEvent.UserMessage - 20, // 16: pluggableharness.frontend.v1.ClientEvent.slash_command:type_name -> pluggableharness.frontend.v1.ClientEvent.SlashCommand - 21, // 17: pluggableharness.frontend.v1.ClientEvent.plan_decision:type_name -> pluggableharness.frontend.v1.ClientEvent.PlanDecision - 22, // 18: pluggableharness.frontend.v1.ClientEvent.interactive_response:type_name -> pluggableharness.frontend.v1.ClientEvent.InteractiveResponse - 23, // 19: pluggableharness.frontend.v1.ClientEvent.action_trigger:type_name -> pluggableharness.frontend.v1.ClientEvent.ActionTrigger - 24, // 20: pluggableharness.frontend.v1.ClientEvent.interrupt:type_name -> pluggableharness.frontend.v1.ClientEvent.Interrupt - 25, // 21: pluggableharness.frontend.v1.ClientEvent.hello:type_name -> pluggableharness.frontend.v1.ClientEvent.Hello - 26, // 22: pluggableharness.frontend.v1.ClientEvent.create_session:type_name -> pluggableharness.frontend.v1.ClientEvent.CreateSession - 27, // 23: pluggableharness.frontend.v1.ClientEvent.attach_session:type_name -> pluggableharness.frontend.v1.ClientEvent.AttachSession - 28, // 24: pluggableharness.frontend.v1.ClientEvent.resume_session:type_name -> pluggableharness.frontend.v1.ClientEvent.ResumeSession - 29, // 25: pluggableharness.frontend.v1.ClientEvent.detach_session:type_name -> pluggableharness.frontend.v1.ClientEvent.DetachSession - 30, // 26: pluggableharness.frontend.v1.ClientEvent.list_sessions:type_name -> pluggableharness.frontend.v1.ClientEvent.ListSessions - 31, // 27: pluggableharness.frontend.v1.ServerEvent.Render.content:type_name -> pluggableharness.render.v1.PlacedContent - 32, // 28: pluggableharness.frontend.v1.ServerEvent.PermissionRequest.plan_item:type_name -> pluggableharness.plan.v1.PlanItem - 33, // 29: pluggableharness.frontend.v1.ServerEvent.PlanReady.plan:type_name -> pluggableharness.plan.v1.Plan - 34, // 30: pluggableharness.frontend.v1.ServerEvent.InteractiveRequest.prompt:type_name -> pluggableharness.render.v1.RenderTree - 35, // 31: pluggableharness.frontend.v1.ServerEvent.SessionTreeUpdate.status:type_name -> pluggableharness.session.v1.SessionStatus - 36, // 32: pluggableharness.frontend.v1.ServerEvent.Error.error:type_name -> pluggableharness.frontend.v1.FrontendError - 37, // 33: pluggableharness.frontend.v1.ServerEvent.SessionCreated.info:type_name -> pluggableharness.session.v1.SessionInfo - 37, // 34: pluggableharness.frontend.v1.ServerEvent.SessionAttached.info:type_name -> pluggableharness.session.v1.SessionInfo - 37, // 35: pluggableharness.frontend.v1.ServerEvent.SessionList.sessions:type_name -> pluggableharness.session.v1.SessionInfo - 38, // 36: pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry.direct_invoke_commands:type_name -> pluggableharness.slashcommand.v1.SlashCommandSpec - 39, // 37: pluggableharness.frontend.v1.ServerEvent.SlashCommandRegistry.prompt_expansion_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec - 40, // 38: pluggableharness.frontend.v1.ServerEvent.UsageUpdate.turn:type_name -> pluggableharness.model.v1.Usage - 35, // 39: pluggableharness.frontend.v1.ServerEvent.SessionStatusUpdate.status:type_name -> pluggableharness.session.v1.SessionStatus - 41, // 40: pluggableharness.frontend.v1.ClientEvent.UserMessage.content:type_name -> pluggableharness.content.v1.ContentBlock - 0, // 41: pluggableharness.frontend.v1.ClientEvent.PlanDecision.decision:type_name -> pluggableharness.frontend.v1.ClientDecision - 42, // 42: pluggableharness.frontend.v1.ClientEvent.PlanDecision.corrected_input:type_name -> google.protobuf.Struct - 1, // 43: pluggableharness.frontend.v1.ClientEvent.PlanDecision.scope:type_name -> pluggableharness.frontend.v1.PlanDecisionScope - 42, // 44: pluggableharness.frontend.v1.ClientEvent.InteractiveResponse.response:type_name -> google.protobuf.Struct - 42, // 45: pluggableharness.frontend.v1.ClientEvent.ActionTrigger.args:type_name -> google.protobuf.Struct - 35, // 46: pluggableharness.frontend.v1.ClientEvent.ListSessions.status:type_name -> pluggableharness.session.v1.SessionStatus - 47, // [47:47] is the sub-list for method output_type - 47, // [47:47] is the sub-list for method input_type - 47, // [47:47] is the sub-list for extension type_name - 47, // [47:47] is the sub-list for extension extendee - 0, // [0:47] is the sub-list for field type_name -} - -func init() { file_pluggableharness_frontend_v1_events_proto_init() } -func file_pluggableharness_frontend_v1_events_proto_init() { - if File_pluggableharness_frontend_v1_events_proto != nil { - return - } - file_pluggableharness_frontend_v1_errors_proto_init() - file_pluggableharness_frontend_v1_events_proto_msgTypes[0].OneofWrappers = []any{ - (*ServerEvent_StreamDelta_)(nil), - (*ServerEvent_Render_)(nil), - (*ServerEvent_PermissionRequest_)(nil), - (*ServerEvent_PlanReady_)(nil), - (*ServerEvent_InteractiveRequest_)(nil), - (*ServerEvent_SessionTreeUpdate_)(nil), - (*ServerEvent_Error_)(nil), - (*ServerEvent_SessionCreated_)(nil), - (*ServerEvent_SessionAttached_)(nil), - (*ServerEvent_BackfillComplete_)(nil), - (*ServerEvent_SessionDetached_)(nil), - (*ServerEvent_SessionList_)(nil), - (*ServerEvent_SlashCommandRegistry_)(nil), - (*ServerEvent_UsageUpdate_)(nil), - (*ServerEvent_SessionStatusUpdate_)(nil), - } - file_pluggableharness_frontend_v1_events_proto_msgTypes[1].OneofWrappers = []any{ - (*ClientEvent_UserMessage_)(nil), - (*ClientEvent_SlashCommand_)(nil), - (*ClientEvent_PlanDecision_)(nil), - (*ClientEvent_InteractiveResponse_)(nil), - (*ClientEvent_ActionTrigger_)(nil), - (*ClientEvent_Interrupt_)(nil), - (*ClientEvent_Hello_)(nil), - (*ClientEvent_CreateSession_)(nil), - (*ClientEvent_AttachSession_)(nil), - (*ClientEvent_ResumeSession_)(nil), - (*ClientEvent_DetachSession_)(nil), - (*ClientEvent_ListSessions_)(nil), - } - file_pluggableharness_frontend_v1_events_proto_msgTypes[19].OneofWrappers = []any{} - file_pluggableharness_frontend_v1_events_proto_msgTypes[24].OneofWrappers = []any{} - file_pluggableharness_frontend_v1_events_proto_msgTypes[28].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_frontend_v1_events_proto_rawDesc), len(file_pluggableharness_frontend_v1_events_proto_rawDesc)), - NumEnums: 2, - NumMessages: 29, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_pluggableharness_frontend_v1_events_proto_goTypes, - DependencyIndexes: file_pluggableharness_frontend_v1_events_proto_depIdxs, - EnumInfos: file_pluggableharness_frontend_v1_events_proto_enumTypes, - MessageInfos: file_pluggableharness_frontend_v1_events_proto_msgTypes, - }.Build() - File_pluggableharness_frontend_v1_events_proto = out.File - file_pluggableharness_frontend_v1_events_proto_goTypes = nil - file_pluggableharness_frontend_v1_events_proto_depIdxs = nil -} diff --git a/pkg/frontend/proto/v1/service.pb.go b/pkg/frontend/proto/v1/service.pb.go index 48efe48..d53f87f 100644 --- a/pkg/frontend/proto/v1/service.pb.go +++ b/pkg/frontend/proto/v1/service.pb.go @@ -4,9 +4,13 @@ // protoc (unknown) // source: pluggableharness/frontend/v1/service.proto -// Package pluggableharness.frontend.v1 defines the frontend provider plugin protocol -// described in specifications/frontend.md §3 (Attach, ServerEvent, -// ClientEvent, ...). +// Package pluggableharness.frontend.v1 defines the frontend provider plugin +// protocol described in specifications/frontend/. A frontend owns how the +// operator sees and types — TUI, web, CLI, voice — but does not own the +// agent loop. Kernel-to-frontend traffic (state, metadata, transcript, +// token deltas) rides the kernel callback channel +// (specifications/kernel-callbacks.md); this service is only the +// standard category triple every plugin exposes. package frontendv1 @@ -28,34 +32,29 @@ var File_pluggableharness_frontend_v1_service_proto protoreflect.FileDescriptor const file_pluggableharness_frontend_v1_service_proto_rawDesc = "" + "\n" + - "*pluggableharness/frontend/v1/service.proto\x12\x1cpluggableharness.frontend.v1\x1a)pluggableharness/frontend/v1/events.proto\x1a.pluggableharness/frontend/v1/rpc_request.proto\x1a/pluggableharness/frontend/v1/rpc_response.proto2\xce\x03\n" + + "*pluggableharness/frontend/v1/service.proto\x12\x1cpluggableharness.frontend.v1\x1a.pluggableharness/frontend/v1/rpc_request.proto\x1a/pluggableharness/frontend/v1/rpc_response.proto2\xea\x02\n" + "\x0fFrontendService\x12~\n" + "\x0fGetCapabilities\x124.pluggableharness.frontend.v1.GetCapabilitiesRequest\x1a5.pluggableharness.frontend.v1.GetCapabilitiesResponse\x12l\n" + - "\tConfigure\x12..pluggableharness.frontend.v1.ConfigureRequest\x1a/.pluggableharness.frontend.v1.ConfigureResponse\x12b\n" + - "\x06Attach\x12).pluggableharness.frontend.v1.ClientEvent\x1a).pluggableharness.frontend.v1.ServerEvent(\x010\x01\x12i\n" + + "\tConfigure\x12..pluggableharness.frontend.v1.ConfigureRequest\x1a/.pluggableharness.frontend.v1.ConfigureResponse\x12i\n" + "\bDescribe\x12-.pluggableharness.frontend.v1.DescribeRequest\x1a..pluggableharness.frontend.v1.DescribeResponseBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" var file_pluggableharness_frontend_v1_service_proto_goTypes = []any{ (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.frontend.v1.GetCapabilitiesRequest (*ConfigureRequest)(nil), // 1: pluggableharness.frontend.v1.ConfigureRequest - (*ClientEvent)(nil), // 2: pluggableharness.frontend.v1.ClientEvent - (*DescribeRequest)(nil), // 3: pluggableharness.frontend.v1.DescribeRequest - (*GetCapabilitiesResponse)(nil), // 4: pluggableharness.frontend.v1.GetCapabilitiesResponse - (*ConfigureResponse)(nil), // 5: pluggableharness.frontend.v1.ConfigureResponse - (*ServerEvent)(nil), // 6: pluggableharness.frontend.v1.ServerEvent - (*DescribeResponse)(nil), // 7: pluggableharness.frontend.v1.DescribeResponse + (*DescribeRequest)(nil), // 2: pluggableharness.frontend.v1.DescribeRequest + (*GetCapabilitiesResponse)(nil), // 3: pluggableharness.frontend.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 4: pluggableharness.frontend.v1.ConfigureResponse + (*DescribeResponse)(nil), // 5: pluggableharness.frontend.v1.DescribeResponse } var file_pluggableharness_frontend_v1_service_proto_depIdxs = []int32{ 0, // 0: pluggableharness.frontend.v1.FrontendService.GetCapabilities:input_type -> pluggableharness.frontend.v1.GetCapabilitiesRequest 1, // 1: pluggableharness.frontend.v1.FrontendService.Configure:input_type -> pluggableharness.frontend.v1.ConfigureRequest - 2, // 2: pluggableharness.frontend.v1.FrontendService.Attach:input_type -> pluggableharness.frontend.v1.ClientEvent - 3, // 3: pluggableharness.frontend.v1.FrontendService.Describe:input_type -> pluggableharness.frontend.v1.DescribeRequest - 4, // 4: pluggableharness.frontend.v1.FrontendService.GetCapabilities:output_type -> pluggableharness.frontend.v1.GetCapabilitiesResponse - 5, // 5: pluggableharness.frontend.v1.FrontendService.Configure:output_type -> pluggableharness.frontend.v1.ConfigureResponse - 6, // 6: pluggableharness.frontend.v1.FrontendService.Attach:output_type -> pluggableharness.frontend.v1.ServerEvent - 7, // 7: pluggableharness.frontend.v1.FrontendService.Describe:output_type -> pluggableharness.frontend.v1.DescribeResponse - 4, // [4:8] is the sub-list for method output_type - 0, // [0:4] is the sub-list for method input_type + 2, // 2: pluggableharness.frontend.v1.FrontendService.Describe:input_type -> pluggableharness.frontend.v1.DescribeRequest + 3, // 3: pluggableharness.frontend.v1.FrontendService.GetCapabilities:output_type -> pluggableharness.frontend.v1.GetCapabilitiesResponse + 4, // 4: pluggableharness.frontend.v1.FrontendService.Configure:output_type -> pluggableharness.frontend.v1.ConfigureResponse + 5, // 5: pluggableharness.frontend.v1.FrontendService.Describe:output_type -> pluggableharness.frontend.v1.DescribeResponse + 3, // [3:6] is the sub-list for method output_type + 0, // [0:3] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -66,7 +65,6 @@ func file_pluggableharness_frontend_v1_service_proto_init() { if File_pluggableharness_frontend_v1_service_proto != nil { return } - file_pluggableharness_frontend_v1_events_proto_init() file_pluggableharness_frontend_v1_rpc_request_proto_init() file_pluggableharness_frontend_v1_rpc_response_proto_init() type x struct{} diff --git a/pkg/frontend/proto/v1/service_grpc.pb.go b/pkg/frontend/proto/v1/service_grpc.pb.go index 94679cc..393a75a 100644 --- a/pkg/frontend/proto/v1/service_grpc.pb.go +++ b/pkg/frontend/proto/v1/service_grpc.pb.go @@ -4,9 +4,13 @@ // - protoc (unknown) // source: pluggableharness/frontend/v1/service.proto -// Package pluggableharness.frontend.v1 defines the frontend provider plugin protocol -// described in specifications/frontend.md §3 (Attach, ServerEvent, -// ClientEvent, ...). +// Package pluggableharness.frontend.v1 defines the frontend provider plugin +// protocol described in specifications/frontend/. A frontend owns how the +// operator sees and types — TUI, web, CLI, voice — but does not own the +// agent loop. Kernel-to-frontend traffic (state, metadata, transcript, +// token deltas) rides the kernel callback channel +// (specifications/kernel-callbacks.md); this service is only the +// standard category triple every plugin exposes. package frontendv1 @@ -25,7 +29,6 @@ const _ = grpc.SupportPackageIsVersion9 const ( FrontendService_GetCapabilities_FullMethodName = "/pluggableharness.frontend.v1.FrontendService/GetCapabilities" FrontendService_Configure_FullMethodName = "/pluggableharness.frontend.v1.FrontendService/Configure" - FrontendService_Attach_FullMethodName = "/pluggableharness.frontend.v1.FrontendService/Attach" FrontendService_Describe_FullMethodName = "/pluggableharness.frontend.v1.FrontendService/Describe" ) @@ -33,57 +36,22 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // -// FrontendService implements the frontend provider protocol described in -// specifications/frontend.md §3. +// FrontendService implements the frontend provider protocol. There is no +// Attach RPC: under go-plugin the plugin is the gRPC server, so the only +// direction that lets the kernel push streams into a frontend is the +// callback channel where the plugin is the client. Session lifecycle, +// operator input, plan/interactive resolution, metadata, and token +// deltas are all KernelCallbackService RPCs. type FrontendServiceClient interface { - // GetCapabilities returns this frontend's slash commands and config - // schema. Unary. frontend.md §3.1. + // GetCapabilities returns this frontend's slash commands, config + // schema, and supported hook points. Unary. GetCapabilities(ctx context.Context, in *GetCapabilitiesRequest, opts ...grpc.CallOption) (*GetCapabilitiesResponse, error) - // Configure applies this provider's `agent.hcl` configuration, validated - // against the schema returned by GetCapabilities (configuration.md §4). - // Unary. frontend.md §3.1. + // Configure applies this provider's agent.hcl configuration, validated + // against the schema returned by GetCapabilities. Configure(ctx context.Context, in *ConfigureRequest, opts ...grpc.CallOption) (*ConfigureResponse, error) - // Attach opens ONE multiplexed, connection-scoped bidirectional event - // channel between the kernel and this frontend connection — not a - // per-session stream. A frontend subscribes individual sessions onto - // this one stream via the session-control ClientEvent variants - // (create_session/attach_session/resume_session/detach_session), and - // unsubscribes the same way; connection-level operations - // (list_sessions, the aggregate slash-command registry) have a natural - // home here precisely because the stream isn't tied to one session. - // ServerEvents flow from kernel to frontend, ClientEvents flow from - // frontend to kernel, both directions live for the duration of the - // stream. Bidirectional streaming — frontend.md §"Transport", and - // (along with the kernel callback channel) one of only two genuinely - // bidirectional RPCs in this protocol series (see .claude/rules/grpc.md). - // - // Multiple frontends MAY subscribe to the same session concurrently on - // their own Attach streams (frontend.md §"Session scope"): every - // ServerEvent for a given session broadcasts identically to every - // frontend subscribed to that session, with no partitioning and no - // "primary" frontend. ClientEvents are processed in kernel arrival - // order; for ClientEvent.plan_decision and - // ClientEvent.interactive_response specifically, which name a pending - // item by id within a session, the kernel applies first-response-wins - // arbitration per session and MUST reject any later response for an - // already-resolved item with a distinct error back to its sender. - // - // buf:lint:ignore RPC_REQUEST_STANDARD_NAME - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element types are the bare "ClientEvent"/"ServerEvent" per - // frontend.md §3.2's literal spec — names used throughout this project's - // specs and rules, not just here. Neither is reused by another RPC (no - // uniqueness violation); renaming to Attach*Request/Response would only - // satisfy a style convention while discarding real spec traceability. - Attach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ClientEvent, ServerEvent], error) // Describe reports this plugin build's own identity — {name, version, // source, category, protocol_version} — directly from the running // process, rather than the kernel inferring it from a lock-file row. - // Every one of the seven category protocols gains this identical RPC in - // this protocol revision; it exists specifically for a - // `dev_overrides`-resolved binary (configuration/lock-file.md's - // "dev_overrides and identity without a lock entry"), which has no - // provider {} lock-file entry to read identity from at all. Describe(ctx context.Context, in *DescribeRequest, opts ...grpc.CallOption) (*DescribeResponse, error) } @@ -115,19 +83,6 @@ func (c *frontendServiceClient) Configure(ctx context.Context, in *ConfigureRequ return out, nil } -func (c *frontendServiceClient) Attach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ClientEvent, ServerEvent], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &FrontendService_ServiceDesc.Streams[0], FrontendService_Attach_FullMethodName, cOpts...) - if err != nil { - return nil, err - } - x := &grpc.GenericClientStream[ClientEvent, ServerEvent]{ClientStream: stream} - return x, nil -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type FrontendService_AttachClient = grpc.BidiStreamingClient[ClientEvent, ServerEvent] - func (c *frontendServiceClient) Describe(ctx context.Context, in *DescribeRequest, opts ...grpc.CallOption) (*DescribeResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DescribeResponse) @@ -142,57 +97,22 @@ func (c *frontendServiceClient) Describe(ctx context.Context, in *DescribeReques // All implementations must embed UnimplementedFrontendServiceServer // for forward compatibility. // -// FrontendService implements the frontend provider protocol described in -// specifications/frontend.md §3. +// FrontendService implements the frontend provider protocol. There is no +// Attach RPC: under go-plugin the plugin is the gRPC server, so the only +// direction that lets the kernel push streams into a frontend is the +// callback channel where the plugin is the client. Session lifecycle, +// operator input, plan/interactive resolution, metadata, and token +// deltas are all KernelCallbackService RPCs. type FrontendServiceServer interface { - // GetCapabilities returns this frontend's slash commands and config - // schema. Unary. frontend.md §3.1. + // GetCapabilities returns this frontend's slash commands, config + // schema, and supported hook points. Unary. GetCapabilities(context.Context, *GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) - // Configure applies this provider's `agent.hcl` configuration, validated - // against the schema returned by GetCapabilities (configuration.md §4). - // Unary. frontend.md §3.1. + // Configure applies this provider's agent.hcl configuration, validated + // against the schema returned by GetCapabilities. Configure(context.Context, *ConfigureRequest) (*ConfigureResponse, error) - // Attach opens ONE multiplexed, connection-scoped bidirectional event - // channel between the kernel and this frontend connection — not a - // per-session stream. A frontend subscribes individual sessions onto - // this one stream via the session-control ClientEvent variants - // (create_session/attach_session/resume_session/detach_session), and - // unsubscribes the same way; connection-level operations - // (list_sessions, the aggregate slash-command registry) have a natural - // home here precisely because the stream isn't tied to one session. - // ServerEvents flow from kernel to frontend, ClientEvents flow from - // frontend to kernel, both directions live for the duration of the - // stream. Bidirectional streaming — frontend.md §"Transport", and - // (along with the kernel callback channel) one of only two genuinely - // bidirectional RPCs in this protocol series (see .claude/rules/grpc.md). - // - // Multiple frontends MAY subscribe to the same session concurrently on - // their own Attach streams (frontend.md §"Session scope"): every - // ServerEvent for a given session broadcasts identically to every - // frontend subscribed to that session, with no partitioning and no - // "primary" frontend. ClientEvents are processed in kernel arrival - // order; for ClientEvent.plan_decision and - // ClientEvent.interactive_response specifically, which name a pending - // item by id within a session, the kernel applies first-response-wins - // arbitration per session and MUST reject any later response for an - // already-resolved item with a distinct error back to its sender. - // - // buf:lint:ignore RPC_REQUEST_STANDARD_NAME - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element types are the bare "ClientEvent"/"ServerEvent" per - // frontend.md §3.2's literal spec — names used throughout this project's - // specs and rules, not just here. Neither is reused by another RPC (no - // uniqueness violation); renaming to Attach*Request/Response would only - // satisfy a style convention while discarding real spec traceability. - Attach(grpc.BidiStreamingServer[ClientEvent, ServerEvent]) error // Describe reports this plugin build's own identity — {name, version, // source, category, protocol_version} — directly from the running // process, rather than the kernel inferring it from a lock-file row. - // Every one of the seven category protocols gains this identical RPC in - // this protocol revision; it exists specifically for a - // `dev_overrides`-resolved binary (configuration/lock-file.md's - // "dev_overrides and identity without a lock entry"), which has no - // provider {} lock-file entry to read identity from at all. Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) mustEmbedUnimplementedFrontendServiceServer() } @@ -210,9 +130,6 @@ func (UnimplementedFrontendServiceServer) GetCapabilities(context.Context, *GetC func (UnimplementedFrontendServiceServer) Configure(context.Context, *ConfigureRequest) (*ConfigureResponse, error) { return nil, status.Error(codes.Unimplemented, "method Configure not implemented") } -func (UnimplementedFrontendServiceServer) Attach(grpc.BidiStreamingServer[ClientEvent, ServerEvent]) error { - return status.Error(codes.Unimplemented, "method Attach not implemented") -} func (UnimplementedFrontendServiceServer) Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) { return nil, status.Error(codes.Unimplemented, "method Describe not implemented") } @@ -273,13 +190,6 @@ func _FrontendService_Configure_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } -func _FrontendService_Attach_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(FrontendServiceServer).Attach(&grpc.GenericServerStream[ClientEvent, ServerEvent]{ServerStream: stream}) -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type FrontendService_AttachServer = grpc.BidiStreamingServer[ClientEvent, ServerEvent] - func _FrontendService_Describe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DescribeRequest) if err := dec(in); err != nil { @@ -318,13 +228,6 @@ var FrontendService_ServiceDesc = grpc.ServiceDesc{ Handler: _FrontendService_Describe_Handler, }, }, - Streams: []grpc.StreamDesc{ - { - StreamName: "Attach", - Handler: _FrontendService_Attach_Handler, - ServerStreams: true, - ClientStreams: true, - }, - }, + Streams: []grpc.StreamDesc{}, Metadata: "pluggableharness/frontend/v1/service.proto", } diff --git a/pkg/frontend/proto/v1/types.pb.go b/pkg/frontend/proto/v1/types.pb.go index 74d8c89..6d577da 100644 --- a/pkg/frontend/proto/v1/types.pb.go +++ b/pkg/frontend/proto/v1/types.pb.go @@ -9,7 +9,6 @@ package frontendv1 import ( v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" - v12 "github.com/pluggableharness/agent/pkg/render/proto/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -25,25 +24,20 @@ const ( ) // FrontendCapabilities is this frontend's static self-description, returned -// by GetCapabilities (frontend.md §3.1). +// by GetCapabilities. type FrontendCapabilities struct { state protoimpl.MessageState `protogen:"open.v1"` // Prompt-expansion slash commands this frontend contributes. MAY be // empty. A direct-invoke command is declared by a slashcommand.v1 // provider instead (specifications/slashcommand/), never here. SlashCommands []*v1.PromptExpansionSpec `protobuf:"bytes,1,rep,name=slash_commands,json=slashCommands,proto3" json:"slash_commands,omitempty"` - // This provider's `agent.hcl` configuration schema (configuration.md §4). + // This provider's agent.hcl configuration schema + // (configuration/blocks-reference.md). ConfigSchema *v11.ConfigSchema `protobuf:"bytes,2,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` - // Regions this frontend proactively declares it can render into. A - // complement to, not a replacement for, the reactive - // FRONTEND_ERROR_CATEGORY_REGION_UNSUPPORTED error a placement the - // frontend can't honor still produces — this lets a producer route - // content preferentially without waiting to find out the hard way. - SupportedRegions []v12.Region `protobuf:"varint,3,rep,packed,name=supported_regions,json=supportedRegions,proto3,enum=pluggableharness.render.v1.Region" json:"supported_regions,omitempty"` - // Hook points this frontend can subscribe to (agent-loop/hook-dispatch.md), - // so a mis-declared agent.hcl hook{} block naming an unsupported point - // can be rejected at config-load time rather than failing at first - // dispatch. + // Hook points this frontend can subscribe to + // (agent-loop/hook-dispatch.md), so a mis-declared agent.hcl hook{} + // block naming an unsupported point can be rejected at config-load + // time rather than failing at first dispatch. SupportedHookPoints []v1.HookPoint `protobuf:"varint,4,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -93,13 +87,6 @@ func (x *FrontendCapabilities) GetConfigSchema() *v11.ConfigSchema { return nil } -func (x *FrontendCapabilities) GetSupportedRegions() []v12.Region { - if x != nil { - return x.SupportedRegions - } - return nil -} - func (x *FrontendCapabilities) GetSupportedHookPoints() []v1.HookPoint { if x != nil { return x.SupportedHookPoints @@ -111,12 +98,11 @@ var File_pluggableharness_frontend_v1_types_proto protoreflect.FileDescriptor const file_pluggableharness_frontend_v1_types_proto_rawDesc = "" + "\n" + - "(pluggableharness/frontend/v1/types.proto\x12\x1cpluggableharness.frontend.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\"\xe9\x02\n" + + "(pluggableharness/frontend/v1/types.proto\x12\x1cpluggableharness.frontend.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\"\xb1\x02\n" + "\x14FrontendCapabilities\x12V\n" + "\x0eslash_commands\x18\x01 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\rslashCommands\x12M\n" + - "\rconfig_schema\x18\x02 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12O\n" + - "\x11supported_regions\x18\x03 \x03(\x0e2\".pluggableharness.render.v1.RegionR\x10supportedRegions\x12Y\n" + - "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPointsBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" + "\rconfig_schema\x18\x02 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + + "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPointsJ\x04\b\x03\x10\x04R\x11supported_regionsBDZBgithub.com/pluggableharness/agent/pkg/frontend/proto/v1;frontendv1b\x06proto3" var ( file_pluggableharness_frontend_v1_types_proto_rawDescOnce sync.Once @@ -135,19 +121,17 @@ var file_pluggableharness_frontend_v1_types_proto_goTypes = []any{ (*FrontendCapabilities)(nil), // 0: pluggableharness.frontend.v1.FrontendCapabilities (*v1.PromptExpansionSpec)(nil), // 1: pluggableharness.common.v1.PromptExpansionSpec (*v11.ConfigSchema)(nil), // 2: pluggableharness.config.v1.ConfigSchema - (v12.Region)(0), // 3: pluggableharness.render.v1.Region - (v1.HookPoint)(0), // 4: pluggableharness.common.v1.HookPoint + (v1.HookPoint)(0), // 3: pluggableharness.common.v1.HookPoint } var file_pluggableharness_frontend_v1_types_proto_depIdxs = []int32{ 1, // 0: pluggableharness.frontend.v1.FrontendCapabilities.slash_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec 2, // 1: pluggableharness.frontend.v1.FrontendCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 3, // 2: pluggableharness.frontend.v1.FrontendCapabilities.supported_regions:type_name -> pluggableharness.render.v1.Region - 4, // 3: pluggableharness.frontend.v1.FrontendCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 4, // [4:4] is the sub-list for method output_type - 4, // [4:4] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 3, // 2: pluggableharness.frontend.v1.FrontendCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_pluggableharness_frontend_v1_types_proto_init() } diff --git a/pkg/frontend/server.go b/pkg/frontend/server.go index 7291366..74ad1d9 100644 --- a/pkg/frontend/server.go +++ b/pkg/frontend/server.go @@ -28,17 +28,20 @@ var ( ) // NewService returns a Service wrapping p. identity is this plugin build's -// own self-reported identity — Describe reports it directly, per -// frontend-protocol.md's "Transport" section, rather than the kernel -// inferring it from a lock-file row. callback is this plugin process's -// lazily-dialed handle to the kernel callback channel -// (github.com/pluggableharness/agent/pkg/plugin's Callback); p's own -// methods may dial it via callback.Client to make kernel-callback calls -// (Log, Emit, GetConfig, ...) as part of handling a request. +// own self-reported identity — Describe reports it directly rather than +// the kernel inferring it from a lock-file row. callback is this plugin +// process's lazily-dialed handle to the kernel callback channel; p uses +// it for GetSessionState, SubmitInput, Subscribe, StreamDeltas, and the +// rest of the frontend state-surface RPCs. func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) *Service { return &Service{provider: p, identity: identity, callback: callback} } +// Callback returns the kernel callback handle passed to NewService. +func (svc *Service) Callback() *plugin.Callback { + return svc.callback +} + // Register registers the FrontendService on s, satisfying // github.com/pluggableharness/agent/pkg/plugin's Service interface. func (svc *Service) Register(s *grpc.Server) { @@ -46,7 +49,7 @@ func (svc *Service) Register(s *grpc.Server) { } // GetCapabilities returns this frontend's slash commands, config schema, -// supported regions, and supported hook points. Unary. +// and supported hook points. Unary. func (svc *Service) GetCapabilities(ctx context.Context, _ *frontendv1.GetCapabilitiesRequest) (*frontendv1.GetCapabilitiesResponse, error) { caps, err := svc.provider.Capabilities(ctx) if err != nil { @@ -57,8 +60,7 @@ func (svc *Service) GetCapabilities(ctx context.Context, _ *frontendv1.GetCapabi // Configure applies this provider's agent.hcl configuration. Unary. A // returned error surfaces as a gRPC status carrying a Error in its -// structured detail, never as an in-band field on ConfigureResponse -// (doc.go's "Error handling is two distinct paths, not one"). +// structured detail, never as an in-band field on ConfigureResponse. func (svc *Service) Configure(ctx context.Context, req *frontendv1.ConfigureRequest) (*frontendv1.ConfigureResponse, error) { if err := svc.provider.Configure(ctx, req.GetConfig()); err != nil { return nil, statusErr(err) diff --git a/pkg/frontend/server_test.go b/pkg/frontend/server_test.go index a304851..2bfd853 100644 --- a/pkg/frontend/server_test.go +++ b/pkg/frontend/server_test.go @@ -84,7 +84,7 @@ func TestService_Configure_InvalidArgument(t *testing.T) { provider := &fakeProvider{ configureFunc: func(context.Context, *structpb.Struct) error { return &frontend.Error{ - Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_CLIENT_EVENT, + Category: frontendv1.FrontendErrorCategory_FRONTEND_ERROR_CATEGORY_INVALID_REQUEST, Message: "malformed theme", } }, diff --git a/pkg/kernel/doc.go b/pkg/kernel/doc.go index 115eadf..28d0c39 100644 --- a/pkg/kernel/doc.go +++ b/pkg/kernel/doc.go @@ -31,6 +31,11 @@ // Publish is a thin one-line call; Subscribe owns the stream-receive // goroutine and delivers events to a caller-supplied handler, so a // plugin author writes a handler, not stream plumbing. +// - Frontend state-surface helpers (frontend.go): GetSessionState, +// SubmitInput (returns turn_id), session lifecycle, plan/interactive +// resolution, metadata publish/list/retract, StreamDeltas, and the +// slash/action unaries — see specifications/frontend/ and +// specifications/kernel-callbacks.md. // // This package deliberately does not import anything under internal/ — // pkg/ is the plugin-author-consumable surface and internal/ is diff --git a/pkg/kernel/frontend.go b/pkg/kernel/frontend.go new file mode 100644 index 0000000..cfce108 --- /dev/null +++ b/pkg/kernel/frontend.go @@ -0,0 +1,209 @@ +package kernel + +import ( + "context" + "errors" + "fmt" + "io" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + + contentv1 "github.com/pluggableharness/agent/pkg/content/proto/v1" + kernelv1 "github.com/pluggableharness/agent/pkg/kernel/proto/v1" + metadatav1 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" + planv1 "github.com/pluggableharness/agent/pkg/plan/proto/v1" + sessionv1 "github.com/pluggableharness/agent/pkg/session/proto/v1" +) + +// GetSessionState returns the fixed-schema "where am I" snapshot for +// sessionID. Pair with Subscribe on topic kernel.state for live updates. +func (c *Client) GetSessionState(ctx context.Context, sessionID string) (*sessionv1.SessionState, error) { + result, err := c.raw.GetSessionState(ctx, &kernelv1.GetSessionStateRequest{SessionId: sessionID}) + if err != nil { + return nil, fmt.Errorf("kernel: get session state: %w", err) + } + return result.GetState(), nil +} + +// SubmitInput submits operator content as the next turn and returns the +// assigned turn_id for correlation. +func (c *Client) SubmitInput(ctx context.Context, sessionID string, content []*contentv1.ContentBlock) (turnID string, err error) { + result, err := c.raw.SubmitInput(ctx, &kernelv1.SubmitInputRequest{ + SessionId: sessionID, + Content: content, + }) + if err != nil { + return "", fmt.Errorf("kernel: submit input: %w", err) + } + return result.GetTurnId(), nil +} + +// ResolvePlanDecision answers a pending plan item that policy evaluated +// as ASK. +func (c *Client) ResolvePlanDecision(ctx context.Context, req *kernelv1.ResolvePlanDecisionRequest) error { + if _, err := c.raw.ResolvePlanDecision(ctx, req); err != nil { + return fmt.Errorf("kernel: resolve plan decision: %w", err) + } + return nil +} + +// ResolvePlanDecisionArgs is a convenience wrapper around +// ResolvePlanDecision for the common allow/deny case. +func (c *Client) ResolvePlanDecisionArgs(ctx context.Context, sessionID, planItemID string, decision planv1.ClientDecision, scope planv1.PlanDecisionScope, corrected *structpb.Struct) error { + return c.ResolvePlanDecision(ctx, &kernelv1.ResolvePlanDecisionRequest{ + SessionId: sessionID, + PlanItemId: planItemID, + Decision: decision, + CorrectedInput: corrected, + Scope: scope, + }) +} + +// ResolveInteractive answers a pending interactive-kind tool call. +func (c *Client) ResolveInteractive(ctx context.Context, sessionID, callID string, response *structpb.Struct) error { + if _, err := c.raw.ResolveInteractive(ctx, &kernelv1.ResolveInteractiveRequest{ + SessionId: sessionID, + CallId: callID, + Response: response, + }); err != nil { + return fmt.Errorf("kernel: resolve interactive: %w", err) + } + return nil +} + +// Interrupt cancels the running turn for sessionID. +func (c *Client) Interrupt(ctx context.Context, sessionID string) error { + if _, err := c.raw.Interrupt(ctx, &kernelv1.InterruptRequest{SessionId: sessionID}); err != nil { + return fmt.Errorf("kernel: interrupt: %w", err) + } + return nil +} + +// CreateSession creates a new session and auto-attaches the caller. +func (c *Client) CreateSession(ctx context.Context, req *kernelv1.CreateSessionRequest) (*sessionv1.SessionInfo, error) { + result, err := c.raw.CreateSession(ctx, req) + if err != nil { + return nil, fmt.Errorf("kernel: create session: %w", err) + } + return result.GetInfo(), nil +} + +// AttachSession subscribes the caller to an existing session. +func (c *Client) AttachSession(ctx context.Context, sessionID string) (*sessionv1.SessionInfo, error) { + result, err := c.raw.AttachSession(ctx, &kernelv1.AttachSessionRequest{SessionId: sessionID}) + if err != nil { + return nil, fmt.Errorf("kernel: attach session: %w", err) + } + return result.GetInfo(), nil +} + +// ResumeSession attaches a historical session for continuation or +// replay-only. +func (c *Client) ResumeSession(ctx context.Context, sessionID string) (*sessionv1.SessionInfo, error) { + result, err := c.raw.ResumeSession(ctx, &kernelv1.ResumeSessionRequest{SessionId: sessionID}) + if err != nil { + return nil, fmt.Errorf("kernel: resume session: %w", err) + } + return result.GetInfo(), nil +} + +// DetachSession unsubscribes the caller from sessionID. +func (c *Client) DetachSession(ctx context.Context, sessionID string) error { + if _, err := c.raw.DetachSession(ctx, &kernelv1.DetachSessionRequest{SessionId: sessionID}); err != nil { + return fmt.Errorf("kernel: detach session: %w", err) + } + return nil +} + +// ListSessions returns a filtered session summary list. +func (c *Client) ListSessions(ctx context.Context, req *kernelv1.ListSessionsRequest) ([]*sessionv1.SessionInfo, error) { + result, err := c.raw.ListSessions(ctx, req) + if err != nil { + return nil, fmt.Errorf("kernel: list sessions: %w", err) + } + return result.GetSessions(), nil +} + +// PublishMetadata upserts a MetadataBlock. The kernel stamps producer and +// liveness=LIVE. +func (c *Client) PublishMetadata(ctx context.Context, sessionID string, block *metadatav1.MetadataBlock) (*metadatav1.MetadataBlock, error) { + result, err := c.raw.PublishMetadata(ctx, &kernelv1.PublishMetadataRequest{ + SessionId: sessionID, + Block: block, + }) + if err != nil { + return nil, fmt.Errorf("kernel: publish metadata: %w", err) + } + return result.GetBlock(), nil +} + +// RetractMetadata flips a block to DISCONNECTED and republishes it. +func (c *Client) RetractMetadata(ctx context.Context, sessionID, blockID string) (*metadatav1.MetadataBlock, error) { + result, err := c.raw.RetractMetadata(ctx, &kernelv1.RetractMetadataRequest{ + SessionId: sessionID, + BlockId: blockID, + }) + if err != nil { + return nil, fmt.Errorf("kernel: retract metadata: %w", err) + } + return result.GetBlock(), nil +} + +// ListMetadata returns every known MetadataBlock for sessionID. +func (c *Client) ListMetadata(ctx context.Context, sessionID string) ([]*metadatav1.MetadataBlock, error) { + result, err := c.raw.ListMetadata(ctx, &kernelv1.ListMetadataRequest{SessionId: sessionID}) + if err != nil { + return nil, fmt.Errorf("kernel: list metadata: %w", err) + } + return result.GetBlocks(), nil +} + +// InvokeSlashCommand dispatches a slash command against sessionID. +func (c *Client) InvokeSlashCommand(ctx context.Context, sessionID, name, args string) error { + if _, err := c.raw.InvokeSlashCommand(ctx, &kernelv1.InvokeSlashCommandRequest{ + SessionId: sessionID, + Name: name, + Args: args, + }); err != nil { + return fmt.Errorf("kernel: invoke slash command: %w", err) + } + return nil +} + +// TriggerAction dispatches an ActionNode activation. +func (c *Client) TriggerAction(ctx context.Context, req *kernelv1.TriggerActionRequest) error { + if _, err := c.raw.TriggerAction(ctx, req); err != nil { + return fmt.Errorf("kernel: trigger action: %w", err) + } + return nil +} + +// DeltaHandler is called once per TokenDelta on a StreamDeltas stream. +type DeltaHandler func(delta *kernelv1.TokenDelta) error + +// StreamDeltas opens the live-only token fast path for sessionID and +// delivers each delta to handler until the stream ends or ctx is canceled. +// The kernel does not batch; callers coalesce to their own refresh rate. +func (c *Client) StreamDeltas(ctx context.Context, sessionID string, handler DeltaHandler) error { + stream, err := c.raw.StreamDeltas(ctx, &kernelv1.StreamDeltasRequest{SessionId: sessionID}) + if err != nil { + return fmt.Errorf("kernel: stream deltas: %w", err) + } + for { + delta, err := stream.Recv() + if err != nil { + // End-of-stream and cancellation are normal control flow + // (grpc.md); anything else is a real failure and MUST NOT be + // swallowed just because ctx happens to be done by now. + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || status.Code(err) == codes.Canceled { + return nil + } + return fmt.Errorf("kernel: stream deltas: recv: %w", err) + } + if err := handler(delta); err != nil { + return err + } + } +} diff --git a/pkg/kernel/proto/v1/events.pb.go b/pkg/kernel/proto/v1/events.pb.go index 39e112a..cecabb1 100644 --- a/pkg/kernel/proto/v1/events.pb.go +++ b/pkg/kernel/proto/v1/events.pb.go @@ -222,6 +222,76 @@ func (x *StoredEvent) GetPayload() []byte { return nil } +// TokenDelta is one live incremental text fragment for the fast path, +// out-of-band with respect to the event bus (no topic matching, no +// filter evaluation, no shared subscriber queue). Per-stream FIFO only; +// not durable and never replayed — finished text arrives as RenderTrees +// via ReadEvents. See kernel-callbacks.md's StreamDeltas. +type TokenDelta struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session this delta belongs to. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Identifies which displayed element this delta appends to (e.g. a + // RenderNode id from a prior render), for correlating consecutive + // deltas into one growing piece of text. + TargetId string `protobuf:"bytes,2,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + // The incremental text to append. + Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TokenDelta) Reset() { + *x = TokenDelta{} + mi := &file_pluggableharness_kernel_v1_events_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TokenDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TokenDelta) ProtoMessage() {} + +func (x *TokenDelta) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_events_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TokenDelta.ProtoReflect.Descriptor instead. +func (*TokenDelta) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_events_proto_rawDescGZIP(), []int{2} +} + +func (x *TokenDelta) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *TokenDelta) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *TokenDelta) GetText() string { + if x != nil { + return x.Text + } + return "" +} + var File_pluggableharness_kernel_v1_events_proto protoreflect.FileDescriptor const file_pluggableharness_kernel_v1_events_proto_rawDesc = "" + @@ -240,7 +310,13 @@ const file_pluggableharness_kernel_v1_events_proto_rawDesc = "" + "\x04kind\x18\x04 \x01(\x0e2%.pluggableharness.kernel.v1.EventKindR\x04kind\x12C\n" + "\bproducer\x18\x05 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer\x12%\n" + "\x0eschema_version\x18\x06 \x01(\tR\rschemaVersion\x12\x18\n" + - "\apayload\x18\a \x01(\fR\apayloadB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + "\apayload\x18\a \x01(\fR\apayload\"\\\n" + + "\n" + + "TokenDelta\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x1b\n" + + "\ttarget_id\x18\x02 \x01(\tR\btargetId\x12\x12\n" + + "\x04text\x18\x03 \x01(\tR\x04textB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" var ( file_pluggableharness_kernel_v1_events_proto_rawDescOnce sync.Once @@ -254,19 +330,20 @@ func file_pluggableharness_kernel_v1_events_proto_rawDescGZIP() []byte { return file_pluggableharness_kernel_v1_events_proto_rawDescData } -var file_pluggableharness_kernel_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pluggableharness_kernel_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_pluggableharness_kernel_v1_events_proto_goTypes = []any{ (*BusEvent)(nil), // 0: pluggableharness.kernel.v1.BusEvent (*StoredEvent)(nil), // 1: pluggableharness.kernel.v1.StoredEvent - (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp - (EventKind)(0), // 3: pluggableharness.kernel.v1.EventKind - (*v1.ProducerRef)(nil), // 4: pluggableharness.common.v1.ProducerRef + (*TokenDelta)(nil), // 2: pluggableharness.kernel.v1.TokenDelta + (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp + (EventKind)(0), // 4: pluggableharness.kernel.v1.EventKind + (*v1.ProducerRef)(nil), // 5: pluggableharness.common.v1.ProducerRef } var file_pluggableharness_kernel_v1_events_proto_depIdxs = []int32{ - 2, // 0: pluggableharness.kernel.v1.BusEvent.time:type_name -> google.protobuf.Timestamp - 2, // 1: pluggableharness.kernel.v1.StoredEvent.time:type_name -> google.protobuf.Timestamp - 3, // 2: pluggableharness.kernel.v1.StoredEvent.kind:type_name -> pluggableharness.kernel.v1.EventKind - 4, // 3: pluggableharness.kernel.v1.StoredEvent.producer:type_name -> pluggableharness.common.v1.ProducerRef + 3, // 0: pluggableharness.kernel.v1.BusEvent.time:type_name -> google.protobuf.Timestamp + 3, // 1: pluggableharness.kernel.v1.StoredEvent.time:type_name -> google.protobuf.Timestamp + 4, // 2: pluggableharness.kernel.v1.StoredEvent.kind:type_name -> pluggableharness.kernel.v1.EventKind + 5, // 3: pluggableharness.kernel.v1.StoredEvent.producer:type_name -> pluggableharness.common.v1.ProducerRef 4, // [4:4] is the sub-list for method output_type 4, // [4:4] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name @@ -286,7 +363,7 @@ func file_pluggableharness_kernel_v1_events_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_events_proto_rawDesc), len(file_pluggableharness_kernel_v1_events_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/kernel/proto/v1/rpc_request.pb.go b/pkg/kernel/proto/v1/rpc_request.pb.go index 5ddea56..685740a 100644 --- a/pkg/kernel/proto/v1/rpc_request.pb.go +++ b/pkg/kernel/proto/v1/rpc_request.pb.go @@ -10,11 +10,15 @@ import ( v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" v11 "github.com/pluggableharness/agent/pkg/content/proto/v1" v13 "github.com/pluggableharness/agent/pkg/log/proto/v1" + v18 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" v15 "github.com/pluggableharness/agent/pkg/metric/proto/v1" v12 "github.com/pluggableharness/agent/pkg/model/proto/v1" + v16 "github.com/pluggableharness/agent/pkg/plan/proto/v1" + v17 "github.com/pluggableharness/agent/pkg/session/proto/v1" v14 "github.com/pluggableharness/agent/pkg/trace/proto/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -799,11 +803,951 @@ func (x *GetSessionRequest) GetSessionId() string { return "" } +// GetSessionStateRequest asks for the fixed-schema "where am I" snapshot +// a frontend renders. See kernel-callbacks.md's GetSessionState. +type GetSessionStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session to snapshot. MUST be set. Same one-session-only rule as + // EmitRequest.session_id for non-frontend callers; a frontend that has + // attached the session holds a grant for it. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionStateRequest) Reset() { + *x = GetSessionStateRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionStateRequest) ProtoMessage() {} + +func (x *GetSessionStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionStateRequest.ProtoReflect.Descriptor instead. +func (*GetSessionStateRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{12} +} + +func (x *GetSessionStateRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// SubmitInputRequest submits operator input as the next turn for a +// session. See kernel-callbacks.md's SubmitInput. +type SubmitInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session to submit into. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The message content, in emission order. MUST contain at least one + // block. Plain typed text is a single TextBlock; image paste adds an + // ImageBlock gated by the target model's supports_vision. + Content []*v11.ContentBlock `protobuf:"bytes,2,rep,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitInputRequest) Reset() { + *x = SubmitInputRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitInputRequest) ProtoMessage() {} + +func (x *SubmitInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitInputRequest.ProtoReflect.Descriptor instead. +func (*SubmitInputRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{13} +} + +func (x *SubmitInputRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SubmitInputRequest) GetContent() []*v11.ContentBlock { + if x != nil { + return x.Content + } + return nil +} + +// ResolvePlanDecisionRequest answers a pending plan item that policy +// evaluated as ASK. See kernel-callbacks.md's ResolvePlanDecision and +// agent-loop/plan-apply-gate.md. +type ResolvePlanDecisionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session owning the pending plan item. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The plan item being resolved. MUST be set. + PlanItemId string `protobuf:"bytes,2,opt,name=plan_item_id,json=planItemId,proto3" json:"plan_item_id,omitempty"` + // The operator's allow/deny decision. MUST be set. + Decision v16.ClientDecision `protobuf:"varint,3,opt,name=decision,proto3,enum=pluggableharness.plan.v1.ClientDecision" json:"decision,omitempty"` + // When present, a operator-edited replacement for the plan item's + // tool input. The kernel MUST re-validate this against the tool's + // input_schema; an invalid correction is rejected, not silently coerced. + CorrectedInput *structpb.Struct `protobuf:"bytes,4,opt,name=corrected_input,json=correctedInput,proto3,oneof" json:"corrected_input,omitempty"` + // How durably this decision applies beyond this one item. + Scope v16.PlanDecisionScope `protobuf:"varint,5,opt,name=scope,proto3,enum=pluggableharness.plan.v1.PlanDecisionScope" json:"scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolvePlanDecisionRequest) Reset() { + *x = ResolvePlanDecisionRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolvePlanDecisionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolvePlanDecisionRequest) ProtoMessage() {} + +func (x *ResolvePlanDecisionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolvePlanDecisionRequest.ProtoReflect.Descriptor instead. +func (*ResolvePlanDecisionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{14} +} + +func (x *ResolvePlanDecisionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ResolvePlanDecisionRequest) GetPlanItemId() string { + if x != nil { + return x.PlanItemId + } + return "" +} + +func (x *ResolvePlanDecisionRequest) GetDecision() v16.ClientDecision { + if x != nil { + return x.Decision + } + return v16.ClientDecision(0) +} + +func (x *ResolvePlanDecisionRequest) GetCorrectedInput() *structpb.Struct { + if x != nil { + return x.CorrectedInput + } + return nil +} + +func (x *ResolvePlanDecisionRequest) GetScope() v16.PlanDecisionScope { + if x != nil { + return x.Scope + } + return v16.PlanDecisionScope(0) +} + +// ResolveInteractiveRequest answers a pending interactive-kind tool call. +// See kernel-callbacks.md's ResolveInteractive. +type ResolveInteractiveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session owning the pending call. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Correlates to the interactive tool call's id. MUST be set. + CallId string `protobuf:"bytes,2,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + // The operator's response, becoming the interactive call's + // ToolResult.payload. MUST be set. + Response *structpb.Struct `protobuf:"bytes,3,opt,name=response,proto3" json:"response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolveInteractiveRequest) Reset() { + *x = ResolveInteractiveRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolveInteractiveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolveInteractiveRequest) ProtoMessage() {} + +func (x *ResolveInteractiveRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolveInteractiveRequest.ProtoReflect.Descriptor instead. +func (*ResolveInteractiveRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{15} +} + +func (x *ResolveInteractiveRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ResolveInteractiveRequest) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *ResolveInteractiveRequest) GetResponse() *structpb.Struct { + if x != nil { + return x.Response + } + return nil +} + +// InterruptRequest cancels the running turn for a session. +type InterruptRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session whose turn to cancel. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InterruptRequest) Reset() { + *x = InterruptRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InterruptRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InterruptRequest) ProtoMessage() {} + +func (x *InterruptRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InterruptRequest.ProtoReflect.Descriptor instead. +func (*InterruptRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{16} +} + +func (x *InterruptRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// CreateSessionRequest creates a new session and grants the calling +// frontend a subscription to it. +type CreateSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The agent.hcl profile to create the session under. Absent means the + // kernel's configured default profile. + Profile *string `protobuf:"bytes,1,opt,name=profile,proto3,oneof" json:"profile,omitempty"` + // An initial user message to seed the session with, submitted as the + // first turn once the session is created. Absent creates an empty + // session awaiting the first SubmitInput. + InitialPrompt *string `protobuf:"bytes,2,opt,name=initial_prompt,json=initialPrompt,proto3,oneof" json:"initial_prompt,omitempty"` + // The session's working directory. Absent means the kernel's own + // working directory at creation time. + WorkingDirectory *string `protobuf:"bytes,3,opt,name=working_directory,json=workingDirectory,proto3,oneof" json:"working_directory,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSessionRequest) Reset() { + *x = CreateSessionRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSessionRequest) ProtoMessage() {} + +func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead. +func (*CreateSessionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{17} +} + +func (x *CreateSessionRequest) GetProfile() string { + if x != nil && x.Profile != nil { + return *x.Profile + } + return "" +} + +func (x *CreateSessionRequest) GetInitialPrompt() string { + if x != nil && x.InitialPrompt != nil { + return *x.InitialPrompt + } + return "" +} + +func (x *CreateSessionRequest) GetWorkingDirectory() string { + if x != nil && x.WorkingDirectory != nil { + return *x.WorkingDirectory + } + return "" +} + +// AttachSessionRequest subscribes the calling frontend to an existing +// session (live or terminal) without re-opening a terminal session. +type AttachSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session to attach. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachSessionRequest) Reset() { + *x = AttachSessionRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachSessionRequest) ProtoMessage() {} + +func (x *AttachSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AttachSessionRequest.ProtoReflect.Descriptor instead. +func (*AttachSessionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{18} +} + +func (x *AttachSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// ResumeSessionRequest attaches a historical session for continuation or +// replay. A COMPLETED or CANCELLED session MAY be re-opened to RUNNING +// for new turns; a bound-exhausted or FAILED session attaches +// replay-only. +type ResumeSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session to resume. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeSessionRequest) Reset() { + *x = ResumeSessionRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeSessionRequest) ProtoMessage() {} + +func (x *ResumeSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResumeSessionRequest.ProtoReflect.Descriptor instead. +func (*ResumeSessionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{19} +} + +func (x *ResumeSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// DetachSessionRequest unsubscribes the calling frontend from a session +// without affecting the session itself or other attached frontends. +type DetachSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session to detach. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DetachSessionRequest) Reset() { + *x = DetachSessionRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DetachSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DetachSessionRequest) ProtoMessage() {} + +func (x *DetachSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DetachSessionRequest.ProtoReflect.Descriptor instead. +func (*DetachSessionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{20} +} + +func (x *DetachSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// ListSessionsRequest returns a filtered session summary list. +type ListSessionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Restricts the result to sessions in this status. Absent means no + // status filter. + Status *v17.SessionStatus `protobuf:"varint,1,opt,name=status,proto3,enum=pluggableharness.session.v1.SessionStatus,oneof" json:"status,omitempty"` + // Restricts the result to children of this session. Absent means no + // parent filter. + ParentSessionId *string `protobuf:"bytes,2,opt,name=parent_session_id,json=parentSessionId,proto3,oneof" json:"parent_session_id,omitempty"` + // True: only root sessions (no parent_session_id). False: all sessions + // matching the other filters, at any depth. + RootsOnly bool `protobuf:"varint,3,opt,name=roots_only,json=rootsOnly,proto3" json:"roots_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionsRequest) Reset() { + *x = ListSessionsRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsRequest) ProtoMessage() {} + +func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. +func (*ListSessionsRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{21} +} + +func (x *ListSessionsRequest) GetStatus() v17.SessionStatus { + if x != nil && x.Status != nil { + return *x.Status + } + return v17.SessionStatus(0) +} + +func (x *ListSessionsRequest) GetParentSessionId() string { + if x != nil && x.ParentSessionId != nil { + return *x.ParentSessionId + } + return "" +} + +func (x *ListSessionsRequest) GetRootsOnly() bool { + if x != nil { + return x.RootsOnly + } + return false +} + +// PublishMetadataRequest upserts one MetadataBlock into a session's +// metadata surface. The kernel stamps producer and liveness=LIVE. +type PublishMetadataRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session this block belongs to. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The block to publish. id and body MUST be set; producer and liveness + // are server-derived and any client-set values are overwritten. + Block *v18.MetadataBlock `protobuf:"bytes,2,opt,name=block,proto3" json:"block,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishMetadataRequest) Reset() { + *x = PublishMetadataRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishMetadataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishMetadataRequest) ProtoMessage() {} + +func (x *PublishMetadataRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PublishMetadataRequest.ProtoReflect.Descriptor instead. +func (*PublishMetadataRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{22} +} + +func (x *PublishMetadataRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *PublishMetadataRequest) GetBlock() *v18.MetadataBlock { + if x != nil { + return x.Block + } + return nil +} + +// RetractMetadataRequest marks a block DISCONNECTED and republishes it. +// The kernel never deletes the block. +type RetractMetadataRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session owning the block. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The block id to retract. MUST be set. + BlockId string `protobuf:"bytes,2,opt,name=block_id,json=blockId,proto3" json:"block_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetractMetadataRequest) Reset() { + *x = RetractMetadataRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetractMetadataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetractMetadataRequest) ProtoMessage() {} + +func (x *RetractMetadataRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetractMetadataRequest.ProtoReflect.Descriptor instead. +func (*RetractMetadataRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{23} +} + +func (x *RetractMetadataRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *RetractMetadataRequest) GetBlockId() string { + if x != nil { + return x.BlockId + } + return "" +} + +// ListMetadataRequest returns every MetadataBlock currently known for a +// session — the snapshot half of snapshot-then-subscribe for the +// metadata surface. +type ListMetadataRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session whose blocks to list. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMetadataRequest) Reset() { + *x = ListMetadataRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMetadataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMetadataRequest) ProtoMessage() {} + +func (x *ListMetadataRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMetadataRequest.ProtoReflect.Descriptor instead. +func (*ListMetadataRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{24} +} + +func (x *ListMetadataRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// StreamDeltasRequest opens a live-only token-delta stream for one +// session. Replayed text arrives as finished RenderTrees via ReadEvents, +// never as deltas. +type StreamDeltasRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session to stream deltas for. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamDeltasRequest) Reset() { + *x = StreamDeltasRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamDeltasRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamDeltasRequest) ProtoMessage() {} + +func (x *StreamDeltasRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamDeltasRequest.ProtoReflect.Descriptor instead. +func (*StreamDeltasRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{25} +} + +func (x *StreamDeltasRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// InvokeSlashCommandRequest dispatches a slash command against a session. +type InvokeSlashCommandRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session to invoke against. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The command name, without its leading slash. MUST be set. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // The raw argument string following the command name. + Args string `protobuf:"bytes,3,opt,name=args,proto3" json:"args,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeSlashCommandRequest) Reset() { + *x = InvokeSlashCommandRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeSlashCommandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeSlashCommandRequest) ProtoMessage() {} + +func (x *InvokeSlashCommandRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InvokeSlashCommandRequest.ProtoReflect.Descriptor instead. +func (*InvokeSlashCommandRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{26} +} + +func (x *InvokeSlashCommandRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *InvokeSlashCommandRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *InvokeSlashCommandRequest) GetArgs() string { + if x != nil { + return x.Args + } + return "" +} + +// TriggerActionRequest dispatches an ActionNode activation — the same +// no-model-turn Invoke/plan-apply path as a direct-invoke slash command. +type TriggerActionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session to invoke against. MUST be set. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The originating ActionNode's id. + NodeId string `protobuf:"bytes,2,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` + // The tool operation to invoke (ToolSchema.name). + ToolName string `protobuf:"bytes,3,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` + // The arguments to invoke it with. + Args *structpb.Struct `protobuf:"bytes,4,opt,name=args,proto3" json:"args,omitempty"` + // The declared name of the tool provider plugin tool_name belongs to. + Provider string `protobuf:"bytes,5,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TriggerActionRequest) Reset() { + *x = TriggerActionRequest{} + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerActionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerActionRequest) ProtoMessage() {} + +func (x *TriggerActionRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerActionRequest.ProtoReflect.Descriptor instead. +func (*TriggerActionRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP(), []int{27} +} + +func (x *TriggerActionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *TriggerActionRequest) GetNodeId() string { + if x != nil { + return x.NodeId + } + return "" +} + +func (x *TriggerActionRequest) GetToolName() string { + if x != nil { + return x.ToolName + } + return "" +} + +func (x *TriggerActionRequest) GetArgs() *structpb.Struct { + if x != nil { + return x.Args + } + return nil +} + +func (x *TriggerActionRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + var File_pluggableharness_kernel_v1_rpc_request_proto protoreflect.FileDescriptor const file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc = "" + "\n" + - ",pluggableharness/kernel/v1/rpc_request.proto\x12\x1apluggableharness.kernel.v1\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a&pluggableharness/kernel/v1/types.proto\x1a#pluggableharness/log/v1/types.proto\x1a&pluggableharness/metric/v1/types.proto\x1a%pluggableharness/model/v1/types.proto\x1a%pluggableharness/trace/v1/types.proto\"\xa9\x02\n" + + ",pluggableharness/kernel/v1/rpc_request.proto\x12\x1apluggableharness.kernel.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a&pluggableharness/common/v1/types.proto\x1a'pluggableharness/content/v1/types.proto\x1a&pluggableharness/kernel/v1/types.proto\x1a#pluggableharness/log/v1/types.proto\x1a(pluggableharness/metadata/v1/types.proto\x1a&pluggableharness/metric/v1/types.proto\x1a%pluggableharness/model/v1/types.proto\x1a$pluggableharness/plan/v1/types.proto\x1a'pluggableharness/session/v1/types.proto\x1a%pluggableharness/trace/v1/types.proto\"\xa9\x02\n" + "\x11RunSessionRequest\x12\x18\n" + "\aprofile\x18\x01 \x01(\tR\aprofile\x12\x16\n" + "\x06prompt\x18\x02 \x01(\tR\x06prompt\x12*\n" + @@ -858,7 +1802,81 @@ const file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc = "" + "\x06_limit\"2\n" + "\x11GetSessionRequest\x12\x1d\n" + "\n" + - "session_id\x18\x01 \x01(\tR\tsessionIdB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + "session_id\x18\x01 \x01(\tR\tsessionId\"7\n" + + "\x16GetSessionStateRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"x\n" + + "\x12SubmitInputRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12C\n" + + "\acontent\x18\x02 \x03(\v2).pluggableharness.content.v1.ContentBlockR\acontent\"\xc1\x02\n" + + "\x1aResolvePlanDecisionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12 \n" + + "\fplan_item_id\x18\x02 \x01(\tR\n" + + "planItemId\x12D\n" + + "\bdecision\x18\x03 \x01(\x0e2(.pluggableharness.plan.v1.ClientDecisionR\bdecision\x12E\n" + + "\x0fcorrected_input\x18\x04 \x01(\v2\x17.google.protobuf.StructH\x00R\x0ecorrectedInput\x88\x01\x01\x12A\n" + + "\x05scope\x18\x05 \x01(\x0e2+.pluggableharness.plan.v1.PlanDecisionScopeR\x05scopeB\x12\n" + + "\x10_corrected_input\"\x88\x01\n" + + "\x19ResolveInteractiveRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x17\n" + + "\acall_id\x18\x02 \x01(\tR\x06callId\x123\n" + + "\bresponse\x18\x03 \x01(\v2\x17.google.protobuf.StructR\bresponse\"1\n" + + "\x10InterruptRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\xc8\x01\n" + + "\x14CreateSessionRequest\x12\x1d\n" + + "\aprofile\x18\x01 \x01(\tH\x00R\aprofile\x88\x01\x01\x12*\n" + + "\x0einitial_prompt\x18\x02 \x01(\tH\x01R\rinitialPrompt\x88\x01\x01\x120\n" + + "\x11working_directory\x18\x03 \x01(\tH\x02R\x10workingDirectory\x88\x01\x01B\n" + + "\n" + + "\b_profileB\x11\n" + + "\x0f_initial_promptB\x14\n" + + "\x12_working_directory\"5\n" + + "\x14AttachSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"5\n" + + "\x14ResumeSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"5\n" + + "\x14DetachSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\xcf\x01\n" + + "\x13ListSessionsRequest\x12G\n" + + "\x06status\x18\x01 \x01(\x0e2*.pluggableharness.session.v1.SessionStatusH\x00R\x06status\x88\x01\x01\x12/\n" + + "\x11parent_session_id\x18\x02 \x01(\tH\x01R\x0fparentSessionId\x88\x01\x01\x12\x1d\n" + + "\n" + + "roots_only\x18\x03 \x01(\bR\trootsOnlyB\t\n" + + "\a_statusB\x14\n" + + "\x12_parent_session_id\"z\n" + + "\x16PublishMetadataRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12A\n" + + "\x05block\x18\x02 \x01(\v2+.pluggableharness.metadata.v1.MetadataBlockR\x05block\"R\n" + + "\x16RetractMetadataRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x19\n" + + "\bblock_id\x18\x02 \x01(\tR\ablockId\"4\n" + + "\x13ListMetadataRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"4\n" + + "\x13StreamDeltasRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"b\n" + + "\x19InvokeSlashCommandRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + + "\x04args\x18\x03 \x01(\tR\x04args\"\xb4\x01\n" + + "\x14TriggerActionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x17\n" + + "\anode_id\x18\x02 \x01(\tR\x06nodeId\x12\x1b\n" + + "\ttool_name\x18\x03 \x01(\tR\btoolName\x12+\n" + + "\x04args\x18\x04 \x01(\v2\x17.google.protobuf.StructR\x04args\x12\x1a\n" + + "\bprovider\x18\x05 \x01(\tR\bproviderB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" var ( file_pluggableharness_kernel_v1_rpc_request_proto_rawDescOnce sync.Once @@ -872,42 +1890,71 @@ func file_pluggableharness_kernel_v1_rpc_request_proto_rawDescGZIP() []byte { return file_pluggableharness_kernel_v1_rpc_request_proto_rawDescData } -var file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 28) var file_pluggableharness_kernel_v1_rpc_request_proto_goTypes = []any{ - (*RunSessionRequest)(nil), // 0: pluggableharness.kernel.v1.RunSessionRequest - (*CountTokensRequest)(nil), // 1: pluggableharness.kernel.v1.CountTokensRequest - (*EmitRequest)(nil), // 2: pluggableharness.kernel.v1.EmitRequest - (*LogRequest)(nil), // 3: pluggableharness.kernel.v1.LogRequest - (*ExportSpansRequest)(nil), // 4: pluggableharness.kernel.v1.ExportSpansRequest - (*RecordMetricsRequest)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsRequest - (*GetTelemetryConfigRequest)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigRequest - (*GetConfigRequest)(nil), // 7: pluggableharness.kernel.v1.GetConfigRequest - (*PublishRequest)(nil), // 8: pluggableharness.kernel.v1.PublishRequest - (*SubscribeRequest)(nil), // 9: pluggableharness.kernel.v1.SubscribeRequest - (*ReadEventsRequest)(nil), // 10: pluggableharness.kernel.v1.ReadEventsRequest - (*GetSessionRequest)(nil), // 11: pluggableharness.kernel.v1.GetSessionRequest - (*v1.ProviderRef)(nil), // 12: pluggableharness.common.v1.ProviderRef - (*v11.ContentBlock)(nil), // 13: pluggableharness.content.v1.ContentBlock - (*v12.ModelRef)(nil), // 14: pluggableharness.model.v1.ModelRef - (EventKind)(0), // 15: pluggableharness.kernel.v1.EventKind - (*v13.LogEntry)(nil), // 16: pluggableharness.log.v1.LogEntry - (*v14.Span)(nil), // 17: pluggableharness.trace.v1.Span - (*v15.MetricRecord)(nil), // 18: pluggableharness.metric.v1.MetricRecord + (*RunSessionRequest)(nil), // 0: pluggableharness.kernel.v1.RunSessionRequest + (*CountTokensRequest)(nil), // 1: pluggableharness.kernel.v1.CountTokensRequest + (*EmitRequest)(nil), // 2: pluggableharness.kernel.v1.EmitRequest + (*LogRequest)(nil), // 3: pluggableharness.kernel.v1.LogRequest + (*ExportSpansRequest)(nil), // 4: pluggableharness.kernel.v1.ExportSpansRequest + (*RecordMetricsRequest)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsRequest + (*GetTelemetryConfigRequest)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigRequest + (*GetConfigRequest)(nil), // 7: pluggableharness.kernel.v1.GetConfigRequest + (*PublishRequest)(nil), // 8: pluggableharness.kernel.v1.PublishRequest + (*SubscribeRequest)(nil), // 9: pluggableharness.kernel.v1.SubscribeRequest + (*ReadEventsRequest)(nil), // 10: pluggableharness.kernel.v1.ReadEventsRequest + (*GetSessionRequest)(nil), // 11: pluggableharness.kernel.v1.GetSessionRequest + (*GetSessionStateRequest)(nil), // 12: pluggableharness.kernel.v1.GetSessionStateRequest + (*SubmitInputRequest)(nil), // 13: pluggableharness.kernel.v1.SubmitInputRequest + (*ResolvePlanDecisionRequest)(nil), // 14: pluggableharness.kernel.v1.ResolvePlanDecisionRequest + (*ResolveInteractiveRequest)(nil), // 15: pluggableharness.kernel.v1.ResolveInteractiveRequest + (*InterruptRequest)(nil), // 16: pluggableharness.kernel.v1.InterruptRequest + (*CreateSessionRequest)(nil), // 17: pluggableharness.kernel.v1.CreateSessionRequest + (*AttachSessionRequest)(nil), // 18: pluggableharness.kernel.v1.AttachSessionRequest + (*ResumeSessionRequest)(nil), // 19: pluggableharness.kernel.v1.ResumeSessionRequest + (*DetachSessionRequest)(nil), // 20: pluggableharness.kernel.v1.DetachSessionRequest + (*ListSessionsRequest)(nil), // 21: pluggableharness.kernel.v1.ListSessionsRequest + (*PublishMetadataRequest)(nil), // 22: pluggableharness.kernel.v1.PublishMetadataRequest + (*RetractMetadataRequest)(nil), // 23: pluggableharness.kernel.v1.RetractMetadataRequest + (*ListMetadataRequest)(nil), // 24: pluggableharness.kernel.v1.ListMetadataRequest + (*StreamDeltasRequest)(nil), // 25: pluggableharness.kernel.v1.StreamDeltasRequest + (*InvokeSlashCommandRequest)(nil), // 26: pluggableharness.kernel.v1.InvokeSlashCommandRequest + (*TriggerActionRequest)(nil), // 27: pluggableharness.kernel.v1.TriggerActionRequest + (*v1.ProviderRef)(nil), // 28: pluggableharness.common.v1.ProviderRef + (*v11.ContentBlock)(nil), // 29: pluggableharness.content.v1.ContentBlock + (*v12.ModelRef)(nil), // 30: pluggableharness.model.v1.ModelRef + (EventKind)(0), // 31: pluggableharness.kernel.v1.EventKind + (*v13.LogEntry)(nil), // 32: pluggableharness.log.v1.LogEntry + (*v14.Span)(nil), // 33: pluggableharness.trace.v1.Span + (*v15.MetricRecord)(nil), // 34: pluggableharness.metric.v1.MetricRecord + (v16.ClientDecision)(0), // 35: pluggableharness.plan.v1.ClientDecision + (*structpb.Struct)(nil), // 36: google.protobuf.Struct + (v16.PlanDecisionScope)(0), // 37: pluggableharness.plan.v1.PlanDecisionScope + (v17.SessionStatus)(0), // 38: pluggableharness.session.v1.SessionStatus + (*v18.MetadataBlock)(nil), // 39: pluggableharness.metadata.v1.MetadataBlock } var file_pluggableharness_kernel_v1_rpc_request_proto_depIdxs = []int32{ - 12, // 0: pluggableharness.kernel.v1.RunSessionRequest.scoped_providers:type_name -> pluggableharness.common.v1.ProviderRef - 13, // 1: pluggableharness.kernel.v1.CountTokensRequest.content:type_name -> pluggableharness.content.v1.ContentBlock - 14, // 2: pluggableharness.kernel.v1.CountTokensRequest.model_ref:type_name -> pluggableharness.model.v1.ModelRef - 15, // 3: pluggableharness.kernel.v1.EmitRequest.kind:type_name -> pluggableharness.kernel.v1.EventKind - 16, // 4: pluggableharness.kernel.v1.LogRequest.entries:type_name -> pluggableharness.log.v1.LogEntry - 17, // 5: pluggableharness.kernel.v1.ExportSpansRequest.spans:type_name -> pluggableharness.trace.v1.Span - 18, // 6: pluggableharness.kernel.v1.RecordMetricsRequest.metrics:type_name -> pluggableharness.metric.v1.MetricRecord - 15, // 7: pluggableharness.kernel.v1.ReadEventsRequest.kinds:type_name -> pluggableharness.kernel.v1.EventKind - 8, // [8:8] is the sub-list for method output_type - 8, // [8:8] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 28, // 0: pluggableharness.kernel.v1.RunSessionRequest.scoped_providers:type_name -> pluggableharness.common.v1.ProviderRef + 29, // 1: pluggableharness.kernel.v1.CountTokensRequest.content:type_name -> pluggableharness.content.v1.ContentBlock + 30, // 2: pluggableharness.kernel.v1.CountTokensRequest.model_ref:type_name -> pluggableharness.model.v1.ModelRef + 31, // 3: pluggableharness.kernel.v1.EmitRequest.kind:type_name -> pluggableharness.kernel.v1.EventKind + 32, // 4: pluggableharness.kernel.v1.LogRequest.entries:type_name -> pluggableharness.log.v1.LogEntry + 33, // 5: pluggableharness.kernel.v1.ExportSpansRequest.spans:type_name -> pluggableharness.trace.v1.Span + 34, // 6: pluggableharness.kernel.v1.RecordMetricsRequest.metrics:type_name -> pluggableharness.metric.v1.MetricRecord + 31, // 7: pluggableharness.kernel.v1.ReadEventsRequest.kinds:type_name -> pluggableharness.kernel.v1.EventKind + 29, // 8: pluggableharness.kernel.v1.SubmitInputRequest.content:type_name -> pluggableharness.content.v1.ContentBlock + 35, // 9: pluggableharness.kernel.v1.ResolvePlanDecisionRequest.decision:type_name -> pluggableharness.plan.v1.ClientDecision + 36, // 10: pluggableharness.kernel.v1.ResolvePlanDecisionRequest.corrected_input:type_name -> google.protobuf.Struct + 37, // 11: pluggableharness.kernel.v1.ResolvePlanDecisionRequest.scope:type_name -> pluggableharness.plan.v1.PlanDecisionScope + 36, // 12: pluggableharness.kernel.v1.ResolveInteractiveRequest.response:type_name -> google.protobuf.Struct + 38, // 13: pluggableharness.kernel.v1.ListSessionsRequest.status:type_name -> pluggableharness.session.v1.SessionStatus + 39, // 14: pluggableharness.kernel.v1.PublishMetadataRequest.block:type_name -> pluggableharness.metadata.v1.MetadataBlock + 36, // 15: pluggableharness.kernel.v1.TriggerActionRequest.args:type_name -> google.protobuf.Struct + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_pluggableharness_kernel_v1_rpc_request_proto_init() } @@ -921,13 +1968,16 @@ func file_pluggableharness_kernel_v1_rpc_request_proto_init() { file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[4].OneofWrappers = []any{} file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[5].OneofWrappers = []any{} file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[10].OneofWrappers = []any{} + file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[14].OneofWrappers = []any{} + file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[17].OneofWrappers = []any{} + file_pluggableharness_kernel_v1_rpc_request_proto_msgTypes[21].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_kernel_v1_rpc_request_proto_rawDesc)), NumEnums: 0, - NumMessages: 12, + NumMessages: 28, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/kernel/proto/v1/rpc_response.pb.go b/pkg/kernel/proto/v1/rpc_response.pb.go index 33b8b7b..8f7934e 100644 --- a/pkg/kernel/proto/v1/rpc_response.pb.go +++ b/pkg/kernel/proto/v1/rpc_response.pb.go @@ -9,6 +9,7 @@ package kernelv1 import ( v1 "github.com/pluggableharness/agent/pkg/content/proto/v1" v12 "github.com/pluggableharness/agent/pkg/log/proto/v1" + v13 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" v11 "github.com/pluggableharness/agent/pkg/session/proto/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" @@ -622,11 +623,656 @@ func (x *GetSessionResult) GetRemainingCostBudgetUsd() float64 { return 0 } +// GetSessionStateResult carries the fixed-schema SessionState snapshot. +type GetSessionStateResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session's "where am I" state. MUST be set. + State *v11.SessionState `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionStateResult) Reset() { + *x = GetSessionStateResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionStateResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionStateResult) ProtoMessage() {} + +func (x *GetSessionStateResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionStateResult.ProtoReflect.Descriptor instead. +func (*GetSessionStateResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{10} +} + +func (x *GetSessionStateResult) GetState() *v11.SessionState { + if x != nil { + return x.State + } + return nil +} + +// SubmitInputResult acknowledges a submitted turn. +type SubmitInputResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The turn id assigned to this submission, for correlating subsequent + // events and deltas without relying solely on stream order. MUST be set. + TurnId string `protobuf:"bytes,1,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitInputResult) Reset() { + *x = SubmitInputResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitInputResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitInputResult) ProtoMessage() {} + +func (x *SubmitInputResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitInputResult.ProtoReflect.Descriptor instead. +func (*SubmitInputResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{11} +} + +func (x *SubmitInputResult) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +// ResolvePlanDecisionResult is empty on success. Errors surface as a +// gRPC status (e.g. already resolved, unknown plan_item_id). +type ResolvePlanDecisionResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolvePlanDecisionResult) Reset() { + *x = ResolvePlanDecisionResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolvePlanDecisionResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolvePlanDecisionResult) ProtoMessage() {} + +func (x *ResolvePlanDecisionResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolvePlanDecisionResult.ProtoReflect.Descriptor instead. +func (*ResolvePlanDecisionResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{12} +} + +// ResolveInteractiveResult is empty on success. +type ResolveInteractiveResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolveInteractiveResult) Reset() { + *x = ResolveInteractiveResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolveInteractiveResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolveInteractiveResult) ProtoMessage() {} + +func (x *ResolveInteractiveResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolveInteractiveResult.ProtoReflect.Descriptor instead. +func (*ResolveInteractiveResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{13} +} + +// InterruptResult is empty on success. +type InterruptResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InterruptResult) Reset() { + *x = InterruptResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InterruptResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InterruptResult) ProtoMessage() {} + +func (x *InterruptResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InterruptResult.ProtoReflect.Descriptor instead. +func (*InterruptResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{14} +} + +// CreateSessionResult carries the newly created session's info. The +// calling frontend is auto-attached. +type CreateSessionResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The newly created session's info. MUST be set. + Info *v11.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSessionResult) Reset() { + *x = CreateSessionResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSessionResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSessionResult) ProtoMessage() {} + +func (x *CreateSessionResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSessionResult.ProtoReflect.Descriptor instead. +func (*CreateSessionResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{15} +} + +func (x *CreateSessionResult) GetInfo() *v11.SessionInfo { + if x != nil { + return x.Info + } + return nil +} + +// AttachSessionResult carries the attached session's current info. +// History backfill is via ReadEvents, not inlined here. +type AttachSessionResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The attached session's current info. MUST be set. + Info *v11.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachSessionResult) Reset() { + *x = AttachSessionResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachSessionResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachSessionResult) ProtoMessage() {} + +func (x *AttachSessionResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AttachSessionResult.ProtoReflect.Descriptor instead. +func (*AttachSessionResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{16} +} + +func (x *AttachSessionResult) GetInfo() *v11.SessionInfo { + if x != nil { + return x.Info + } + return nil +} + +// ResumeSessionResult carries the resumed session's current info. +type ResumeSessionResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The resumed session's current info. MUST be set. + Info *v11.SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeSessionResult) Reset() { + *x = ResumeSessionResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeSessionResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeSessionResult) ProtoMessage() {} + +func (x *ResumeSessionResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResumeSessionResult.ProtoReflect.Descriptor instead. +func (*ResumeSessionResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{17} +} + +func (x *ResumeSessionResult) GetInfo() *v11.SessionInfo { + if x != nil { + return x.Info + } + return nil +} + +// DetachSessionResult is empty on success. +type DetachSessionResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DetachSessionResult) Reset() { + *x = DetachSessionResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DetachSessionResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DetachSessionResult) ProtoMessage() {} + +func (x *DetachSessionResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DetachSessionResult.ProtoReflect.Descriptor instead. +func (*DetachSessionResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{18} +} + +// ListSessionsResult carries the matching session summaries. +type ListSessionsResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The matching sessions, most-recently-started first. + Sessions []*v11.SessionInfo `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionsResult) Reset() { + *x = ListSessionsResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionsResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsResult) ProtoMessage() {} + +func (x *ListSessionsResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsResult.ProtoReflect.Descriptor instead. +func (*ListSessionsResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{19} +} + +func (x *ListSessionsResult) GetSessions() []*v11.SessionInfo { + if x != nil { + return x.Sessions + } + return nil +} + +// PublishMetadataResult carries the block as stored after the kernel +// stamped producer and liveness. +type PublishMetadataResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The stored block. MUST be set. + Block *v13.MetadataBlock `protobuf:"bytes,1,opt,name=block,proto3" json:"block,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublishMetadataResult) Reset() { + *x = PublishMetadataResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublishMetadataResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublishMetadataResult) ProtoMessage() {} + +func (x *PublishMetadataResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PublishMetadataResult.ProtoReflect.Descriptor instead. +func (*PublishMetadataResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{20} +} + +func (x *PublishMetadataResult) GetBlock() *v13.MetadataBlock { + if x != nil { + return x.Block + } + return nil +} + +// RetractMetadataResult carries the block after liveness was flipped to +// DISCONNECTED. +type RetractMetadataResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The retracted block. MUST be set. + Block *v13.MetadataBlock `protobuf:"bytes,1,opt,name=block,proto3" json:"block,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetractMetadataResult) Reset() { + *x = RetractMetadataResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetractMetadataResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetractMetadataResult) ProtoMessage() {} + +func (x *RetractMetadataResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetractMetadataResult.ProtoReflect.Descriptor instead. +func (*RetractMetadataResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{21} +} + +func (x *RetractMetadataResult) GetBlock() *v13.MetadataBlock { + if x != nil { + return x.Block + } + return nil +} + +// ListMetadataResult carries every known block for the session. +type ListMetadataResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // All blocks currently in the session's metadata store, in stable + // id order. MAY be empty. + Blocks []*v13.MetadataBlock `protobuf:"bytes,1,rep,name=blocks,proto3" json:"blocks,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMetadataResult) Reset() { + *x = ListMetadataResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMetadataResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMetadataResult) ProtoMessage() {} + +func (x *ListMetadataResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMetadataResult.ProtoReflect.Descriptor instead. +func (*ListMetadataResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{22} +} + +func (x *ListMetadataResult) GetBlocks() []*v13.MetadataBlock { + if x != nil { + return x.Blocks + } + return nil +} + +// InvokeSlashCommandResult is empty on success; command output flows +// through the transcript / metadata surfaces. +type InvokeSlashCommandResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeSlashCommandResult) Reset() { + *x = InvokeSlashCommandResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeSlashCommandResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeSlashCommandResult) ProtoMessage() {} + +func (x *InvokeSlashCommandResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InvokeSlashCommandResult.ProtoReflect.Descriptor instead. +func (*InvokeSlashCommandResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{23} +} + +// TriggerActionResult is empty on success; action output flows through +// the transcript / plan-apply path. +type TriggerActionResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TriggerActionResult) Reset() { + *x = TriggerActionResult{} + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerActionResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerActionResult) ProtoMessage() {} + +func (x *TriggerActionResult) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerActionResult.ProtoReflect.Descriptor instead. +func (*TriggerActionResult) Descriptor() ([]byte, []int) { + return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP(), []int{24} +} + var File_pluggableharness_kernel_v1_rpc_response_proto protoreflect.FileDescriptor const file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc = "" + "\n" + - "-pluggableharness/kernel/v1/rpc_response.proto\x12\x1apluggableharness.kernel.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/content/v1/types.proto\x1a#pluggableharness/log/v1/types.proto\x1a'pluggableharness/session/v1/types.proto\"\xc4\x02\n" + + "-pluggableharness/kernel/v1/rpc_response.proto\x12\x1apluggableharness.kernel.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a'pluggableharness/content/v1/types.proto\x1a#pluggableharness/log/v1/types.proto\x1a(pluggableharness/metadata/v1/types.proto\x1a'pluggableharness/session/v1/types.proto\"\xc4\x02\n" + "\x10RunSessionResult\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12I\n" + @@ -658,7 +1304,31 @@ const file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc = "" + "\x10GetSessionResult\x12<\n" + "\x04info\x18\x01 \x01(\v2(.pluggableharness.session.v1.SessionInfoR\x04info\x12'\n" + "\x0fremaining_depth\x18\x02 \x01(\x05R\x0eremainingDepth\x129\n" + - "\x19remaining_cost_budget_usd\x18\x03 \x01(\x01R\x16remainingCostBudgetUsdB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + "\x19remaining_cost_budget_usd\x18\x03 \x01(\x01R\x16remainingCostBudgetUsd\"X\n" + + "\x15GetSessionStateResult\x12?\n" + + "\x05state\x18\x01 \x01(\v2).pluggableharness.session.v1.SessionStateR\x05state\",\n" + + "\x11SubmitInputResult\x12\x17\n" + + "\aturn_id\x18\x01 \x01(\tR\x06turnId\"\x1b\n" + + "\x19ResolvePlanDecisionResult\"\x1a\n" + + "\x18ResolveInteractiveResult\"\x11\n" + + "\x0fInterruptResult\"S\n" + + "\x13CreateSessionResult\x12<\n" + + "\x04info\x18\x01 \x01(\v2(.pluggableharness.session.v1.SessionInfoR\x04info\"S\n" + + "\x13AttachSessionResult\x12<\n" + + "\x04info\x18\x01 \x01(\v2(.pluggableharness.session.v1.SessionInfoR\x04info\"S\n" + + "\x13ResumeSessionResult\x12<\n" + + "\x04info\x18\x01 \x01(\v2(.pluggableharness.session.v1.SessionInfoR\x04info\"\x15\n" + + "\x13DetachSessionResult\"Z\n" + + "\x12ListSessionsResult\x12D\n" + + "\bsessions\x18\x01 \x03(\v2(.pluggableharness.session.v1.SessionInfoR\bsessions\"Z\n" + + "\x15PublishMetadataResult\x12A\n" + + "\x05block\x18\x01 \x01(\v2+.pluggableharness.metadata.v1.MetadataBlockR\x05block\"Z\n" + + "\x15RetractMetadataResult\x12A\n" + + "\x05block\x18\x01 \x01(\v2+.pluggableharness.metadata.v1.MetadataBlockR\x05block\"Y\n" + + "\x12ListMetadataResult\x12C\n" + + "\x06blocks\x18\x01 \x03(\v2+.pluggableharness.metadata.v1.MetadataBlockR\x06blocks\"\x1a\n" + + "\x18InvokeSlashCommandResult\"\x15\n" + + "\x13TriggerActionResultB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" var ( file_pluggableharness_kernel_v1_rpc_response_proto_rawDescOnce sync.Once @@ -672,35 +1342,60 @@ func file_pluggableharness_kernel_v1_rpc_response_proto_rawDescGZIP() []byte { return file_pluggableharness_kernel_v1_rpc_response_proto_rawDescData } -var file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_pluggableharness_kernel_v1_rpc_response_proto_msgTypes = make([]protoimpl.MessageInfo, 25) var file_pluggableharness_kernel_v1_rpc_response_proto_goTypes = []any{ - (*RunSessionResult)(nil), // 0: pluggableharness.kernel.v1.RunSessionResult - (*CountTokensResult)(nil), // 1: pluggableharness.kernel.v1.CountTokensResult - (*EmitResult)(nil), // 2: pluggableharness.kernel.v1.EmitResult - (*LogResult)(nil), // 3: pluggableharness.kernel.v1.LogResult - (*ExportSpansResult)(nil), // 4: pluggableharness.kernel.v1.ExportSpansResult - (*RecordMetricsResult)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsResult - (*GetTelemetryConfigResult)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigResult - (*GetConfigResult)(nil), // 7: pluggableharness.kernel.v1.GetConfigResult - (*PublishResult)(nil), // 8: pluggableharness.kernel.v1.PublishResult - (*GetSessionResult)(nil), // 9: pluggableharness.kernel.v1.GetSessionResult - (*v1.Message)(nil), // 10: pluggableharness.content.v1.Message - (v11.SessionStatus)(0), // 11: pluggableharness.session.v1.SessionStatus - (v12.LogLevel)(0), // 12: pluggableharness.log.v1.LogLevel - (*structpb.Struct)(nil), // 13: google.protobuf.Struct - (*v11.SessionInfo)(nil), // 14: pluggableharness.session.v1.SessionInfo + (*RunSessionResult)(nil), // 0: pluggableharness.kernel.v1.RunSessionResult + (*CountTokensResult)(nil), // 1: pluggableharness.kernel.v1.CountTokensResult + (*EmitResult)(nil), // 2: pluggableharness.kernel.v1.EmitResult + (*LogResult)(nil), // 3: pluggableharness.kernel.v1.LogResult + (*ExportSpansResult)(nil), // 4: pluggableharness.kernel.v1.ExportSpansResult + (*RecordMetricsResult)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsResult + (*GetTelemetryConfigResult)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigResult + (*GetConfigResult)(nil), // 7: pluggableharness.kernel.v1.GetConfigResult + (*PublishResult)(nil), // 8: pluggableharness.kernel.v1.PublishResult + (*GetSessionResult)(nil), // 9: pluggableharness.kernel.v1.GetSessionResult + (*GetSessionStateResult)(nil), // 10: pluggableharness.kernel.v1.GetSessionStateResult + (*SubmitInputResult)(nil), // 11: pluggableharness.kernel.v1.SubmitInputResult + (*ResolvePlanDecisionResult)(nil), // 12: pluggableharness.kernel.v1.ResolvePlanDecisionResult + (*ResolveInteractiveResult)(nil), // 13: pluggableharness.kernel.v1.ResolveInteractiveResult + (*InterruptResult)(nil), // 14: pluggableharness.kernel.v1.InterruptResult + (*CreateSessionResult)(nil), // 15: pluggableharness.kernel.v1.CreateSessionResult + (*AttachSessionResult)(nil), // 16: pluggableharness.kernel.v1.AttachSessionResult + (*ResumeSessionResult)(nil), // 17: pluggableharness.kernel.v1.ResumeSessionResult + (*DetachSessionResult)(nil), // 18: pluggableharness.kernel.v1.DetachSessionResult + (*ListSessionsResult)(nil), // 19: pluggableharness.kernel.v1.ListSessionsResult + (*PublishMetadataResult)(nil), // 20: pluggableharness.kernel.v1.PublishMetadataResult + (*RetractMetadataResult)(nil), // 21: pluggableharness.kernel.v1.RetractMetadataResult + (*ListMetadataResult)(nil), // 22: pluggableharness.kernel.v1.ListMetadataResult + (*InvokeSlashCommandResult)(nil), // 23: pluggableharness.kernel.v1.InvokeSlashCommandResult + (*TriggerActionResult)(nil), // 24: pluggableharness.kernel.v1.TriggerActionResult + (*v1.Message)(nil), // 25: pluggableharness.content.v1.Message + (v11.SessionStatus)(0), // 26: pluggableharness.session.v1.SessionStatus + (v12.LogLevel)(0), // 27: pluggableharness.log.v1.LogLevel + (*structpb.Struct)(nil), // 28: google.protobuf.Struct + (*v11.SessionInfo)(nil), // 29: pluggableharness.session.v1.SessionInfo + (*v11.SessionState)(nil), // 30: pluggableharness.session.v1.SessionState + (*v13.MetadataBlock)(nil), // 31: pluggableharness.metadata.v1.MetadataBlock } var file_pluggableharness_kernel_v1_rpc_response_proto_depIdxs = []int32{ - 10, // 0: pluggableharness.kernel.v1.RunSessionResult.final_message:type_name -> pluggableharness.content.v1.Message - 11, // 1: pluggableharness.kernel.v1.RunSessionResult.status:type_name -> pluggableharness.session.v1.SessionStatus - 12, // 2: pluggableharness.kernel.v1.GetTelemetryConfigResult.log_level:type_name -> pluggableharness.log.v1.LogLevel - 13, // 3: pluggableharness.kernel.v1.GetConfigResult.config:type_name -> google.protobuf.Struct - 14, // 4: pluggableharness.kernel.v1.GetSessionResult.info:type_name -> pluggableharness.session.v1.SessionInfo - 5, // [5:5] is the sub-list for method output_type - 5, // [5:5] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 25, // 0: pluggableharness.kernel.v1.RunSessionResult.final_message:type_name -> pluggableharness.content.v1.Message + 26, // 1: pluggableharness.kernel.v1.RunSessionResult.status:type_name -> pluggableharness.session.v1.SessionStatus + 27, // 2: pluggableharness.kernel.v1.GetTelemetryConfigResult.log_level:type_name -> pluggableharness.log.v1.LogLevel + 28, // 3: pluggableharness.kernel.v1.GetConfigResult.config:type_name -> google.protobuf.Struct + 29, // 4: pluggableharness.kernel.v1.GetSessionResult.info:type_name -> pluggableharness.session.v1.SessionInfo + 30, // 5: pluggableharness.kernel.v1.GetSessionStateResult.state:type_name -> pluggableharness.session.v1.SessionState + 29, // 6: pluggableharness.kernel.v1.CreateSessionResult.info:type_name -> pluggableharness.session.v1.SessionInfo + 29, // 7: pluggableharness.kernel.v1.AttachSessionResult.info:type_name -> pluggableharness.session.v1.SessionInfo + 29, // 8: pluggableharness.kernel.v1.ResumeSessionResult.info:type_name -> pluggableharness.session.v1.SessionInfo + 29, // 9: pluggableharness.kernel.v1.ListSessionsResult.sessions:type_name -> pluggableharness.session.v1.SessionInfo + 31, // 10: pluggableharness.kernel.v1.PublishMetadataResult.block:type_name -> pluggableharness.metadata.v1.MetadataBlock + 31, // 11: pluggableharness.kernel.v1.RetractMetadataResult.block:type_name -> pluggableharness.metadata.v1.MetadataBlock + 31, // 12: pluggableharness.kernel.v1.ListMetadataResult.blocks:type_name -> pluggableharness.metadata.v1.MetadataBlock + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_pluggableharness_kernel_v1_rpc_response_proto_init() } @@ -714,7 +1409,7 @@ func file_pluggableharness_kernel_v1_rpc_response_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_kernel_v1_rpc_response_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 25, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/kernel/proto/v1/service.pb.go b/pkg/kernel/proto/v1/service.pb.go index 828b049..39c7338 100644 --- a/pkg/kernel/proto/v1/service.pb.go +++ b/pkg/kernel/proto/v1/service.pb.go @@ -4,15 +4,19 @@ // protoc (unknown) // source: pluggableharness/kernel/v1/service.proto -// Package pluggableharness.kernel.v1 defines the kernel-callback service described -// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, -// Log, ExportSpans, RecordMetrics, GetTelemetryConfig, GetConfig, Publish, -// Subscribe, ReadEvents, GetSession) — the plugin-to-kernel calling -// direction every plugin category gets at handshake, the reverse of every -// other category's protocol in this series. Unlike a category plugin -// protocol, this service carries no GetCapabilities/Configure RPCs: it -// isn't something the kernel dials into a plugin, it's the connection -// every plugin subprocess is handed back to call into the kernel. +// Package pluggableharness.kernel.v1 defines the kernel-callback service +// described in specifications/kernel-callbacks.md — the plugin-to-kernel +// calling direction every plugin category gets at handshake, the reverse +// of every other category's protocol in this series. Unlike a category +// plugin protocol, this service carries no GetCapabilities/Configure +// RPCs: it is not something the kernel dials into a plugin, it is the +// connection every plugin subprocess is handed back to call into the +// kernel. +// +// Frontend state surfaces (input, state, metadata, transcript backfill, +// token deltas) also live here: under go-plugin the plugin is the gRPC +// server, so kernel-push streams and frontend-originated control both +// use this channel rather than a category Attach RPC. package kernelv1 @@ -34,8 +38,7 @@ var File_pluggableharness_kernel_v1_service_proto protoreflect.FileDescriptor const file_pluggableharness_kernel_v1_service_proto_rawDesc = "" + "\n" + - "(pluggableharness/kernel/v1/service.proto\x12\x1apluggableharness.kernel.v1\x1a'pluggableharness/kernel/v1/events.proto\x1a,pluggableharness/kernel/v1/rpc_request.proto\x1a-pluggableharness/kernel/v1/rpc_response.proto2\x85\n" + - "\n" + + "(pluggableharness/kernel/v1/service.proto\x12\x1apluggableharness.kernel.v1\x1a'pluggableharness/kernel/v1/events.proto\x1a,pluggableharness/kernel/v1/rpc_request.proto\x1a-pluggableharness/kernel/v1/rpc_response.proto2\xe9\x18\n" + "\x15KernelCallbackService\x12i\n" + "\n" + "RunSession\x12-.pluggableharness.kernel.v1.RunSessionRequest\x1a,.pluggableharness.kernel.v1.RunSessionResult\x12l\n" + @@ -51,33 +54,81 @@ const file_pluggableharness_kernel_v1_service_proto_rawDesc = "" + "\n" + "ReadEvents\x12-.pluggableharness.kernel.v1.ReadEventsRequest\x1a'.pluggableharness.kernel.v1.StoredEvent0\x01\x12i\n" + "\n" + - "GetSession\x12-.pluggableharness.kernel.v1.GetSessionRequest\x1a,.pluggableharness.kernel.v1.GetSessionResultB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" + "GetSession\x12-.pluggableharness.kernel.v1.GetSessionRequest\x1a,.pluggableharness.kernel.v1.GetSessionResult\x12x\n" + + "\x0fGetSessionState\x122.pluggableharness.kernel.v1.GetSessionStateRequest\x1a1.pluggableharness.kernel.v1.GetSessionStateResult\x12l\n" + + "\vSubmitInput\x12..pluggableharness.kernel.v1.SubmitInputRequest\x1a-.pluggableharness.kernel.v1.SubmitInputResult\x12\x84\x01\n" + + "\x13ResolvePlanDecision\x126.pluggableharness.kernel.v1.ResolvePlanDecisionRequest\x1a5.pluggableharness.kernel.v1.ResolvePlanDecisionResult\x12\x81\x01\n" + + "\x12ResolveInteractive\x125.pluggableharness.kernel.v1.ResolveInteractiveRequest\x1a4.pluggableharness.kernel.v1.ResolveInteractiveResult\x12f\n" + + "\tInterrupt\x12,.pluggableharness.kernel.v1.InterruptRequest\x1a+.pluggableharness.kernel.v1.InterruptResult\x12r\n" + + "\rCreateSession\x120.pluggableharness.kernel.v1.CreateSessionRequest\x1a/.pluggableharness.kernel.v1.CreateSessionResult\x12r\n" + + "\rAttachSession\x120.pluggableharness.kernel.v1.AttachSessionRequest\x1a/.pluggableharness.kernel.v1.AttachSessionResult\x12r\n" + + "\rResumeSession\x120.pluggableharness.kernel.v1.ResumeSessionRequest\x1a/.pluggableharness.kernel.v1.ResumeSessionResult\x12r\n" + + "\rDetachSession\x120.pluggableharness.kernel.v1.DetachSessionRequest\x1a/.pluggableharness.kernel.v1.DetachSessionResult\x12o\n" + + "\fListSessions\x12/.pluggableharness.kernel.v1.ListSessionsRequest\x1a..pluggableharness.kernel.v1.ListSessionsResult\x12x\n" + + "\x0fPublishMetadata\x122.pluggableharness.kernel.v1.PublishMetadataRequest\x1a1.pluggableharness.kernel.v1.PublishMetadataResult\x12x\n" + + "\x0fRetractMetadata\x122.pluggableharness.kernel.v1.RetractMetadataRequest\x1a1.pluggableharness.kernel.v1.RetractMetadataResult\x12o\n" + + "\fListMetadata\x12/.pluggableharness.kernel.v1.ListMetadataRequest\x1a..pluggableharness.kernel.v1.ListMetadataResult\x12i\n" + + "\fStreamDeltas\x12/.pluggableharness.kernel.v1.StreamDeltasRequest\x1a&.pluggableharness.kernel.v1.TokenDelta0\x01\x12\x81\x01\n" + + "\x12InvokeSlashCommand\x125.pluggableharness.kernel.v1.InvokeSlashCommandRequest\x1a4.pluggableharness.kernel.v1.InvokeSlashCommandResult\x12r\n" + + "\rTriggerAction\x120.pluggableharness.kernel.v1.TriggerActionRequest\x1a/.pluggableharness.kernel.v1.TriggerActionResultB@Z>github.com/pluggableharness/agent/pkg/kernel/proto/v1;kernelv1b\x06proto3" var file_pluggableharness_kernel_v1_service_proto_goTypes = []any{ - (*RunSessionRequest)(nil), // 0: pluggableharness.kernel.v1.RunSessionRequest - (*CountTokensRequest)(nil), // 1: pluggableharness.kernel.v1.CountTokensRequest - (*EmitRequest)(nil), // 2: pluggableharness.kernel.v1.EmitRequest - (*LogRequest)(nil), // 3: pluggableharness.kernel.v1.LogRequest - (*ExportSpansRequest)(nil), // 4: pluggableharness.kernel.v1.ExportSpansRequest - (*RecordMetricsRequest)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsRequest - (*GetTelemetryConfigRequest)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigRequest - (*GetConfigRequest)(nil), // 7: pluggableharness.kernel.v1.GetConfigRequest - (*PublishRequest)(nil), // 8: pluggableharness.kernel.v1.PublishRequest - (*SubscribeRequest)(nil), // 9: pluggableharness.kernel.v1.SubscribeRequest - (*ReadEventsRequest)(nil), // 10: pluggableharness.kernel.v1.ReadEventsRequest - (*GetSessionRequest)(nil), // 11: pluggableharness.kernel.v1.GetSessionRequest - (*RunSessionResult)(nil), // 12: pluggableharness.kernel.v1.RunSessionResult - (*CountTokensResult)(nil), // 13: pluggableharness.kernel.v1.CountTokensResult - (*EmitResult)(nil), // 14: pluggableharness.kernel.v1.EmitResult - (*LogResult)(nil), // 15: pluggableharness.kernel.v1.LogResult - (*ExportSpansResult)(nil), // 16: pluggableharness.kernel.v1.ExportSpansResult - (*RecordMetricsResult)(nil), // 17: pluggableharness.kernel.v1.RecordMetricsResult - (*GetTelemetryConfigResult)(nil), // 18: pluggableharness.kernel.v1.GetTelemetryConfigResult - (*GetConfigResult)(nil), // 19: pluggableharness.kernel.v1.GetConfigResult - (*PublishResult)(nil), // 20: pluggableharness.kernel.v1.PublishResult - (*BusEvent)(nil), // 21: pluggableharness.kernel.v1.BusEvent - (*StoredEvent)(nil), // 22: pluggableharness.kernel.v1.StoredEvent - (*GetSessionResult)(nil), // 23: pluggableharness.kernel.v1.GetSessionResult + (*RunSessionRequest)(nil), // 0: pluggableharness.kernel.v1.RunSessionRequest + (*CountTokensRequest)(nil), // 1: pluggableharness.kernel.v1.CountTokensRequest + (*EmitRequest)(nil), // 2: pluggableharness.kernel.v1.EmitRequest + (*LogRequest)(nil), // 3: pluggableharness.kernel.v1.LogRequest + (*ExportSpansRequest)(nil), // 4: pluggableharness.kernel.v1.ExportSpansRequest + (*RecordMetricsRequest)(nil), // 5: pluggableharness.kernel.v1.RecordMetricsRequest + (*GetTelemetryConfigRequest)(nil), // 6: pluggableharness.kernel.v1.GetTelemetryConfigRequest + (*GetConfigRequest)(nil), // 7: pluggableharness.kernel.v1.GetConfigRequest + (*PublishRequest)(nil), // 8: pluggableharness.kernel.v1.PublishRequest + (*SubscribeRequest)(nil), // 9: pluggableharness.kernel.v1.SubscribeRequest + (*ReadEventsRequest)(nil), // 10: pluggableharness.kernel.v1.ReadEventsRequest + (*GetSessionRequest)(nil), // 11: pluggableharness.kernel.v1.GetSessionRequest + (*GetSessionStateRequest)(nil), // 12: pluggableharness.kernel.v1.GetSessionStateRequest + (*SubmitInputRequest)(nil), // 13: pluggableharness.kernel.v1.SubmitInputRequest + (*ResolvePlanDecisionRequest)(nil), // 14: pluggableharness.kernel.v1.ResolvePlanDecisionRequest + (*ResolveInteractiveRequest)(nil), // 15: pluggableharness.kernel.v1.ResolveInteractiveRequest + (*InterruptRequest)(nil), // 16: pluggableharness.kernel.v1.InterruptRequest + (*CreateSessionRequest)(nil), // 17: pluggableharness.kernel.v1.CreateSessionRequest + (*AttachSessionRequest)(nil), // 18: pluggableharness.kernel.v1.AttachSessionRequest + (*ResumeSessionRequest)(nil), // 19: pluggableharness.kernel.v1.ResumeSessionRequest + (*DetachSessionRequest)(nil), // 20: pluggableharness.kernel.v1.DetachSessionRequest + (*ListSessionsRequest)(nil), // 21: pluggableharness.kernel.v1.ListSessionsRequest + (*PublishMetadataRequest)(nil), // 22: pluggableharness.kernel.v1.PublishMetadataRequest + (*RetractMetadataRequest)(nil), // 23: pluggableharness.kernel.v1.RetractMetadataRequest + (*ListMetadataRequest)(nil), // 24: pluggableharness.kernel.v1.ListMetadataRequest + (*StreamDeltasRequest)(nil), // 25: pluggableharness.kernel.v1.StreamDeltasRequest + (*InvokeSlashCommandRequest)(nil), // 26: pluggableharness.kernel.v1.InvokeSlashCommandRequest + (*TriggerActionRequest)(nil), // 27: pluggableharness.kernel.v1.TriggerActionRequest + (*RunSessionResult)(nil), // 28: pluggableharness.kernel.v1.RunSessionResult + (*CountTokensResult)(nil), // 29: pluggableharness.kernel.v1.CountTokensResult + (*EmitResult)(nil), // 30: pluggableharness.kernel.v1.EmitResult + (*LogResult)(nil), // 31: pluggableharness.kernel.v1.LogResult + (*ExportSpansResult)(nil), // 32: pluggableharness.kernel.v1.ExportSpansResult + (*RecordMetricsResult)(nil), // 33: pluggableharness.kernel.v1.RecordMetricsResult + (*GetTelemetryConfigResult)(nil), // 34: pluggableharness.kernel.v1.GetTelemetryConfigResult + (*GetConfigResult)(nil), // 35: pluggableharness.kernel.v1.GetConfigResult + (*PublishResult)(nil), // 36: pluggableharness.kernel.v1.PublishResult + (*BusEvent)(nil), // 37: pluggableharness.kernel.v1.BusEvent + (*StoredEvent)(nil), // 38: pluggableharness.kernel.v1.StoredEvent + (*GetSessionResult)(nil), // 39: pluggableharness.kernel.v1.GetSessionResult + (*GetSessionStateResult)(nil), // 40: pluggableharness.kernel.v1.GetSessionStateResult + (*SubmitInputResult)(nil), // 41: pluggableharness.kernel.v1.SubmitInputResult + (*ResolvePlanDecisionResult)(nil), // 42: pluggableharness.kernel.v1.ResolvePlanDecisionResult + (*ResolveInteractiveResult)(nil), // 43: pluggableharness.kernel.v1.ResolveInteractiveResult + (*InterruptResult)(nil), // 44: pluggableharness.kernel.v1.InterruptResult + (*CreateSessionResult)(nil), // 45: pluggableharness.kernel.v1.CreateSessionResult + (*AttachSessionResult)(nil), // 46: pluggableharness.kernel.v1.AttachSessionResult + (*ResumeSessionResult)(nil), // 47: pluggableharness.kernel.v1.ResumeSessionResult + (*DetachSessionResult)(nil), // 48: pluggableharness.kernel.v1.DetachSessionResult + (*ListSessionsResult)(nil), // 49: pluggableharness.kernel.v1.ListSessionsResult + (*PublishMetadataResult)(nil), // 50: pluggableharness.kernel.v1.PublishMetadataResult + (*RetractMetadataResult)(nil), // 51: pluggableharness.kernel.v1.RetractMetadataResult + (*ListMetadataResult)(nil), // 52: pluggableharness.kernel.v1.ListMetadataResult + (*TokenDelta)(nil), // 53: pluggableharness.kernel.v1.TokenDelta + (*InvokeSlashCommandResult)(nil), // 54: pluggableharness.kernel.v1.InvokeSlashCommandResult + (*TriggerActionResult)(nil), // 55: pluggableharness.kernel.v1.TriggerActionResult } var file_pluggableharness_kernel_v1_service_proto_depIdxs = []int32{ 0, // 0: pluggableharness.kernel.v1.KernelCallbackService.RunSession:input_type -> pluggableharness.kernel.v1.RunSessionRequest @@ -92,20 +143,52 @@ var file_pluggableharness_kernel_v1_service_proto_depIdxs = []int32{ 9, // 9: pluggableharness.kernel.v1.KernelCallbackService.Subscribe:input_type -> pluggableharness.kernel.v1.SubscribeRequest 10, // 10: pluggableharness.kernel.v1.KernelCallbackService.ReadEvents:input_type -> pluggableharness.kernel.v1.ReadEventsRequest 11, // 11: pluggableharness.kernel.v1.KernelCallbackService.GetSession:input_type -> pluggableharness.kernel.v1.GetSessionRequest - 12, // 12: pluggableharness.kernel.v1.KernelCallbackService.RunSession:output_type -> pluggableharness.kernel.v1.RunSessionResult - 13, // 13: pluggableharness.kernel.v1.KernelCallbackService.CountTokens:output_type -> pluggableharness.kernel.v1.CountTokensResult - 14, // 14: pluggableharness.kernel.v1.KernelCallbackService.Emit:output_type -> pluggableharness.kernel.v1.EmitResult - 15, // 15: pluggableharness.kernel.v1.KernelCallbackService.Log:output_type -> pluggableharness.kernel.v1.LogResult - 16, // 16: pluggableharness.kernel.v1.KernelCallbackService.ExportSpans:output_type -> pluggableharness.kernel.v1.ExportSpansResult - 17, // 17: pluggableharness.kernel.v1.KernelCallbackService.RecordMetrics:output_type -> pluggableharness.kernel.v1.RecordMetricsResult - 18, // 18: pluggableharness.kernel.v1.KernelCallbackService.GetTelemetryConfig:output_type -> pluggableharness.kernel.v1.GetTelemetryConfigResult - 19, // 19: pluggableharness.kernel.v1.KernelCallbackService.GetConfig:output_type -> pluggableharness.kernel.v1.GetConfigResult - 20, // 20: pluggableharness.kernel.v1.KernelCallbackService.Publish:output_type -> pluggableharness.kernel.v1.PublishResult - 21, // 21: pluggableharness.kernel.v1.KernelCallbackService.Subscribe:output_type -> pluggableharness.kernel.v1.BusEvent - 22, // 22: pluggableharness.kernel.v1.KernelCallbackService.ReadEvents:output_type -> pluggableharness.kernel.v1.StoredEvent - 23, // 23: pluggableharness.kernel.v1.KernelCallbackService.GetSession:output_type -> pluggableharness.kernel.v1.GetSessionResult - 12, // [12:24] is the sub-list for method output_type - 0, // [0:12] is the sub-list for method input_type + 12, // 12: pluggableharness.kernel.v1.KernelCallbackService.GetSessionState:input_type -> pluggableharness.kernel.v1.GetSessionStateRequest + 13, // 13: pluggableharness.kernel.v1.KernelCallbackService.SubmitInput:input_type -> pluggableharness.kernel.v1.SubmitInputRequest + 14, // 14: pluggableharness.kernel.v1.KernelCallbackService.ResolvePlanDecision:input_type -> pluggableharness.kernel.v1.ResolvePlanDecisionRequest + 15, // 15: pluggableharness.kernel.v1.KernelCallbackService.ResolveInteractive:input_type -> pluggableharness.kernel.v1.ResolveInteractiveRequest + 16, // 16: pluggableharness.kernel.v1.KernelCallbackService.Interrupt:input_type -> pluggableharness.kernel.v1.InterruptRequest + 17, // 17: pluggableharness.kernel.v1.KernelCallbackService.CreateSession:input_type -> pluggableharness.kernel.v1.CreateSessionRequest + 18, // 18: pluggableharness.kernel.v1.KernelCallbackService.AttachSession:input_type -> pluggableharness.kernel.v1.AttachSessionRequest + 19, // 19: pluggableharness.kernel.v1.KernelCallbackService.ResumeSession:input_type -> pluggableharness.kernel.v1.ResumeSessionRequest + 20, // 20: pluggableharness.kernel.v1.KernelCallbackService.DetachSession:input_type -> pluggableharness.kernel.v1.DetachSessionRequest + 21, // 21: pluggableharness.kernel.v1.KernelCallbackService.ListSessions:input_type -> pluggableharness.kernel.v1.ListSessionsRequest + 22, // 22: pluggableharness.kernel.v1.KernelCallbackService.PublishMetadata:input_type -> pluggableharness.kernel.v1.PublishMetadataRequest + 23, // 23: pluggableharness.kernel.v1.KernelCallbackService.RetractMetadata:input_type -> pluggableharness.kernel.v1.RetractMetadataRequest + 24, // 24: pluggableharness.kernel.v1.KernelCallbackService.ListMetadata:input_type -> pluggableharness.kernel.v1.ListMetadataRequest + 25, // 25: pluggableharness.kernel.v1.KernelCallbackService.StreamDeltas:input_type -> pluggableharness.kernel.v1.StreamDeltasRequest + 26, // 26: pluggableharness.kernel.v1.KernelCallbackService.InvokeSlashCommand:input_type -> pluggableharness.kernel.v1.InvokeSlashCommandRequest + 27, // 27: pluggableharness.kernel.v1.KernelCallbackService.TriggerAction:input_type -> pluggableharness.kernel.v1.TriggerActionRequest + 28, // 28: pluggableharness.kernel.v1.KernelCallbackService.RunSession:output_type -> pluggableharness.kernel.v1.RunSessionResult + 29, // 29: pluggableharness.kernel.v1.KernelCallbackService.CountTokens:output_type -> pluggableharness.kernel.v1.CountTokensResult + 30, // 30: pluggableharness.kernel.v1.KernelCallbackService.Emit:output_type -> pluggableharness.kernel.v1.EmitResult + 31, // 31: pluggableharness.kernel.v1.KernelCallbackService.Log:output_type -> pluggableharness.kernel.v1.LogResult + 32, // 32: pluggableharness.kernel.v1.KernelCallbackService.ExportSpans:output_type -> pluggableharness.kernel.v1.ExportSpansResult + 33, // 33: pluggableharness.kernel.v1.KernelCallbackService.RecordMetrics:output_type -> pluggableharness.kernel.v1.RecordMetricsResult + 34, // 34: pluggableharness.kernel.v1.KernelCallbackService.GetTelemetryConfig:output_type -> pluggableharness.kernel.v1.GetTelemetryConfigResult + 35, // 35: pluggableharness.kernel.v1.KernelCallbackService.GetConfig:output_type -> pluggableharness.kernel.v1.GetConfigResult + 36, // 36: pluggableharness.kernel.v1.KernelCallbackService.Publish:output_type -> pluggableharness.kernel.v1.PublishResult + 37, // 37: pluggableharness.kernel.v1.KernelCallbackService.Subscribe:output_type -> pluggableharness.kernel.v1.BusEvent + 38, // 38: pluggableharness.kernel.v1.KernelCallbackService.ReadEvents:output_type -> pluggableharness.kernel.v1.StoredEvent + 39, // 39: pluggableharness.kernel.v1.KernelCallbackService.GetSession:output_type -> pluggableharness.kernel.v1.GetSessionResult + 40, // 40: pluggableharness.kernel.v1.KernelCallbackService.GetSessionState:output_type -> pluggableharness.kernel.v1.GetSessionStateResult + 41, // 41: pluggableharness.kernel.v1.KernelCallbackService.SubmitInput:output_type -> pluggableharness.kernel.v1.SubmitInputResult + 42, // 42: pluggableharness.kernel.v1.KernelCallbackService.ResolvePlanDecision:output_type -> pluggableharness.kernel.v1.ResolvePlanDecisionResult + 43, // 43: pluggableharness.kernel.v1.KernelCallbackService.ResolveInteractive:output_type -> pluggableharness.kernel.v1.ResolveInteractiveResult + 44, // 44: pluggableharness.kernel.v1.KernelCallbackService.Interrupt:output_type -> pluggableharness.kernel.v1.InterruptResult + 45, // 45: pluggableharness.kernel.v1.KernelCallbackService.CreateSession:output_type -> pluggableharness.kernel.v1.CreateSessionResult + 46, // 46: pluggableharness.kernel.v1.KernelCallbackService.AttachSession:output_type -> pluggableharness.kernel.v1.AttachSessionResult + 47, // 47: pluggableharness.kernel.v1.KernelCallbackService.ResumeSession:output_type -> pluggableharness.kernel.v1.ResumeSessionResult + 48, // 48: pluggableharness.kernel.v1.KernelCallbackService.DetachSession:output_type -> pluggableharness.kernel.v1.DetachSessionResult + 49, // 49: pluggableharness.kernel.v1.KernelCallbackService.ListSessions:output_type -> pluggableharness.kernel.v1.ListSessionsResult + 50, // 50: pluggableharness.kernel.v1.KernelCallbackService.PublishMetadata:output_type -> pluggableharness.kernel.v1.PublishMetadataResult + 51, // 51: pluggableharness.kernel.v1.KernelCallbackService.RetractMetadata:output_type -> pluggableharness.kernel.v1.RetractMetadataResult + 52, // 52: pluggableharness.kernel.v1.KernelCallbackService.ListMetadata:output_type -> pluggableharness.kernel.v1.ListMetadataResult + 53, // 53: pluggableharness.kernel.v1.KernelCallbackService.StreamDeltas:output_type -> pluggableharness.kernel.v1.TokenDelta + 54, // 54: pluggableharness.kernel.v1.KernelCallbackService.InvokeSlashCommand:output_type -> pluggableharness.kernel.v1.InvokeSlashCommandResult + 55, // 55: pluggableharness.kernel.v1.KernelCallbackService.TriggerAction:output_type -> pluggableharness.kernel.v1.TriggerActionResult + 28, // [28:56] is the sub-list for method output_type + 0, // [0:28] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/pkg/kernel/proto/v1/service_grpc.pb.go b/pkg/kernel/proto/v1/service_grpc.pb.go index 7f3e044..87935c1 100644 --- a/pkg/kernel/proto/v1/service_grpc.pb.go +++ b/pkg/kernel/proto/v1/service_grpc.pb.go @@ -4,15 +4,19 @@ // - protoc (unknown) // source: pluggableharness/kernel/v1/service.proto -// Package pluggableharness.kernel.v1 defines the kernel-callback service described -// in specifications/kernel-callbacks.md (RunSession, CountTokens, Emit, -// Log, ExportSpans, RecordMetrics, GetTelemetryConfig, GetConfig, Publish, -// Subscribe, ReadEvents, GetSession) — the plugin-to-kernel calling -// direction every plugin category gets at handshake, the reverse of every -// other category's protocol in this series. Unlike a category plugin -// protocol, this service carries no GetCapabilities/Configure RPCs: it -// isn't something the kernel dials into a plugin, it's the connection -// every plugin subprocess is handed back to call into the kernel. +// Package pluggableharness.kernel.v1 defines the kernel-callback service +// described in specifications/kernel-callbacks.md — the plugin-to-kernel +// calling direction every plugin category gets at handshake, the reverse +// of every other category's protocol in this series. Unlike a category +// plugin protocol, this service carries no GetCapabilities/Configure +// RPCs: it is not something the kernel dials into a plugin, it is the +// connection every plugin subprocess is handed back to call into the +// kernel. +// +// Frontend state surfaces (input, state, metadata, transcript backfill, +// token deltas) also live here: under go-plugin the plugin is the gRPC +// server, so kernel-push streams and frontend-originated control both +// use this channel rather than a category Attach RPC. package kernelv1 @@ -29,18 +33,34 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - KernelCallbackService_RunSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/RunSession" - KernelCallbackService_CountTokens_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/CountTokens" - KernelCallbackService_Emit_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Emit" - KernelCallbackService_Log_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Log" - KernelCallbackService_ExportSpans_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ExportSpans" - KernelCallbackService_RecordMetrics_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/RecordMetrics" - KernelCallbackService_GetTelemetryConfig_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetTelemetryConfig" - KernelCallbackService_GetConfig_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetConfig" - KernelCallbackService_Publish_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Publish" - KernelCallbackService_Subscribe_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Subscribe" - KernelCallbackService_ReadEvents_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ReadEvents" - KernelCallbackService_GetSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetSession" + KernelCallbackService_RunSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/RunSession" + KernelCallbackService_CountTokens_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/CountTokens" + KernelCallbackService_Emit_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Emit" + KernelCallbackService_Log_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Log" + KernelCallbackService_ExportSpans_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ExportSpans" + KernelCallbackService_RecordMetrics_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/RecordMetrics" + KernelCallbackService_GetTelemetryConfig_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetTelemetryConfig" + KernelCallbackService_GetConfig_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetConfig" + KernelCallbackService_Publish_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Publish" + KernelCallbackService_Subscribe_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Subscribe" + KernelCallbackService_ReadEvents_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ReadEvents" + KernelCallbackService_GetSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetSession" + KernelCallbackService_GetSessionState_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/GetSessionState" + KernelCallbackService_SubmitInput_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/SubmitInput" + KernelCallbackService_ResolvePlanDecision_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ResolvePlanDecision" + KernelCallbackService_ResolveInteractive_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ResolveInteractive" + KernelCallbackService_Interrupt_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/Interrupt" + KernelCallbackService_CreateSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/CreateSession" + KernelCallbackService_AttachSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/AttachSession" + KernelCallbackService_ResumeSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ResumeSession" + KernelCallbackService_DetachSession_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/DetachSession" + KernelCallbackService_ListSessions_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ListSessions" + KernelCallbackService_PublishMetadata_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/PublishMetadata" + KernelCallbackService_RetractMetadata_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/RetractMetadata" + KernelCallbackService_ListMetadata_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/ListMetadata" + KernelCallbackService_StreamDeltas_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/StreamDeltas" + KernelCallbackService_InvokeSlashCommand_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/InvokeSlashCommand" + KernelCallbackService_TriggerAction_FullMethodName = "/pluggableharness.kernel.v1.KernelCallbackService/TriggerAction" ) // KernelCallbackServiceClient is the client API for KernelCallbackService service. @@ -48,126 +68,145 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // // KernelCallbackService is the plugin-to-kernel callback channel described -// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively +// in specifications/kernel-callbacks.md. hashicorp/go-plugin natively // supports bidirectional plugins, and this is that mechanism: every plugin -// subprocess, for every category defined across this series, MUST be given -// a client connection to this service at handshake time, unconditionally. -// A plugin that never calls back simply never uses it, but the channel's -// presence is not gated on category — a context provider needing -// CountTokens is just as valid a caller as a tool provider needing -// RunSession. +// subprocess, for every category, MUST be given a client connection to +// this service at handshake time, unconditionally. +// +// Application RPCs are unary or server-streaming. The connection itself is +// bidirectional at the transport layer; that is the only genuinely +// bidirectional surface in the protocol series (there is no category +// Attach stream). type KernelCallbackServiceClient interface { // RunSession dispatches a nested sub-agent session under a named - // agent.hcl profile. Full semantics — profile resolution, budget - // inheritance, visibility of intermediate turns — are defined in - // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 - // gives this RPC's calling contract. + // agent.hcl profile. Full semantics live in agent-loop/subagents.md. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 - // uses in its own data-type definition. Not a uniqueness violation: - // RunSessionResult is used by exactly this one RPC. RunSession(ctx context.Context, in *RunSessionRequest, opts ...grpc.CallOption) (*RunSessionResult, error) - // CountTokens resolves the token-counting gap independently flagged by - // context.md §12, configuration.md §12, memory.md §13, and frontend.md - // §10: exactly one kernel-owned implementation, so that `tokens` figures - // produced by different providers stay mutually comparable and additive - // for configuration.md §6's budget-sum arithmetic. See - // kernel-callbacks.md §2 for the resolution algorithm and §3 for the - // single documented fallback formula. + // CountTokens resolves the token-counting gap: exactly one kernel-owned + // implementation so tokens figures stay mutually comparable. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "CountTokensResult", the exact name kernel-callbacks.md - // §2 uses. Not a uniqueness violation: used by exactly this one RPC. CountTokens(ctx context.Context, in *CountTokensRequest, opts ...grpc.CallOption) (*CountTokensResult, error) // Emit is how a plugin persists anything into the session's state - // backend. The kernel is the state backend's sole writer - // (state-backend.md §3) — a plugin never opens or writes the sqlite file - // directly; it calls Emit and the kernel performs the actual write, - // assigning the ordering-authoritative sequence number and the stable - // event id itself. See kernel-callbacks.md §4. + // backend. The kernel is the sole writer. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "EmitResult", the exact name kernel-callbacks.md §4 - // uses. Not a uniqueness violation: used by exactly this one RPC. Emit(ctx context.Context, in *EmitRequest, opts ...grpc.CallOption) (*EmitResult, error) // Log carries a plugin's own log output into the kernel's centralized - // logging, so it doesn't vanish into an unread subprocess stderr. - // Unlike Emit, a Log call is not tied to an active session — a plugin - // MAY call Log before any session exists (process startup, or from - // within Configure) or after one has ended (during shutdown). See - // kernel-callbacks.md §5. + // logging. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "LogResult", the exact name kernel-callbacks.md §5 - // uses. Not a uniqueness violation: used by exactly this one RPC. Log(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*LogResult, error) - // ExportSpans relays a batch of a plugin's own completed trace spans to - // the kernel, which forwards them to the operator's configured - // collector essentially unchanged. This reverses an earlier - // direct-per-process-OTLP-export design — see - // specifications/observability.md#the-relay-model for why. + // ExportSpans relays a batch of a plugin's own completed trace spans. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "ExportSpansResult", used by exactly this one RPC. ExportSpans(ctx context.Context, in *ExportSpansRequest, opts ...grpc.CallOption) (*ExportSpansResult, error) - // RecordMetrics relays a batch of metric observations. Unlike - // ExportSpans, this is not a transparent relay: the kernel records each - // observation against its own instrument and bounds the attribute key - // set before it reaches any exporter. See - // specifications/observability.md#the-tracing-metrics-asymmetry. + // RecordMetrics relays a batch of metric observations (bounded + // attributes; not a transparent relay). // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RecordMetricsResult", used by exactly this one RPC. RecordMetrics(ctx context.Context, in *RecordMetricsRequest, opts ...grpc.CallOption) (*RecordMetricsResult, error) - // GetTelemetryConfig answers whether tracing/metrics/logs are enabled - // and at what level/ratio, so a plugin doesn't have to guess from its - // own environment. A plugin SHOULD call this once at startup and cache - // the result — see specifications/observability.md#gettelemetryconfig-caching. + // GetTelemetryConfig answers whether tracing/metrics/logs are enabled. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetTelemetryConfigResult", used by exactly this one - // RPC. GetTelemetryConfig(ctx context.Context, in *GetTelemetryConfigRequest, opts ...grpc.CallOption) (*GetTelemetryConfigResult, error) // GetConfig returns the calling plugin's own already-decoded agent.hcl - // configuration — the same shape Configure received. See - // kernel-callbacks.md's GetConfig for the secret-echo MUST NOT rule this - // implies. + // configuration. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetConfigResult", used by exactly this one RPC. GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResult, error) - // Publish emits one event onto the ephemeral, best-effort, cross-plugin - // event bus, distinct from Emit's durable per-session log and from - // hook dispatch's synchronous, agent.hcl-declared subscriber chain. See - // specifications/event-bus.md. + // Publish emits one event onto the ephemeral event bus. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "PublishResult", used by exactly this one RPC. Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResult, error) - // Subscribe opens a server-streaming subscription to the event bus, - // filtered by topic. See specifications/event-bus.md#filter-grammar and - // #backpressure for why the kernel may unilaterally close this stream. + // Subscribe opens a server-streaming subscription to the event bus. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is "BusEvent", naming the streamed domain concept - // rather than the RPC, the same convention model.md §4's StreamEvent - // and widget.md's WidgetUpdate already use. Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BusEvent], error) // ReadEvents reads back the calling plugin's own session's persisted - // event log, ordered by sequence — never by wall-clock time - // (.claude/rules/determinism.md). + // event log, ordered by sequence. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is "StoredEvent", the same "name the domain - // concept" convention as Subscribe's BusEvent above. ReadEvents(ctx context.Context, in *ReadEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StoredEvent], error) // GetSession returns the calling plugin's own session's metadata plus - // its live, in-memory budget rollups. + // its live budget rollups. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetSessionResult", used by exactly this one RPC. GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResult, error) + // GetSessionState returns the fixed-schema "where am I" snapshot for + // one session. Pair with Subscribe on topic kernel.state for deltas. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + GetSessionState(ctx context.Context, in *GetSessionStateRequest, opts ...grpc.CallOption) (*GetSessionStateResult, error) + // SubmitInput submits operator input as the next turn. Returns the + // assigned turn_id for correlation. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + SubmitInput(ctx context.Context, in *SubmitInputRequest, opts ...grpc.CallOption) (*SubmitInputResult, error) + // ResolvePlanDecision answers a pending plan item (policy ASK). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ResolvePlanDecision(ctx context.Context, in *ResolvePlanDecisionRequest, opts ...grpc.CallOption) (*ResolvePlanDecisionResult, error) + // ResolveInteractive answers a pending interactive-kind tool call. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ResolveInteractive(ctx context.Context, in *ResolveInteractiveRequest, opts ...grpc.CallOption) (*ResolveInteractiveResult, error) + // Interrupt cancels the running turn for a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + Interrupt(ctx context.Context, in *InterruptRequest, opts ...grpc.CallOption) (*InterruptResult, error) + // CreateSession creates a new session and auto-attaches the caller. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + CreateSession(ctx context.Context, in *CreateSessionRequest, opts ...grpc.CallOption) (*CreateSessionResult, error) + // AttachSession subscribes the caller to an existing session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + AttachSession(ctx context.Context, in *AttachSessionRequest, opts ...grpc.CallOption) (*AttachSessionResult, error) + // ResumeSession attaches a historical session for continuation or + // replay-only. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ResumeSession(ctx context.Context, in *ResumeSessionRequest, opts ...grpc.CallOption) (*ResumeSessionResult, error) + // DetachSession unsubscribes the caller from a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + DetachSession(ctx context.Context, in *DetachSessionRequest, opts ...grpc.CallOption) (*DetachSessionResult, error) + // ListSessions returns a filtered session summary list. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResult, error) + // PublishMetadata upserts a MetadataBlock (producer and liveness + // server-stamped). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + PublishMetadata(ctx context.Context, in *PublishMetadataRequest, opts ...grpc.CallOption) (*PublishMetadataResult, error) + // RetractMetadata flips a block to DISCONNECTED and republishes; never + // deletes. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + RetractMetadata(ctx context.Context, in *RetractMetadataRequest, opts ...grpc.CallOption) (*RetractMetadataResult, error) + // ListMetadata returns every known MetadataBlock for a session + // (snapshot half of snapshot-then-subscribe). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ListMetadata(ctx context.Context, in *ListMetadataRequest, opts ...grpc.CallOption) (*ListMetadataResult, error) + // StreamDeltas is the live-only token fast path: server-streaming on + // this channel, out-of-band with respect to the event bus. The kernel + // does not batch; frontends coalesce to their own refresh. + // + // buf:lint:ignore RPC_REQUEST_STANDARD_NAME + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + StreamDeltas(ctx context.Context, in *StreamDeltasRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TokenDelta], error) + // InvokeSlashCommand dispatches a slash command against a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + InvokeSlashCommand(ctx context.Context, in *InvokeSlashCommandRequest, opts ...grpc.CallOption) (*InvokeSlashCommandResult, error) + // TriggerAction dispatches an ActionNode activation (no model turn). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + TriggerAction(ctx context.Context, in *TriggerActionRequest, opts ...grpc.CallOption) (*TriggerActionResult, error) } type kernelCallbackServiceClient struct { @@ -316,131 +355,319 @@ func (c *kernelCallbackServiceClient) GetSession(ctx context.Context, in *GetSes return out, nil } +func (c *kernelCallbackServiceClient) GetSessionState(ctx context.Context, in *GetSessionStateRequest, opts ...grpc.CallOption) (*GetSessionStateResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSessionStateResult) + err := c.cc.Invoke(ctx, KernelCallbackService_GetSessionState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) SubmitInput(ctx context.Context, in *SubmitInputRequest, opts ...grpc.CallOption) (*SubmitInputResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SubmitInputResult) + err := c.cc.Invoke(ctx, KernelCallbackService_SubmitInput_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) ResolvePlanDecision(ctx context.Context, in *ResolvePlanDecisionRequest, opts ...grpc.CallOption) (*ResolvePlanDecisionResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResolvePlanDecisionResult) + err := c.cc.Invoke(ctx, KernelCallbackService_ResolvePlanDecision_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) ResolveInteractive(ctx context.Context, in *ResolveInteractiveRequest, opts ...grpc.CallOption) (*ResolveInteractiveResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResolveInteractiveResult) + err := c.cc.Invoke(ctx, KernelCallbackService_ResolveInteractive_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) Interrupt(ctx context.Context, in *InterruptRequest, opts ...grpc.CallOption) (*InterruptResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InterruptResult) + err := c.cc.Invoke(ctx, KernelCallbackService_Interrupt_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) CreateSession(ctx context.Context, in *CreateSessionRequest, opts ...grpc.CallOption) (*CreateSessionResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateSessionResult) + err := c.cc.Invoke(ctx, KernelCallbackService_CreateSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) AttachSession(ctx context.Context, in *AttachSessionRequest, opts ...grpc.CallOption) (*AttachSessionResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AttachSessionResult) + err := c.cc.Invoke(ctx, KernelCallbackService_AttachSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) ResumeSession(ctx context.Context, in *ResumeSessionRequest, opts ...grpc.CallOption) (*ResumeSessionResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResumeSessionResult) + err := c.cc.Invoke(ctx, KernelCallbackService_ResumeSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) DetachSession(ctx context.Context, in *DetachSessionRequest, opts ...grpc.CallOption) (*DetachSessionResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DetachSessionResult) + err := c.cc.Invoke(ctx, KernelCallbackService_DetachSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSessionsResult) + err := c.cc.Invoke(ctx, KernelCallbackService_ListSessions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) PublishMetadata(ctx context.Context, in *PublishMetadataRequest, opts ...grpc.CallOption) (*PublishMetadataResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PublishMetadataResult) + err := c.cc.Invoke(ctx, KernelCallbackService_PublishMetadata_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) RetractMetadata(ctx context.Context, in *RetractMetadataRequest, opts ...grpc.CallOption) (*RetractMetadataResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RetractMetadataResult) + err := c.cc.Invoke(ctx, KernelCallbackService_RetractMetadata_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) ListMetadata(ctx context.Context, in *ListMetadataRequest, opts ...grpc.CallOption) (*ListMetadataResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListMetadataResult) + err := c.cc.Invoke(ctx, KernelCallbackService_ListMetadata_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) StreamDeltas(ctx context.Context, in *StreamDeltasRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TokenDelta], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &KernelCallbackService_ServiceDesc.Streams[2], KernelCallbackService_StreamDeltas_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StreamDeltasRequest, TokenDelta]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type KernelCallbackService_StreamDeltasClient = grpc.ServerStreamingClient[TokenDelta] + +func (c *kernelCallbackServiceClient) InvokeSlashCommand(ctx context.Context, in *InvokeSlashCommandRequest, opts ...grpc.CallOption) (*InvokeSlashCommandResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InvokeSlashCommandResult) + err := c.cc.Invoke(ctx, KernelCallbackService_InvokeSlashCommand_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *kernelCallbackServiceClient) TriggerAction(ctx context.Context, in *TriggerActionRequest, opts ...grpc.CallOption) (*TriggerActionResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(TriggerActionResult) + err := c.cc.Invoke(ctx, KernelCallbackService_TriggerAction_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // KernelCallbackServiceServer is the server API for KernelCallbackService service. // All implementations must embed UnimplementedKernelCallbackServiceServer // for forward compatibility. // // KernelCallbackService is the plugin-to-kernel callback channel described -// in specifications/kernel-callbacks.md §1. hashicorp/go-plugin natively +// in specifications/kernel-callbacks.md. hashicorp/go-plugin natively // supports bidirectional plugins, and this is that mechanism: every plugin -// subprocess, for every category defined across this series, MUST be given -// a client connection to this service at handshake time, unconditionally. -// A plugin that never calls back simply never uses it, but the channel's -// presence is not gated on category — a context provider needing -// CountTokens is just as valid a caller as a tool provider needing -// RunSession. +// subprocess, for every category, MUST be given a client connection to +// this service at handshake time, unconditionally. +// +// Application RPCs are unary or server-streaming. The connection itself is +// bidirectional at the transport layer; that is the only genuinely +// bidirectional surface in the protocol series (there is no category +// Attach stream). type KernelCallbackServiceServer interface { // RunSession dispatches a nested sub-agent session under a named - // agent.hcl profile. Full semantics — profile resolution, budget - // inheritance, visibility of intermediate turns — are defined in - // agent-loop.md §7 and are not repeated here; kernel-callbacks.md §1 - // gives this RPC's calling contract. + // agent.hcl profile. Full semantics live in agent-loop/subagents.md. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RunSessionResult", the exact name agent-loop.md §7.1 - // uses in its own data-type definition. Not a uniqueness violation: - // RunSessionResult is used by exactly this one RPC. RunSession(context.Context, *RunSessionRequest) (*RunSessionResult, error) - // CountTokens resolves the token-counting gap independently flagged by - // context.md §12, configuration.md §12, memory.md §13, and frontend.md - // §10: exactly one kernel-owned implementation, so that `tokens` figures - // produced by different providers stay mutually comparable and additive - // for configuration.md §6's budget-sum arithmetic. See - // kernel-callbacks.md §2 for the resolution algorithm and §3 for the - // single documented fallback formula. + // CountTokens resolves the token-counting gap: exactly one kernel-owned + // implementation so tokens figures stay mutually comparable. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "CountTokensResult", the exact name kernel-callbacks.md - // §2 uses. Not a uniqueness violation: used by exactly this one RPC. CountTokens(context.Context, *CountTokensRequest) (*CountTokensResult, error) // Emit is how a plugin persists anything into the session's state - // backend. The kernel is the state backend's sole writer - // (state-backend.md §3) — a plugin never opens or writes the sqlite file - // directly; it calls Emit and the kernel performs the actual write, - // assigning the ordering-authoritative sequence number and the stable - // event id itself. See kernel-callbacks.md §4. + // backend. The kernel is the sole writer. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "EmitResult", the exact name kernel-callbacks.md §4 - // uses. Not a uniqueness violation: used by exactly this one RPC. Emit(context.Context, *EmitRequest) (*EmitResult, error) // Log carries a plugin's own log output into the kernel's centralized - // logging, so it doesn't vanish into an unread subprocess stderr. - // Unlike Emit, a Log call is not tied to an active session — a plugin - // MAY call Log before any session exists (process startup, or from - // within Configure) or after one has ended (during shutdown). See - // kernel-callbacks.md §5. + // logging. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "LogResult", the exact name kernel-callbacks.md §5 - // uses. Not a uniqueness violation: used by exactly this one RPC. Log(context.Context, *LogRequest) (*LogResult, error) - // ExportSpans relays a batch of a plugin's own completed trace spans to - // the kernel, which forwards them to the operator's configured - // collector essentially unchanged. This reverses an earlier - // direct-per-process-OTLP-export design — see - // specifications/observability.md#the-relay-model for why. + // ExportSpans relays a batch of a plugin's own completed trace spans. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "ExportSpansResult", used by exactly this one RPC. ExportSpans(context.Context, *ExportSpansRequest) (*ExportSpansResult, error) - // RecordMetrics relays a batch of metric observations. Unlike - // ExportSpans, this is not a transparent relay: the kernel records each - // observation against its own instrument and bounds the attribute key - // set before it reaches any exporter. See - // specifications/observability.md#the-tracing-metrics-asymmetry. + // RecordMetrics relays a batch of metric observations (bounded + // attributes; not a transparent relay). // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "RecordMetricsResult", used by exactly this one RPC. RecordMetrics(context.Context, *RecordMetricsRequest) (*RecordMetricsResult, error) - // GetTelemetryConfig answers whether tracing/metrics/logs are enabled - // and at what level/ratio, so a plugin doesn't have to guess from its - // own environment. A plugin SHOULD call this once at startup and cache - // the result — see specifications/observability.md#gettelemetryconfig-caching. + // GetTelemetryConfig answers whether tracing/metrics/logs are enabled. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetTelemetryConfigResult", used by exactly this one - // RPC. GetTelemetryConfig(context.Context, *GetTelemetryConfigRequest) (*GetTelemetryConfigResult, error) // GetConfig returns the calling plugin's own already-decoded agent.hcl - // configuration — the same shape Configure received. See - // kernel-callbacks.md's GetConfig for the secret-echo MUST NOT rule this - // implies. + // configuration. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetConfigResult", used by exactly this one RPC. GetConfig(context.Context, *GetConfigRequest) (*GetConfigResult, error) - // Publish emits one event onto the ephemeral, best-effort, cross-plugin - // event bus, distinct from Emit's durable per-session log and from - // hook dispatch's synchronous, agent.hcl-declared subscriber chain. See - // specifications/event-bus.md. + // Publish emits one event onto the ephemeral event bus. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "PublishResult", used by exactly this one RPC. Publish(context.Context, *PublishRequest) (*PublishResult, error) - // Subscribe opens a server-streaming subscription to the event bus, - // filtered by topic. See specifications/event-bus.md#filter-grammar and - // #backpressure for why the kernel may unilaterally close this stream. + // Subscribe opens a server-streaming subscription to the event bus. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is "BusEvent", naming the streamed domain concept - // rather than the RPC, the same convention model.md §4's StreamEvent - // and widget.md's WidgetUpdate already use. Subscribe(*SubscribeRequest, grpc.ServerStreamingServer[BusEvent]) error // ReadEvents reads back the calling plugin's own session's persisted - // event log, ordered by sequence — never by wall-clock time - // (.claude/rules/determinism.md). + // event log, ordered by sequence. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is "StoredEvent", the same "name the domain - // concept" convention as Subscribe's BusEvent above. ReadEvents(*ReadEventsRequest, grpc.ServerStreamingServer[StoredEvent]) error // GetSession returns the calling plugin's own session's metadata plus - // its live, in-memory budget rollups. + // its live budget rollups. // // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Response type is "GetSessionResult", used by exactly this one RPC. GetSession(context.Context, *GetSessionRequest) (*GetSessionResult, error) + // GetSessionState returns the fixed-schema "where am I" snapshot for + // one session. Pair with Subscribe on topic kernel.state for deltas. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + GetSessionState(context.Context, *GetSessionStateRequest) (*GetSessionStateResult, error) + // SubmitInput submits operator input as the next turn. Returns the + // assigned turn_id for correlation. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + SubmitInput(context.Context, *SubmitInputRequest) (*SubmitInputResult, error) + // ResolvePlanDecision answers a pending plan item (policy ASK). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ResolvePlanDecision(context.Context, *ResolvePlanDecisionRequest) (*ResolvePlanDecisionResult, error) + // ResolveInteractive answers a pending interactive-kind tool call. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ResolveInteractive(context.Context, *ResolveInteractiveRequest) (*ResolveInteractiveResult, error) + // Interrupt cancels the running turn for a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + Interrupt(context.Context, *InterruptRequest) (*InterruptResult, error) + // CreateSession creates a new session and auto-attaches the caller. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + CreateSession(context.Context, *CreateSessionRequest) (*CreateSessionResult, error) + // AttachSession subscribes the caller to an existing session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + AttachSession(context.Context, *AttachSessionRequest) (*AttachSessionResult, error) + // ResumeSession attaches a historical session for continuation or + // replay-only. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ResumeSession(context.Context, *ResumeSessionRequest) (*ResumeSessionResult, error) + // DetachSession unsubscribes the caller from a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + DetachSession(context.Context, *DetachSessionRequest) (*DetachSessionResult, error) + // ListSessions returns a filtered session summary list. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResult, error) + // PublishMetadata upserts a MetadataBlock (producer and liveness + // server-stamped). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + PublishMetadata(context.Context, *PublishMetadataRequest) (*PublishMetadataResult, error) + // RetractMetadata flips a block to DISCONNECTED and republishes; never + // deletes. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + RetractMetadata(context.Context, *RetractMetadataRequest) (*RetractMetadataResult, error) + // ListMetadata returns every known MetadataBlock for a session + // (snapshot half of snapshot-then-subscribe). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + ListMetadata(context.Context, *ListMetadataRequest) (*ListMetadataResult, error) + // StreamDeltas is the live-only token fast path: server-streaming on + // this channel, out-of-band with respect to the event bus. The kernel + // does not batch; frontends coalesce to their own refresh. + // + // buf:lint:ignore RPC_REQUEST_STANDARD_NAME + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + StreamDeltas(*StreamDeltasRequest, grpc.ServerStreamingServer[TokenDelta]) error + // InvokeSlashCommand dispatches a slash command against a session. + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + InvokeSlashCommand(context.Context, *InvokeSlashCommandRequest) (*InvokeSlashCommandResult, error) + // TriggerAction dispatches an ActionNode activation (no model turn). + // + // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME + TriggerAction(context.Context, *TriggerActionRequest) (*TriggerActionResult, error) mustEmbedUnimplementedKernelCallbackServiceServer() } @@ -487,6 +714,54 @@ func (UnimplementedKernelCallbackServiceServer) ReadEvents(*ReadEventsRequest, g func (UnimplementedKernelCallbackServiceServer) GetSession(context.Context, *GetSessionRequest) (*GetSessionResult, error) { return nil, status.Error(codes.Unimplemented, "method GetSession not implemented") } +func (UnimplementedKernelCallbackServiceServer) GetSessionState(context.Context, *GetSessionStateRequest) (*GetSessionStateResult, error) { + return nil, status.Error(codes.Unimplemented, "method GetSessionState not implemented") +} +func (UnimplementedKernelCallbackServiceServer) SubmitInput(context.Context, *SubmitInputRequest) (*SubmitInputResult, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitInput not implemented") +} +func (UnimplementedKernelCallbackServiceServer) ResolvePlanDecision(context.Context, *ResolvePlanDecisionRequest) (*ResolvePlanDecisionResult, error) { + return nil, status.Error(codes.Unimplemented, "method ResolvePlanDecision not implemented") +} +func (UnimplementedKernelCallbackServiceServer) ResolveInteractive(context.Context, *ResolveInteractiveRequest) (*ResolveInteractiveResult, error) { + return nil, status.Error(codes.Unimplemented, "method ResolveInteractive not implemented") +} +func (UnimplementedKernelCallbackServiceServer) Interrupt(context.Context, *InterruptRequest) (*InterruptResult, error) { + return nil, status.Error(codes.Unimplemented, "method Interrupt not implemented") +} +func (UnimplementedKernelCallbackServiceServer) CreateSession(context.Context, *CreateSessionRequest) (*CreateSessionResult, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSession not implemented") +} +func (UnimplementedKernelCallbackServiceServer) AttachSession(context.Context, *AttachSessionRequest) (*AttachSessionResult, error) { + return nil, status.Error(codes.Unimplemented, "method AttachSession not implemented") +} +func (UnimplementedKernelCallbackServiceServer) ResumeSession(context.Context, *ResumeSessionRequest) (*ResumeSessionResult, error) { + return nil, status.Error(codes.Unimplemented, "method ResumeSession not implemented") +} +func (UnimplementedKernelCallbackServiceServer) DetachSession(context.Context, *DetachSessionRequest) (*DetachSessionResult, error) { + return nil, status.Error(codes.Unimplemented, "method DetachSession not implemented") +} +func (UnimplementedKernelCallbackServiceServer) ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResult, error) { + return nil, status.Error(codes.Unimplemented, "method ListSessions not implemented") +} +func (UnimplementedKernelCallbackServiceServer) PublishMetadata(context.Context, *PublishMetadataRequest) (*PublishMetadataResult, error) { + return nil, status.Error(codes.Unimplemented, "method PublishMetadata not implemented") +} +func (UnimplementedKernelCallbackServiceServer) RetractMetadata(context.Context, *RetractMetadataRequest) (*RetractMetadataResult, error) { + return nil, status.Error(codes.Unimplemented, "method RetractMetadata not implemented") +} +func (UnimplementedKernelCallbackServiceServer) ListMetadata(context.Context, *ListMetadataRequest) (*ListMetadataResult, error) { + return nil, status.Error(codes.Unimplemented, "method ListMetadata not implemented") +} +func (UnimplementedKernelCallbackServiceServer) StreamDeltas(*StreamDeltasRequest, grpc.ServerStreamingServer[TokenDelta]) error { + return status.Error(codes.Unimplemented, "method StreamDeltas not implemented") +} +func (UnimplementedKernelCallbackServiceServer) InvokeSlashCommand(context.Context, *InvokeSlashCommandRequest) (*InvokeSlashCommandResult, error) { + return nil, status.Error(codes.Unimplemented, "method InvokeSlashCommand not implemented") +} +func (UnimplementedKernelCallbackServiceServer) TriggerAction(context.Context, *TriggerActionRequest) (*TriggerActionResult, error) { + return nil, status.Error(codes.Unimplemented, "method TriggerAction not implemented") +} func (UnimplementedKernelCallbackServiceServer) mustEmbedUnimplementedKernelCallbackServiceServer() {} func (UnimplementedKernelCallbackServiceServer) testEmbeddedByValue() {} @@ -710,6 +985,287 @@ func _KernelCallbackService_GetSession_Handler(srv interface{}, ctx context.Cont return interceptor(ctx, in, info, handler) } +func _KernelCallbackService_GetSessionState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSessionStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).GetSessionState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_GetSessionState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).GetSessionState(ctx, req.(*GetSessionStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_SubmitInput_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubmitInputRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).SubmitInput(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_SubmitInput_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).SubmitInput(ctx, req.(*SubmitInputRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_ResolvePlanDecision_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResolvePlanDecisionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).ResolvePlanDecision(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_ResolvePlanDecision_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).ResolvePlanDecision(ctx, req.(*ResolvePlanDecisionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_ResolveInteractive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResolveInteractiveRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).ResolveInteractive(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_ResolveInteractive_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).ResolveInteractive(ctx, req.(*ResolveInteractiveRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_Interrupt_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InterruptRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).Interrupt(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_Interrupt_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).Interrupt(ctx, req.(*InterruptRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_CreateSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).CreateSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_CreateSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).CreateSession(ctx, req.(*CreateSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_AttachSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AttachSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).AttachSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_AttachSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).AttachSession(ctx, req.(*AttachSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_ResumeSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResumeSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).ResumeSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_ResumeSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).ResumeSession(ctx, req.(*ResumeSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_DetachSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DetachSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).DetachSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_DetachSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).DetachSession(ctx, req.(*DetachSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_ListSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSessionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).ListSessions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_ListSessions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).ListSessions(ctx, req.(*ListSessionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_PublishMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PublishMetadataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).PublishMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_PublishMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).PublishMetadata(ctx, req.(*PublishMetadataRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_RetractMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RetractMetadataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).RetractMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_RetractMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).RetractMetadata(ctx, req.(*RetractMetadataRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_ListMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListMetadataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).ListMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_ListMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).ListMetadata(ctx, req.(*ListMetadataRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_StreamDeltas_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StreamDeltasRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(KernelCallbackServiceServer).StreamDeltas(m, &grpc.GenericServerStream[StreamDeltasRequest, TokenDelta]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type KernelCallbackService_StreamDeltasServer = grpc.ServerStreamingServer[TokenDelta] + +func _KernelCallbackService_InvokeSlashCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InvokeSlashCommandRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).InvokeSlashCommand(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_InvokeSlashCommand_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).InvokeSlashCommand(ctx, req.(*InvokeSlashCommandRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KernelCallbackService_TriggerAction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TriggerActionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KernelCallbackServiceServer).TriggerAction(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KernelCallbackService_TriggerAction_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KernelCallbackServiceServer).TriggerAction(ctx, req.(*TriggerActionRequest)) + } + return interceptor(ctx, in, info, handler) +} + // KernelCallbackService_ServiceDesc is the grpc.ServiceDesc for KernelCallbackService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -757,6 +1313,66 @@ var KernelCallbackService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetSession", Handler: _KernelCallbackService_GetSession_Handler, }, + { + MethodName: "GetSessionState", + Handler: _KernelCallbackService_GetSessionState_Handler, + }, + { + MethodName: "SubmitInput", + Handler: _KernelCallbackService_SubmitInput_Handler, + }, + { + MethodName: "ResolvePlanDecision", + Handler: _KernelCallbackService_ResolvePlanDecision_Handler, + }, + { + MethodName: "ResolveInteractive", + Handler: _KernelCallbackService_ResolveInteractive_Handler, + }, + { + MethodName: "Interrupt", + Handler: _KernelCallbackService_Interrupt_Handler, + }, + { + MethodName: "CreateSession", + Handler: _KernelCallbackService_CreateSession_Handler, + }, + { + MethodName: "AttachSession", + Handler: _KernelCallbackService_AttachSession_Handler, + }, + { + MethodName: "ResumeSession", + Handler: _KernelCallbackService_ResumeSession_Handler, + }, + { + MethodName: "DetachSession", + Handler: _KernelCallbackService_DetachSession_Handler, + }, + { + MethodName: "ListSessions", + Handler: _KernelCallbackService_ListSessions_Handler, + }, + { + MethodName: "PublishMetadata", + Handler: _KernelCallbackService_PublishMetadata_Handler, + }, + { + MethodName: "RetractMetadata", + Handler: _KernelCallbackService_RetractMetadata_Handler, + }, + { + MethodName: "ListMetadata", + Handler: _KernelCallbackService_ListMetadata_Handler, + }, + { + MethodName: "InvokeSlashCommand", + Handler: _KernelCallbackService_InvokeSlashCommand_Handler, + }, + { + MethodName: "TriggerAction", + Handler: _KernelCallbackService_TriggerAction_Handler, + }, }, Streams: []grpc.StreamDesc{ { @@ -769,6 +1385,11 @@ var KernelCallbackService_ServiceDesc = grpc.ServiceDesc{ Handler: _KernelCallbackService_ReadEvents_Handler, ServerStreams: true, }, + { + StreamName: "StreamDeltas", + Handler: _KernelCallbackService_StreamDeltas_Handler, + ServerStreams: true, + }, }, Metadata: "pluggableharness/kernel/v1/service.proto", } diff --git a/pkg/metadata/block.go b/pkg/metadata/block.go new file mode 100644 index 0000000..664e932 --- /dev/null +++ b/pkg/metadata/block.go @@ -0,0 +1,116 @@ +package metadata + +import ( + "time" + + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + metadatav1 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" +) + +// BlockOption configures optional fields on a MetadataBlock builder. +type BlockOption func(*metadatav1.MetadataBlock) + +// WithPriority sets the block's ordering/eviction hint. Higher wins. +func WithPriority(priority int32) BlockOption { + return func(b *metadatav1.MetadataBlock) { b.Priority = priority } +} + +// WithTone sets the presentation intent token. Defaults to ToneNeutral +// when no option is provided. +func WithTone(tone Tone) BlockOption { + return func(b *metadatav1.MetadataBlock) { b.Tone = tone } +} + +// WithSessionID sets the session the block belongs to. The kernel also +// stamps this from PublishMetadataRequest.session_id; setting it here is +// useful when constructing a block for a bus payload outside a publish call. +func WithSessionID(sessionID string) BlockOption { + return func(b *metadatav1.MetadataBlock) { b.SessionId = sessionID } +} + +func applyOpts(b *metadatav1.MetadataBlock, opts []BlockOption) *metadatav1.MetadataBlock { + if b.Tone == ToneUnspecified { + b.Tone = ToneNeutral + } + b.Liveness = metadatav1.Liveness_LIVENESS_LIVE + for _, opt := range opts { + opt(b) + } + return b +} + +// KeyValue builds a MetadataBlock with a short labeled value body. +func KeyValue(id, key, value string, opts ...BlockOption) *metadatav1.MetadataBlock { + return applyOpts(&metadatav1.MetadataBlock{ + Id: id, + Body: &metadatav1.MetadataBlock_KeyValue{ + KeyValue: &metadatav1.KeyValue{Key: key, Value: value}, + }, + }, opts) +} + +// Progress builds a MetadataBlock with a progress body. Pass total <= 0 +// for indeterminate progress. +func Progress(id, label string, completed, total int64, opts ...BlockOption) *metadatav1.MetadataBlock { + body := &metadatav1.Progress{Label: label, Completed: completed} + if total > 0 { + body.Total = &total + } + return applyOpts(&metadatav1.MetadataBlock{ + Id: id, + Body: &metadatav1.MetadataBlock_Progress{ + Progress: body, + }, + }, opts) +} + +// Status builds a MetadataBlock with a status body. detail may be empty. +func Status(id, text, detail string, opts ...BlockOption) *metadatav1.MetadataBlock { + body := &metadatav1.Status{Text: text} + if detail != "" { + body.Detail = &detail + } + return applyOpts(&metadatav1.MetadataBlock{ + Id: id, + Body: &metadatav1.MetadataBlock_Status{ + Status: body, + }, + }, opts) +} + +// ItemList builds a MetadataBlock with an ordered list body. title may be empty. +func ItemList(id, title string, items []string, opts ...BlockOption) *metadatav1.MetadataBlock { + body := &metadatav1.ItemList{Items: items} + if title != "" { + body.Title = &title + } + return applyOpts(&metadatav1.MetadataBlock{ + Id: id, + Body: &metadatav1.MetadataBlock_ItemList{ + ItemList: body, + }, + }, opts) +} + +// Timer builds a MetadataBlock with a timer body starting at startedAt. +// deadline and duration are optional (zero values omitted). +func Timer(id, label string, startedAt time.Time, deadline time.Time, duration time.Duration, opts ...BlockOption) *metadatav1.MetadataBlock { + body := &metadatav1.Timer{StartedAt: timestamppb.New(startedAt)} + if label != "" { + body.Label = &label + } + if !deadline.IsZero() { + body.Deadline = timestamppb.New(deadline) + } + if duration > 0 { + body.Duration = durationpb.New(duration) + } + return applyOpts(&metadatav1.MetadataBlock{ + Id: id, + Body: &metadatav1.MetadataBlock_Timer{ + Timer: body, + }, + }, opts) +} diff --git a/pkg/metadata/block_test.go b/pkg/metadata/block_test.go new file mode 100644 index 0000000..3a006aa --- /dev/null +++ b/pkg/metadata/block_test.go @@ -0,0 +1,119 @@ +package metadata_test + +import ( + "testing" + "time" + + "github.com/pluggableharness/agent/pkg/metadata" + metadatav1 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" +) + +func TestToneByName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want metadata.Tone + ok bool + }{ + {"neutral", "neutral", metadata.ToneNeutral, true}, + {"warning", "warning", metadata.ToneWarning, true}, + {"unknown falls back", "chartreuse", metadata.ToneNeutral, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := metadata.ToneByName(tt.in) + if ok != tt.ok || got != tt.want { + t.Errorf("ToneByName(%q) = %v, %v; want %v, %v", tt.in, got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestKeyValue(t *testing.T) { + t.Parallel() + + b := metadata.KeyValue("branch", "branch", "main", + metadata.WithPriority(10), + metadata.WithTone(metadata.ToneInfo), + metadata.WithSessionID("sess-1"), + ) + if b.GetId() != "branch" { + t.Errorf("id = %q, want branch", b.GetId()) + } + if b.GetPriority() != 10 || b.GetTone() != metadata.ToneInfo { + t.Errorf("priority/tone = %d/%v", b.GetPriority(), b.GetTone()) + } + if b.GetSessionId() != "sess-1" { + t.Errorf("session_id = %q", b.GetSessionId()) + } + if b.GetLiveness() != metadatav1.Liveness_LIVENESS_LIVE { + t.Errorf("liveness = %v, want LIVE", b.GetLiveness()) + } + kv := b.GetKeyValue() + if kv == nil || kv.GetKey() != "branch" || kv.GetValue() != "main" { + t.Errorf("body = %+v", kv) + } +} + +func TestProgressIndeterminate(t *testing.T) { + t.Parallel() + + b := metadata.Progress("p1", "loading", 3, 0) + p := b.GetProgress() + if p == nil || p.GetCompleted() != 3 || p.Total != nil { + t.Errorf("progress = %+v, want completed=3 no total", p) + } +} + +func TestProgressDeterminate(t *testing.T) { + t.Parallel() + + b := metadata.Progress("p1", "loading", 3, 10) + p := b.GetProgress() + if p == nil || p.GetTotal() != 10 { + t.Errorf("progress total = %v, want 10", p.GetTotal()) + } +} + +func TestStatusAndItemList(t *testing.T) { + t.Parallel() + + st := metadata.Status("s1", "ok", "detail") + if st.GetStatus().GetText() != "ok" || st.GetStatus().GetDetail() != "detail" { + t.Errorf("status = %+v", st.GetStatus()) + } + il := metadata.ItemList("l1", "todos", []string{"a", "b"}) + if il.GetItemList().GetTitle() != "todos" || len(il.GetItemList().GetItems()) != 2 { + t.Errorf("item_list = %+v", il.GetItemList()) + } +} + +func TestTimer(t *testing.T) { + t.Parallel() + + start := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + deadline := start.Add(time.Minute) + b := metadata.Timer("t1", "turn", start, deadline, 30*time.Second) + tm := b.GetTimer() + if tm == nil || tm.GetLabel() != "turn" { + t.Fatalf("timer = %+v", tm) + } + if !tm.GetStartedAt().AsTime().Equal(start) { + t.Errorf("started_at = %v", tm.GetStartedAt().AsTime()) + } + if tm.GetDuration().AsDuration() != 30*time.Second { + t.Errorf("duration = %v", tm.GetDuration().AsDuration()) + } +} + +func TestDefaultToneIsNeutral(t *testing.T) { + t.Parallel() + + b := metadata.KeyValue("k", "a", "b") + if b.GetTone() != metadata.ToneNeutral { + t.Errorf("default tone = %v, want neutral", b.GetTone()) + } +} diff --git a/pkg/metadata/doc.go b/pkg/metadata/doc.go new file mode 100644 index 0000000..bde8698 --- /dev/null +++ b/pkg/metadata/doc.go @@ -0,0 +1,9 @@ +// Package metadata is the shared builder package for MetadataBlock — the +// typed "Metadata" surface of the four frontend state surfaces (input, +// state, metadata, transcript). A plugin author composes intent from +// these primitives; a frontend maps Tone tokens to whatever it has +// (ANSI color, CSS class, spoken label). A block never carries a color, +// a width, or a position. +// +// See docs/specifications/frontend/ and api/pluggableharness/metadata/v1. +package metadata diff --git a/pkg/metadata/proto/v1/types.pb.go b/pkg/metadata/proto/v1/types.pb.go new file mode 100644 index 0000000..04e68ea --- /dev/null +++ b/pkg/metadata/proto/v1/types.pb.go @@ -0,0 +1,798 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: pluggableharness/metadata/v1/types.proto + +// Package pluggableharness.metadata.v1 defines the typed metadata-block +// vocabulary plugins publish and frontends render — the "Metadata" surface +// of the four frontend state surfaces (input, state, metadata, transcript) +// described in specifications/frontend/. A block never carries a color, a +// width, or a position: Tone is a closed token scale the frontend maps to +// whatever it has (including a screen reader saying "warning"), and the +// closed oneof body is the only set of kinds every conforming frontend is +// required to render. + +package metadatav1 + +import ( + v1 "github.com/pluggableharness/agent/pkg/common/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Tone is a closed token scale for a MetadataBlock's presentation intent. +// Frontends map each token to their own vocabulary (ANSI color, CSS class, +// spoken label); a block never names a color value. +type Tone int32 + +const ( + // Zero value. Never valid for a real block; its presence means a caller + // forgot to set the field. + Tone_TONE_UNSPECIFIED Tone = 0 + // Ordinary, unemphasized content. + Tone_TONE_NEUTRAL Tone = 1 + // Informational emphasis without implying success or failure. + Tone_TONE_INFO Tone = 2 + // A successful or healthy condition. + Tone_TONE_SUCCESS Tone = 3 + // A cautionary condition that is not yet a failure. + Tone_TONE_WARNING Tone = 4 + // A failure or high-severity condition. + Tone_TONE_DANGER Tone = 5 +) + +// Enum value maps for Tone. +var ( + Tone_name = map[int32]string{ + 0: "TONE_UNSPECIFIED", + 1: "TONE_NEUTRAL", + 2: "TONE_INFO", + 3: "TONE_SUCCESS", + 4: "TONE_WARNING", + 5: "TONE_DANGER", + } + Tone_value = map[string]int32{ + "TONE_UNSPECIFIED": 0, + "TONE_NEUTRAL": 1, + "TONE_INFO": 2, + "TONE_SUCCESS": 3, + "TONE_WARNING": 4, + "TONE_DANGER": 5, + } +) + +func (x Tone) Enum() *Tone { + p := new(Tone) + *p = x + return p +} + +func (x Tone) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Tone) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_metadata_v1_types_proto_enumTypes[0].Descriptor() +} + +func (Tone) Type() protoreflect.EnumType { + return &file_pluggableharness_metadata_v1_types_proto_enumTypes[0] +} + +func (x Tone) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Tone.Descriptor instead. +func (Tone) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_metadata_v1_types_proto_rawDescGZIP(), []int{0} +} + +// Liveness reports whether the producing plugin is still connected. +// The kernel never deletes a block: on publisher exit or RetractMetadata +// it flips liveness to DISCONNECTED and republishes; the frontend decides +// whether that means gray, drop, or "plugin gone." +type Liveness int32 + +const ( + // Zero value. Never valid for a real block. + Liveness_LIVENESS_UNSPECIFIED Liveness = 0 + // The producing plugin is still live and owns this block. + Liveness_LIVENESS_LIVE Liveness = 1 + // The producing plugin has exited or explicitly retracted the block. + Liveness_LIVENESS_DISCONNECTED Liveness = 2 +) + +// Enum value maps for Liveness. +var ( + Liveness_name = map[int32]string{ + 0: "LIVENESS_UNSPECIFIED", + 1: "LIVENESS_LIVE", + 2: "LIVENESS_DISCONNECTED", + } + Liveness_value = map[string]int32{ + "LIVENESS_UNSPECIFIED": 0, + "LIVENESS_LIVE": 1, + "LIVENESS_DISCONNECTED": 2, + } +) + +func (x Liveness) Enum() *Liveness { + p := new(Liveness) + *p = x + return p +} + +func (x Liveness) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Liveness) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_metadata_v1_types_proto_enumTypes[1].Descriptor() +} + +func (Liveness) Type() protoreflect.EnumType { + return &file_pluggableharness_metadata_v1_types_proto_enumTypes[1] +} + +func (x Liveness) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Liveness.Descriptor instead. +func (Liveness) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_metadata_v1_types_proto_rawDescGZIP(), []int{1} +} + +// MetadataBlock is one keyed, typed contribution to a session's metadata +// surface. Identified by id within a session; upserted by PublishMetadata +// and never deleted by the kernel. +type MetadataBlock struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable id within the session. The publishing plugin chooses it; a + // second PublishMetadata with the same id upserts. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // The plugin that currently owns this block. Server-derived from the + // authenticated callback connection on PublishMetadata — a client MUST + // NOT supply this; the kernel overwrites any client-set value. + Producer *v1.ProducerRef `protobuf:"bytes,2,opt,name=producer,proto3" json:"producer,omitempty"` + // Ordering/eviction hint for space-constrained frontends. Higher wins. + // Unset (zero) means declaration order among equal-priority peers. + Priority int32 `protobuf:"varint,3,opt,name=priority,proto3" json:"priority,omitempty"` + // Presentation intent token. MUST be set to a non-UNSPECIFIED value. + Tone Tone `protobuf:"varint,4,opt,name=tone,proto3,enum=pluggableharness.metadata.v1.Tone" json:"tone,omitempty"` + // Whether the producer is still connected. MUST be set. + Liveness Liveness `protobuf:"varint,5,opt,name=liveness,proto3,enum=pluggableharness.metadata.v1.Liveness" json:"liveness,omitempty"` + // The session this block belongs to. MUST be set. Carried on the block + // (rather than only on the publish request) so a bus subscriber can + // filter without a second lookup. + SessionId string `protobuf:"bytes,6,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Exactly one body variant is set. The set is closed on purpose: five + // kinds surface missing shapes faster than an open type would, and a + // closed set is what guarantees every frontend can render every block. + // + // Types that are valid to be assigned to Body: + // + // *MetadataBlock_KeyValue + // *MetadataBlock_Progress + // *MetadataBlock_Status + // *MetadataBlock_ItemList + // *MetadataBlock_Timer + Body isMetadataBlock_Body `protobuf_oneof:"body"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MetadataBlock) Reset() { + *x = MetadataBlock{} + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MetadataBlock) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetadataBlock) ProtoMessage() {} + +func (x *MetadataBlock) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetadataBlock.ProtoReflect.Descriptor instead. +func (*MetadataBlock) Descriptor() ([]byte, []int) { + return file_pluggableharness_metadata_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *MetadataBlock) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *MetadataBlock) GetProducer() *v1.ProducerRef { + if x != nil { + return x.Producer + } + return nil +} + +func (x *MetadataBlock) GetPriority() int32 { + if x != nil { + return x.Priority + } + return 0 +} + +func (x *MetadataBlock) GetTone() Tone { + if x != nil { + return x.Tone + } + return Tone_TONE_UNSPECIFIED +} + +func (x *MetadataBlock) GetLiveness() Liveness { + if x != nil { + return x.Liveness + } + return Liveness_LIVENESS_UNSPECIFIED +} + +func (x *MetadataBlock) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *MetadataBlock) GetBody() isMetadataBlock_Body { + if x != nil { + return x.Body + } + return nil +} + +func (x *MetadataBlock) GetKeyValue() *KeyValue { + if x != nil { + if x, ok := x.Body.(*MetadataBlock_KeyValue); ok { + return x.KeyValue + } + } + return nil +} + +func (x *MetadataBlock) GetProgress() *Progress { + if x != nil { + if x, ok := x.Body.(*MetadataBlock_Progress); ok { + return x.Progress + } + } + return nil +} + +func (x *MetadataBlock) GetStatus() *Status { + if x != nil { + if x, ok := x.Body.(*MetadataBlock_Status); ok { + return x.Status + } + } + return nil +} + +func (x *MetadataBlock) GetItemList() *ItemList { + if x != nil { + if x, ok := x.Body.(*MetadataBlock_ItemList); ok { + return x.ItemList + } + } + return nil +} + +func (x *MetadataBlock) GetTimer() *Timer { + if x != nil { + if x, ok := x.Body.(*MetadataBlock_Timer); ok { + return x.Timer + } + } + return nil +} + +type isMetadataBlock_Body interface { + isMetadataBlock_Body() +} + +type MetadataBlock_KeyValue struct { + KeyValue *KeyValue `protobuf:"bytes,10,opt,name=key_value,json=keyValue,proto3,oneof"` +} + +type MetadataBlock_Progress struct { + Progress *Progress `protobuf:"bytes,11,opt,name=progress,proto3,oneof"` +} + +type MetadataBlock_Status struct { + Status *Status `protobuf:"bytes,12,opt,name=status,proto3,oneof"` +} + +type MetadataBlock_ItemList struct { + ItemList *ItemList `protobuf:"bytes,13,opt,name=item_list,json=itemList,proto3,oneof"` +} + +type MetadataBlock_Timer struct { + Timer *Timer `protobuf:"bytes,14,opt,name=timer,proto3,oneof"` +} + +func (*MetadataBlock_KeyValue) isMetadataBlock_Body() {} + +func (*MetadataBlock_Progress) isMetadataBlock_Body() {} + +func (*MetadataBlock_Status) isMetadataBlock_Body() {} + +func (*MetadataBlock_ItemList) isMetadataBlock_Body() {} + +func (*MetadataBlock_Timer) isMetadataBlock_Body() {} + +// KeyValue is a short labeled value, e.g. "branch: main". +type KeyValue struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The field label shown to the operator. + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // The field value shown to the operator. + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *KeyValue) Reset() { + *x = KeyValue{} + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *KeyValue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*KeyValue) ProtoMessage() {} + +func (x *KeyValue) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use KeyValue.ProtoReflect.Descriptor instead. +func (*KeyValue) Descriptor() ([]byte, []int) { + return file_pluggableharness_metadata_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *KeyValue) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *KeyValue) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +// Progress is a determinate or indeterminate progress indicator. +type Progress struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Short label describing what is in progress. + Label string `protobuf:"bytes,1,opt,name=label,proto3" json:"label,omitempty"` + // Completed units. Meaningful when total is set and > 0. + Completed int64 `protobuf:"varint,2,opt,name=completed,proto3" json:"completed,omitempty"` + // Total units. Absent or zero means indeterminate progress. + Total *int64 `protobuf:"varint,3,opt,name=total,proto3,oneof" json:"total,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Progress) Reset() { + *x = Progress{} + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Progress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Progress) ProtoMessage() {} + +func (x *Progress) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Progress.ProtoReflect.Descriptor instead. +func (*Progress) Descriptor() ([]byte, []int) { + return file_pluggableharness_metadata_v1_types_proto_rawDescGZIP(), []int{2} +} + +func (x *Progress) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *Progress) GetCompleted() int64 { + if x != nil { + return x.Completed + } + return 0 +} + +func (x *Progress) GetTotal() int64 { + if x != nil && x.Total != nil { + return *x.Total + } + return 0 +} + +// Status is a single status line with an optional detail. +type Status struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The primary status text. + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + // Optional secondary detail. + Detail *string `protobuf:"bytes,2,opt,name=detail,proto3,oneof" json:"detail,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Status) Reset() { + *x = Status{} + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Status) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Status) ProtoMessage() {} + +func (x *Status) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Status.ProtoReflect.Descriptor instead. +func (*Status) Descriptor() ([]byte, []int) { + return file_pluggableharness_metadata_v1_types_proto_rawDescGZIP(), []int{3} +} + +func (x *Status) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *Status) GetDetail() string { + if x != nil && x.Detail != nil { + return *x.Detail + } + return "" +} + +// ItemList is an ordered list of short text items. +type ItemList struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional list title. + Title *string `protobuf:"bytes,1,opt,name=title,proto3,oneof" json:"title,omitempty"` + // The items, in display order. + Items []string `protobuf:"bytes,2,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ItemList) Reset() { + *x = ItemList{} + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ItemList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ItemList) ProtoMessage() {} + +func (x *ItemList) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ItemList.ProtoReflect.Descriptor instead. +func (*ItemList) Descriptor() ([]byte, []int) { + return file_pluggableharness_metadata_v1_types_proto_rawDescGZIP(), []int{4} +} + +func (x *ItemList) GetTitle() string { + if x != nil && x.Title != nil { + return *x.Title + } + return "" +} + +func (x *ItemList) GetItems() []string { + if x != nil { + return x.Items + } + return nil +} + +// Timer is a wall-clock duration or deadline the frontend can render as +// a ticking clock or countdown. +type Timer struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional label for what the timer measures. + Label *string `protobuf:"bytes,1,opt,name=label,proto3,oneof" json:"label,omitempty"` + // When the timed interval started. MUST be set for elapsed display. + StartedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + // Optional absolute deadline; when set, frontends MAY render a + // countdown instead of (or in addition to) elapsed time. + Deadline *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=deadline,proto3,oneof" json:"deadline,omitempty"` + // Optional fixed duration the timer represents when no deadline is set. + Duration *durationpb.Duration `protobuf:"bytes,4,opt,name=duration,proto3,oneof" json:"duration,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Timer) Reset() { + *x = Timer{} + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Timer) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Timer) ProtoMessage() {} + +func (x *Timer) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_metadata_v1_types_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Timer.ProtoReflect.Descriptor instead. +func (*Timer) Descriptor() ([]byte, []int) { + return file_pluggableharness_metadata_v1_types_proto_rawDescGZIP(), []int{5} +} + +func (x *Timer) GetLabel() string { + if x != nil && x.Label != nil { + return *x.Label + } + return "" +} + +func (x *Timer) GetStartedAt() *timestamppb.Timestamp { + if x != nil { + return x.StartedAt + } + return nil +} + +func (x *Timer) GetDeadline() *timestamppb.Timestamp { + if x != nil { + return x.Deadline + } + return nil +} + +func (x *Timer) GetDuration() *durationpb.Duration { + if x != nil { + return x.Duration + } + return nil +} + +var File_pluggableharness_metadata_v1_types_proto protoreflect.FileDescriptor + +const file_pluggableharness_metadata_v1_types_proto_rawDesc = "" + + "\n" + + "(pluggableharness/metadata/v1/types.proto\x12\x1cpluggableharness.metadata.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&pluggableharness/common/v1/types.proto\"\xf4\x04\n" + + "\rMetadataBlock\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12C\n" + + "\bproducer\x18\x02 \x01(\v2'.pluggableharness.common.v1.ProducerRefR\bproducer\x12\x1a\n" + + "\bpriority\x18\x03 \x01(\x05R\bpriority\x126\n" + + "\x04tone\x18\x04 \x01(\x0e2\".pluggableharness.metadata.v1.ToneR\x04tone\x12B\n" + + "\bliveness\x18\x05 \x01(\x0e2&.pluggableharness.metadata.v1.LivenessR\bliveness\x12\x1d\n" + + "\n" + + "session_id\x18\x06 \x01(\tR\tsessionId\x12E\n" + + "\tkey_value\x18\n" + + " \x01(\v2&.pluggableharness.metadata.v1.KeyValueH\x00R\bkeyValue\x12D\n" + + "\bprogress\x18\v \x01(\v2&.pluggableharness.metadata.v1.ProgressH\x00R\bprogress\x12>\n" + + "\x06status\x18\f \x01(\v2$.pluggableharness.metadata.v1.StatusH\x00R\x06status\x12E\n" + + "\titem_list\x18\r \x01(\v2&.pluggableharness.metadata.v1.ItemListH\x00R\bitemList\x12;\n" + + "\x05timer\x18\x0e \x01(\v2#.pluggableharness.metadata.v1.TimerH\x00R\x05timerB\x06\n" + + "\x04body\"2\n" + + "\bKeyValue\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"c\n" + + "\bProgress\x12\x14\n" + + "\x05label\x18\x01 \x01(\tR\x05label\x12\x1c\n" + + "\tcompleted\x18\x02 \x01(\x03R\tcompleted\x12\x19\n" + + "\x05total\x18\x03 \x01(\x03H\x00R\x05total\x88\x01\x01B\b\n" + + "\x06_total\"D\n" + + "\x06Status\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x12\x1b\n" + + "\x06detail\x18\x02 \x01(\tH\x00R\x06detail\x88\x01\x01B\t\n" + + "\a_detail\"E\n" + + "\bItemList\x12\x19\n" + + "\x05title\x18\x01 \x01(\tH\x00R\x05title\x88\x01\x01\x12\x14\n" + + "\x05items\x18\x02 \x03(\tR\x05itemsB\b\n" + + "\x06_title\"\xfa\x01\n" + + "\x05Timer\x12\x19\n" + + "\x05label\x18\x01 \x01(\tH\x00R\x05label\x88\x01\x01\x129\n" + + "\n" + + "started_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12;\n" + + "\bdeadline\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\bdeadline\x88\x01\x01\x12:\n" + + "\bduration\x18\x04 \x01(\v2\x19.google.protobuf.DurationH\x02R\bduration\x88\x01\x01B\b\n" + + "\x06_labelB\v\n" + + "\t_deadlineB\v\n" + + "\t_duration*r\n" + + "\x04Tone\x12\x14\n" + + "\x10TONE_UNSPECIFIED\x10\x00\x12\x10\n" + + "\fTONE_NEUTRAL\x10\x01\x12\r\n" + + "\tTONE_INFO\x10\x02\x12\x10\n" + + "\fTONE_SUCCESS\x10\x03\x12\x10\n" + + "\fTONE_WARNING\x10\x04\x12\x0f\n" + + "\vTONE_DANGER\x10\x05*R\n" + + "\bLiveness\x12\x18\n" + + "\x14LIVENESS_UNSPECIFIED\x10\x00\x12\x11\n" + + "\rLIVENESS_LIVE\x10\x01\x12\x19\n" + + "\x15LIVENESS_DISCONNECTED\x10\x02BDZBgithub.com/pluggableharness/agent/pkg/metadata/proto/v1;metadatav1b\x06proto3" + +var ( + file_pluggableharness_metadata_v1_types_proto_rawDescOnce sync.Once + file_pluggableharness_metadata_v1_types_proto_rawDescData []byte +) + +func file_pluggableharness_metadata_v1_types_proto_rawDescGZIP() []byte { + file_pluggableharness_metadata_v1_types_proto_rawDescOnce.Do(func() { + file_pluggableharness_metadata_v1_types_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_metadata_v1_types_proto_rawDesc), len(file_pluggableharness_metadata_v1_types_proto_rawDesc))) + }) + return file_pluggableharness_metadata_v1_types_proto_rawDescData +} + +var file_pluggableharness_metadata_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_pluggableharness_metadata_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_pluggableharness_metadata_v1_types_proto_goTypes = []any{ + (Tone)(0), // 0: pluggableharness.metadata.v1.Tone + (Liveness)(0), // 1: pluggableharness.metadata.v1.Liveness + (*MetadataBlock)(nil), // 2: pluggableharness.metadata.v1.MetadataBlock + (*KeyValue)(nil), // 3: pluggableharness.metadata.v1.KeyValue + (*Progress)(nil), // 4: pluggableharness.metadata.v1.Progress + (*Status)(nil), // 5: pluggableharness.metadata.v1.Status + (*ItemList)(nil), // 6: pluggableharness.metadata.v1.ItemList + (*Timer)(nil), // 7: pluggableharness.metadata.v1.Timer + (*v1.ProducerRef)(nil), // 8: pluggableharness.common.v1.ProducerRef + (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 10: google.protobuf.Duration +} +var file_pluggableharness_metadata_v1_types_proto_depIdxs = []int32{ + 8, // 0: pluggableharness.metadata.v1.MetadataBlock.producer:type_name -> pluggableharness.common.v1.ProducerRef + 0, // 1: pluggableharness.metadata.v1.MetadataBlock.tone:type_name -> pluggableharness.metadata.v1.Tone + 1, // 2: pluggableharness.metadata.v1.MetadataBlock.liveness:type_name -> pluggableharness.metadata.v1.Liveness + 3, // 3: pluggableharness.metadata.v1.MetadataBlock.key_value:type_name -> pluggableharness.metadata.v1.KeyValue + 4, // 4: pluggableharness.metadata.v1.MetadataBlock.progress:type_name -> pluggableharness.metadata.v1.Progress + 5, // 5: pluggableharness.metadata.v1.MetadataBlock.status:type_name -> pluggableharness.metadata.v1.Status + 6, // 6: pluggableharness.metadata.v1.MetadataBlock.item_list:type_name -> pluggableharness.metadata.v1.ItemList + 7, // 7: pluggableharness.metadata.v1.MetadataBlock.timer:type_name -> pluggableharness.metadata.v1.Timer + 9, // 8: pluggableharness.metadata.v1.Timer.started_at:type_name -> google.protobuf.Timestamp + 9, // 9: pluggableharness.metadata.v1.Timer.deadline:type_name -> google.protobuf.Timestamp + 10, // 10: pluggableharness.metadata.v1.Timer.duration:type_name -> google.protobuf.Duration + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_pluggableharness_metadata_v1_types_proto_init() } +func file_pluggableharness_metadata_v1_types_proto_init() { + if File_pluggableharness_metadata_v1_types_proto != nil { + return + } + file_pluggableharness_metadata_v1_types_proto_msgTypes[0].OneofWrappers = []any{ + (*MetadataBlock_KeyValue)(nil), + (*MetadataBlock_Progress)(nil), + (*MetadataBlock_Status)(nil), + (*MetadataBlock_ItemList)(nil), + (*MetadataBlock_Timer)(nil), + } + file_pluggableharness_metadata_v1_types_proto_msgTypes[2].OneofWrappers = []any{} + file_pluggableharness_metadata_v1_types_proto_msgTypes[3].OneofWrappers = []any{} + file_pluggableharness_metadata_v1_types_proto_msgTypes[4].OneofWrappers = []any{} + file_pluggableharness_metadata_v1_types_proto_msgTypes[5].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_metadata_v1_types_proto_rawDesc), len(file_pluggableharness_metadata_v1_types_proto_rawDesc)), + NumEnums: 2, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pluggableharness_metadata_v1_types_proto_goTypes, + DependencyIndexes: file_pluggableharness_metadata_v1_types_proto_depIdxs, + EnumInfos: file_pluggableharness_metadata_v1_types_proto_enumTypes, + MessageInfos: file_pluggableharness_metadata_v1_types_proto_msgTypes, + }.Build() + File_pluggableharness_metadata_v1_types_proto = out.File + file_pluggableharness_metadata_v1_types_proto_goTypes = nil + file_pluggableharness_metadata_v1_types_proto_depIdxs = nil +} diff --git a/pkg/metadata/tone.go b/pkg/metadata/tone.go new file mode 100644 index 0000000..e375d9f --- /dev/null +++ b/pkg/metadata/tone.go @@ -0,0 +1,44 @@ +package metadata + +import metadatav1 "github.com/pluggableharness/agent/pkg/metadata/proto/v1" + +// Tone is a closed token scale for a MetadataBlock's presentation intent. +// Frontends map each token to their own vocabulary; a block never names +// a color value. +type Tone = metadatav1.Tone + +const ( + // ToneUnspecified is the zero value. Never valid for a real block. + ToneUnspecified = metadatav1.Tone_TONE_UNSPECIFIED + // ToneNeutral is ordinary, unemphasized content. + ToneNeutral = metadatav1.Tone_TONE_NEUTRAL + // ToneInfo is informational emphasis without implying success or failure. + ToneInfo = metadatav1.Tone_TONE_INFO + // ToneSuccess is a successful or healthy condition. + ToneSuccess = metadatav1.Tone_TONE_SUCCESS + // ToneWarning is a cautionary condition that is not yet a failure. + ToneWarning = metadatav1.Tone_TONE_WARNING + // ToneDanger is a failure or high-severity condition. + ToneDanger = metadatav1.Tone_TONE_DANGER +) + +// toneByName maps the lowercase token names plugins and config use onto +// Tone values. Unknown names fall back to ToneNeutral. +var toneByName = map[string]Tone{ + "neutral": ToneNeutral, + "info": ToneInfo, + "success": ToneSuccess, + "warning": ToneWarning, + "danger": ToneDanger, +} + +// ToneByName resolves a lowercase tone token name. ok is false for an +// unknown name; the returned Tone is then ToneNeutral so a caller that +// ignores ok still paints something sensible. +func ToneByName(name string) (Tone, bool) { + t, ok := toneByName[name] + if !ok { + return ToneNeutral, false + } + return t, true +} diff --git a/pkg/model/convert.go b/pkg/model/convert.go index 9daab40..b24f220 100644 --- a/pkg/model/convert.go +++ b/pkg/model/convert.go @@ -14,12 +14,18 @@ func capabilitiesToProto(c *Capabilities) *modelv1.Capabilities { for i, m := range c.Models { models[i] = modelSpecToProto(m) } - return &modelv1.Capabilities{ + out := &modelv1.Capabilities{ Models: models, SlashCommands: c.SlashCommands, ConfigSchema: c.ConfigSchema, SupportedHookPoints: c.SupportedHookPoints, + Auth: authToProto(c.Auth), + CatalogEtag: c.CatalogEtag, } + if c.CatalogFetchedAt != nil { + out.CatalogFetchedAt = timestamppb.New(*c.CatalogFetchedAt) + } + return out } // capabilitiesFromProto is capabilitiesToProto's inverse. @@ -31,12 +37,19 @@ func capabilitiesFromProto(in *modelv1.Capabilities) *Capabilities { for i, m := range in.GetModels() { models[i] = modelSpecFromProto(m) } - return &Capabilities{ + out := &Capabilities{ Models: models, SlashCommands: in.GetSlashCommands(), ConfigSchema: in.GetConfigSchema(), SupportedHookPoints: in.GetSupportedHookPoints(), + Auth: authFromProto(in.GetAuth()), + CatalogEtag: in.CatalogEtag, + } + if ts := in.GetCatalogFetchedAt(); ts != nil { + at := ts.AsTime() + out.CatalogFetchedAt = &at } + return out } // modelSpecToProto converts m into the generated wire type. @@ -55,6 +68,75 @@ func modelSpecToProto(m Spec) *modelv1.ModelSpec { Pricing: pricingToProto(m.Pricing), SupportedToolChoiceModes: m.SupportedToolChoiceModes, SupportsDocuments: m.SupportsDocuments, + + Catalog: catalogToProto(m.Catalog), + MaxContextWindow: m.MaxContextWindow, + EffectiveContextWindowPercent: m.EffectiveContextWindowPercent, + AutoCompactTokenLimit: m.AutoCompactTokenLimit, + Verbosity: verbosityToProto(m.Verbosity), + ServiceTiers: append([]string(nil), m.ServiceTiers...), + ApiBackend: m.APIBackend, + TruncationPolicy: m.TruncationPolicy, + CompHash: m.CompHash, + } +} + +// catalogToProto converts optional picker metadata into the wire type. +func catalogToProto(c *CatalogMetadata) *modelv1.CatalogMetadata { + if c == nil { + return nil + } + return &modelv1.CatalogMetadata{ + DisplayName: c.DisplayName, + Description: c.Description, + Visible: c.Visible, + Priority: c.Priority, + SupportedInApi: c.SupportedInAPI, + // Copied rather than aliased, for the same reason + // thinkingSpecToProto copies Effort.Levels: a roster value is + // typically shared across models. + Aliases: append([]string(nil), c.Aliases...), + Family: c.Family, + } +} + +// catalogFromProto is catalogToProto's inverse. +func catalogFromProto(in *modelv1.CatalogMetadata) *CatalogMetadata { + if in == nil { + return nil + } + return &CatalogMetadata{ + DisplayName: in.DisplayName, + Description: in.Description, + Visible: in.Visible, + Priority: in.Priority, + SupportedInAPI: in.SupportedInApi, + Aliases: append([]string(nil), in.GetAliases()...), + Family: in.Family, + } +} + +// verbosityToProto converts an optional verbosity control into the wire type. +func verbosityToProto(v *VerbositySpec) *modelv1.VerbositySpec { + if v == nil { + return nil + } + return &modelv1.VerbositySpec{ + Supported: v.Supported, + Levels: append([]string(nil), v.Levels...), + Default: v.Default, + } +} + +// verbosityFromProto is verbosityToProto's inverse. +func verbosityFromProto(in *modelv1.VerbositySpec) *VerbositySpec { + if in == nil { + return nil + } + return &VerbositySpec{ + Supported: in.GetSupported(), + Levels: append([]string(nil), in.GetLevels()...), + Default: in.Default, } } @@ -76,15 +158,27 @@ func modelSpecFromProto(in *modelv1.ModelSpec) Spec { Pricing: pricingFromProto(in.GetPricing()), SupportedToolChoiceModes: in.GetSupportedToolChoiceModes(), SupportsDocuments: in.GetSupportsDocuments(), + + Catalog: catalogFromProto(in.GetCatalog()), + MaxContextWindow: in.MaxContextWindow, + EffectiveContextWindowPercent: in.EffectiveContextWindowPercent, + AutoCompactTokenLimit: in.AutoCompactTokenLimit, + Verbosity: verbosityFromProto(in.GetVerbosity()), + ServiceTiers: append([]string(nil), in.GetServiceTiers()...), + APIBackend: in.ApiBackend, + TruncationPolicy: in.TruncationPolicy, + CompHash: in.CompHash, } } // thinkingSpecToProto converts t into the generated wire type. func thinkingSpecToProto(t ThinkingSpec) *modelv1.ThinkingSpec { out := &modelv1.ThinkingSpec{ - Supported: t.Supported, - AdaptiveByDefault: t.AdaptiveByDefault, - Disable: t.Disable, + Supported: t.Supported, + AdaptiveByDefault: t.AdaptiveByDefault, + Disable: t.Disable, + SupportsReasoningSummary: t.SupportsReasoningSummary, + DefaultReasoningSummary: t.DefaultReasoningSummary, } if t.Effort != nil { out.Effort = &modelv1.EffortControl{ @@ -119,9 +213,11 @@ func thinkingSpecFromProto(in *modelv1.ThinkingSpec) ThinkingSpec { return ThinkingSpec{} } out := ThinkingSpec{ - Supported: in.GetSupported(), - AdaptiveByDefault: in.GetAdaptiveByDefault(), - Disable: in.GetDisable(), + Supported: in.GetSupported(), + AdaptiveByDefault: in.GetAdaptiveByDefault(), + Disable: in.GetDisable(), + SupportsReasoningSummary: in.SupportsReasoningSummary, + DefaultReasoningSummary: in.DefaultReasoningSummary, } if e := in.GetEffort(); e != nil { out.Effort = &EffortControl{ @@ -173,9 +269,10 @@ func pricingToProto(p Pricing) *modelv1.Pricing { tiers[i] = pricingTierToProto(t) } return &modelv1.Pricing{ - Currency: p.Currency, - Free: p.Free, - Tiers: tiers, + Currency: p.Currency, + Free: p.Free, + Tiers: tiers, + SourceUnit: p.SourceUnit, } } @@ -189,9 +286,10 @@ func pricingFromProto(in *modelv1.Pricing) Pricing { tiers[i] = pricingTierFromProto(t) } return Pricing{ - Currency: in.GetCurrency(), - Free: in.GetFree(), - Tiers: tiers, + Currency: in.GetCurrency(), + Free: in.GetFree(), + Tiers: tiers, + SourceUnit: in.SourceUnit, } } @@ -213,6 +311,8 @@ func pricingTierToProto(t PricingTier) *modelv1.PricingTier { out.BatchOutputPerMtok = t.BatchOutputPerMtok out.InputTokensFrom = t.InputTokensFrom out.InputTokensUntil = t.InputTokensUntil + out.ImageInputPerMtok = t.ImageInputPerMtok + out.AudioInputPerMtok = t.AudioInputPerMtok return out } @@ -230,6 +330,8 @@ func pricingTierFromProto(in *modelv1.PricingTier) PricingTier { BatchOutputPerMtok: in.BatchOutputPerMtok, InputTokensFrom: in.InputTokensFrom, InputTokensUntil: in.InputTokensUntil, + ImageInputPerMtok: in.ImageInputPerMtok, + AudioInputPerMtok: in.AudioInputPerMtok, } if ef := in.GetEffectiveFrom(); ef != nil { t := ef.AsTime() @@ -246,13 +348,57 @@ func pricingTierFromProto(in *modelv1.PricingTier) PricingTier { // StreamEvent Usage variant. func usageToProto(u Usage) *modelv1.Usage { return &modelv1.Usage{ - InputTokens: u.InputTokens, - OutputTokens: u.OutputTokens, - CacheReadTokens: u.CacheReadTokens, - CacheWriteTokens: u.CacheWriteTokens, - ReasoningTokens: u.ReasoningTokens, - RateLimits: rateLimitsToProto(u.RateLimits), + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CacheReadTokens: u.CacheReadTokens, + CacheWriteTokens: u.CacheWriteTokens, + ReasoningTokens: u.ReasoningTokens, + RateLimits: rateLimitsToProto(u.RateLimits), + VendorCost: vendorCostToProto(u.VendorCost), + VendorTotalTokens: u.VendorTotalTokens, + Components: componentsToProto(u.Components), + ReasoningAlreadyCounted: u.ReasoningAlreadyCounted, + } +} + +// vendorCostToProto converts an optional vendor cost into the wire type. +func vendorCostToProto(c *VendorCost) *modelv1.VendorCost { + if c == nil { + return nil + } + return &modelv1.VendorCost{Amount: c.Amount, Unit: c.Unit, Currency: c.Currency} +} + +// vendorCostFromProto is vendorCostToProto's inverse. +func vendorCostFromProto(in *modelv1.VendorCost) *VendorCost { + if in == nil { + return nil } + return &VendorCost{Amount: in.GetAmount(), Unit: in.GetUnit(), Currency: in.Currency} +} + +// componentsToProto converts vendor-defined counters into wire types. +func componentsToProto(in []UsageComponent) []*modelv1.UsageComponent { + if len(in) == 0 { + return nil + } + out := make([]*modelv1.UsageComponent, len(in)) + for i, c := range in { + out[i] = &modelv1.UsageComponent{Name: c.Name, Value: c.Value} + } + return out +} + +// componentsFromProto is componentsToProto's inverse. +func componentsFromProto(in []*modelv1.UsageComponent) []UsageComponent { + if len(in) == 0 { + return nil + } + out := make([]UsageComponent, len(in)) + for i, c := range in { + out[i] = UsageComponent{Name: c.GetName(), Value: c.GetValue()} + } + return out } // rateLimitsToProto converts each snapshot into the generated wire type. @@ -263,9 +409,14 @@ func rateLimitsToProto(in []RateLimitSnapshot) []*modelv1.RateLimitSnapshot { out := make([]*modelv1.RateLimitSnapshot, len(in)) for i, r := range in { snap := &modelv1.RateLimitSnapshot{ - Kind: r.Kind, - Remaining: r.Remaining, - Limit: r.Limit, + Kind: r.Kind, + Remaining: r.Remaining, + Limit: r.Limit, + LimitId: r.LimitID, + LimitName: r.LimitName, + WindowRole: r.WindowRole, + UsedPercent: r.UsedPercent, + WindowSeconds: r.WindowSeconds, } if r.ResetAt != nil { snap.ResetAt = timestamppb.New(*r.ResetAt) @@ -283,9 +434,14 @@ func rateLimitsFromProto(in []*modelv1.RateLimitSnapshot) []RateLimitSnapshot { out := make([]RateLimitSnapshot, len(in)) for i, r := range in { snap := RateLimitSnapshot{ - Kind: r.GetKind(), - Remaining: r.Remaining, - Limit: r.Limit, + Kind: r.GetKind(), + Remaining: r.Remaining, + Limit: r.Limit, + LimitID: r.LimitId, + LimitName: r.LimitName, + WindowRole: r.GetWindowRole(), + UsedPercent: r.UsedPercent, + WindowSeconds: r.WindowSeconds, } if ts := r.GetResetAt(); ts != nil { at := ts.AsTime() @@ -302,11 +458,75 @@ func usageFromProto(in *modelv1.Usage) Usage { return Usage{} } return Usage{ - InputTokens: in.GetInputTokens(), - OutputTokens: in.GetOutputTokens(), - CacheReadTokens: in.CacheReadTokens, - CacheWriteTokens: in.CacheWriteTokens, - ReasoningTokens: in.ReasoningTokens, - RateLimits: rateLimitsFromProto(in.GetRateLimits()), + InputTokens: in.GetInputTokens(), + OutputTokens: in.GetOutputTokens(), + CacheReadTokens: in.CacheReadTokens, + CacheWriteTokens: in.CacheWriteTokens, + ReasoningTokens: in.ReasoningTokens, + RateLimits: rateLimitsFromProto(in.GetRateLimits()), + VendorCost: vendorCostFromProto(in.GetVendorCost()), + VendorTotalTokens: in.VendorTotalTokens, + Components: componentsFromProto(in.GetComponents()), + ReasoningAlreadyCounted: in.ReasoningAlreadyCounted, + } +} + +// accountToProto converts an account snapshot into the wire type. +func accountToProto(a AccountSnapshot) *modelv1.AccountSnapshot { + out := &modelv1.AccountSnapshot{ + Method: a.Method, + Metering: a.Metering, + Plan: a.Plan, + Labels: a.Labels, + Quotas: rateLimitsToProto(a.Quotas), + } + if a.FetchedAt != nil { + out.FetchedAt = timestamppb.New(*a.FetchedAt) + } + return out +} + +// accountFromProto is accountToProto's inverse. +func accountFromProto(in *modelv1.AccountSnapshot) AccountSnapshot { + if in == nil { + return AccountSnapshot{} + } + out := AccountSnapshot{ + Method: in.GetMethod(), + Metering: in.GetMetering(), + Plan: in.Plan, + Labels: in.GetLabels(), + Quotas: rateLimitsFromProto(in.GetQuotas()), + } + if ts := in.GetFetchedAt(); ts != nil { + at := ts.AsTime() + out.FetchedAt = &at + } + return out +} + +// authToProto converts an optional auth descriptor into the wire type. +func authToProto(a *AuthDescriptor) *modelv1.AuthDescriptor { + if a == nil { + return nil + } + return &modelv1.AuthDescriptor{ + Method: a.Method, + Metering: a.Metering, + Plan: a.Plan, + Labels: a.Labels, + } +} + +// authFromProto is authToProto's inverse. +func authFromProto(in *modelv1.AuthDescriptor) *AuthDescriptor { + if in == nil { + return nil + } + return &AuthDescriptor{ + Method: in.GetMethod(), + Metering: in.GetMetering(), + Plan: in.Plan, + Labels: in.GetLabels(), } } diff --git a/pkg/model/convert_test.go b/pkg/model/convert_test.go index 768faf3..37b2e9e 100644 --- a/pkg/model/convert_test.go +++ b/pkg/model/convert_test.go @@ -463,3 +463,160 @@ func TestConvert_UsageWithoutRateLimitsStaysNil(t *testing.T) { t.Errorf("RateLimits = %+v, want nil", back.RateLimits) } } + +// TestUsageRoundTrip_vendorFields covers the fields added for +// vendor-reported cost and metering. A round trip is the real assertion: +// convert.go is the only place these cross the wire boundary, so a field +// dropped in either direction is silent data loss for every provider. +func TestUsageRoundTrip_vendorFields(t *testing.T) { + t.Parallel() + + currency := "USD" + total := int64(999) + counted := true + want := model.Usage{ + InputTokens: 10, + OutputTokens: 5, + VendorCost: &model.VendorCost{ + Amount: "24100000", + Unit: "xai_ticks_1e10", + Currency: ¤cy, + }, + VendorTotalTokens: &total, + Components: []model.UsageComponent{ + {Name: "input_image_tokens", Value: 128}, + {Name: "num_sources_used", Value: 3}, + }, + ReasoningAlreadyCounted: &counted, + } + + got := model.UsageFromProtoForTest(model.UsageToProtoForTest(want)) + + if got.VendorCost == nil { + t.Fatal("VendorCost was dropped in the round trip") + } + if got.VendorCost.Amount != "24100000" || got.VendorCost.Unit != "xai_ticks_1e10" { + t.Errorf("VendorCost = %+v, want the amount and unit preserved verbatim", got.VendorCost) + } + if got.VendorCost.Currency == nil || *got.VendorCost.Currency != "USD" { + t.Errorf("VendorCost.Currency = %v, want USD", got.VendorCost.Currency) + } + if got.VendorTotalTokens == nil || *got.VendorTotalTokens != 999 { + t.Errorf("VendorTotalTokens = %v, want 999", got.VendorTotalTokens) + } + if len(got.Components) != 2 || got.Components[0].Name != "input_image_tokens" || got.Components[0].Value != 128 { + t.Errorf("Components = %+v, want both counters in order", got.Components) + } + if got.ReasoningAlreadyCounted == nil || !*got.ReasoningAlreadyCounted { + t.Errorf("ReasoningAlreadyCounted = %v, want true", got.ReasoningAlreadyCounted) + } +} + +// TestRateLimitRoundTrip_subscriptionFields covers the fields that exist +// so a percentage-only vendor stops faking Limit=100. +func TestRateLimitRoundTrip_subscriptionFields(t *testing.T) { + t.Parallel() + + id, name := "codex", "Codex weekly" + pct := 62.5 + window := int64(18000) + want := model.Usage{RateLimits: []model.RateLimitSnapshot{{ + Kind: modelv1.RateLimitKind_RATE_LIMIT_KIND_CREDITS, + LimitID: &id, + LimitName: &name, + WindowRole: modelv1.WindowRole_WINDOW_ROLE_PRIMARY, + UsedPercent: &pct, + WindowSeconds: &window, + }}} + + got := model.UsageFromProtoForTest(model.UsageToProtoForTest(want)) + + if len(got.RateLimits) != 1 { + t.Fatalf("RateLimits has %d entries, want 1", len(got.RateLimits)) + } + r := got.RateLimits[0] + if r.Kind != modelv1.RateLimitKind_RATE_LIMIT_KIND_CREDITS { + t.Errorf("Kind = %v, want CREDITS", r.Kind) + } + if r.WindowRole != modelv1.WindowRole_WINDOW_ROLE_PRIMARY { + t.Errorf("WindowRole = %v, want PRIMARY", r.WindowRole) + } + if r.LimitID == nil || *r.LimitID != "codex" { + t.Errorf("LimitID = %v, want codex", r.LimitID) + } + if r.LimitName == nil || *r.LimitName != "Codex weekly" { + t.Errorf("LimitName = %v, want %q", r.LimitName, "Codex weekly") + } + if r.UsedPercent == nil || *r.UsedPercent != 62.5 { + t.Errorf("UsedPercent = %v, want 62.5", r.UsedPercent) + } + if r.WindowSeconds == nil || *r.WindowSeconds != 18000 { + t.Errorf("WindowSeconds = %v, want 18000", r.WindowSeconds) + } + // Absolute counters stay unset: this vendor published only a + // percentage, and deriving Remaining/Limit from it is exactly the + // lie these fields were added to retire. + if r.Remaining != nil || r.Limit != nil { + t.Errorf("Remaining/Limit = %v/%v, want both nil for a percentage-only vendor", r.Remaining, r.Limit) + } +} + +// TestAccountSnapshotRoundTrip covers the GetAccount payload. The quota +// list is the load-bearing part: it reuses RateLimitSnapshot precisely so +// pool headroom and per-completion budgets cannot drift into two shapes a +// frontend has to render twice. +func TestAccountSnapshotRoundTrip(t *testing.T) { + t.Parallel() + + plan := "SuperGrok" + fetched := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + pct := 41.0 + want := model.AccountSnapshot{ + Method: modelv1.AuthMethod_AUTH_METHOD_PRODUCT_SESSION, + Metering: modelv1.MeteringDomain_METERING_DOMAIN_SUBSCRIPTION_POOL, + Plan: &plan, + Labels: map[string]string{"account": "s***@example.com"}, + Quotas: []model.RateLimitSnapshot{{ + Kind: modelv1.RateLimitKind_RATE_LIMIT_KIND_TOKENS, + WindowRole: modelv1.WindowRole_WINDOW_ROLE_PRIMARY, + UsedPercent: &pct, + }}, + FetchedAt: &fetched, + } + + got := model.AccountFromProtoForTest(model.AccountToProtoForTest(want)) + + if got.Method != want.Method || got.Metering != want.Metering { + t.Errorf("method/metering = %v/%v, want %v/%v", got.Method, got.Metering, want.Method, want.Metering) + } + if got.Plan == nil || *got.Plan != "SuperGrok" { + t.Errorf("Plan = %v, want SuperGrok", got.Plan) + } + if got.Labels["account"] != "s***@example.com" { + t.Errorf("Labels = %v, want the redacted handle preserved", got.Labels) + } + if len(got.Quotas) != 1 || got.Quotas[0].UsedPercent == nil || *got.Quotas[0].UsedPercent != 41.0 { + t.Errorf("Quotas = %+v, want one entry at 41%%", got.Quotas) + } + if got.FetchedAt == nil || !got.FetchedAt.Equal(fetched) { + t.Errorf("FetchedAt = %v, want %v", got.FetchedAt, fetched) + } +} + +// TestAccountSnapshotRoundTrip_zeroValue asserts an empty snapshot stays +// empty rather than acquiring a zero timestamp — "never fetched" and +// "fetched at the epoch" must stay distinguishable. +func TestAccountSnapshotRoundTrip_zeroValue(t *testing.T) { + t.Parallel() + + got := model.AccountFromProtoForTest(model.AccountToProtoForTest(model.AccountSnapshot{})) + if got.FetchedAt != nil { + t.Errorf("FetchedAt = %v, want nil for a snapshot that never set it", got.FetchedAt) + } + if got.Plan != nil { + t.Errorf("Plan = %v, want nil", got.Plan) + } + if len(got.Quotas) != 0 { + t.Errorf("Quotas = %v, want empty", got.Quotas) + } +} diff --git a/pkg/model/export_test.go b/pkg/model/export_test.go index 4e17fd5..3db3301 100644 --- a/pkg/model/export_test.go +++ b/pkg/model/export_test.go @@ -23,6 +23,8 @@ var ( PricingTierFromProtoForTest = pricingTierFromProto UsageToProtoForTest = usageToProto UsageFromProtoForTest = usageFromProto + AccountToProtoForTest = accountToProto + AccountFromProtoForTest = accountFromProto ModelErrorFromProtoForTest = modelErrorFromProto ) diff --git a/pkg/model/model.go b/pkg/model/model.go index 9dd5f0f..825ac61 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -93,6 +93,50 @@ type Renderer interface { Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) } +// Accounter is the optional interface a Provider implements to report +// live account and entitlement state, per +// docs/specifications/model/protocol.md#getaccount (MAY). +// +// Implement it when the backend meters against something an operator can +// run out of — a subscription pool, a credit balance, a plan quota — so +// the harness can show headroom before a turn strands them. A Provider +// that does not implement it returns codes.Unimplemented, which the +// kernel treats as "no account state", not an error. +// +// Do not implement this by counting tokens locally: the point is what +// the vendor says is left, and a locally-derived figure is the +// synthesized value RateLimitSnapshot's own contract forbids. +type Accounter interface { + // GetAccount reads current account state from the vendor. Called on + // demand rather than cached by the kernel, so an implementation + // SHOULD apply its own short cache if the upstream call is + // expensive or rate-limited. + GetAccount(ctx context.Context) (AccountSnapshot, error) +} + +// AccountSnapshot is the live account and entitlement state behind a +// provider's credential. +type AccountSnapshot struct { + // Method is the credential shape in use. + Method modelv1.AuthMethod + // Metering is what completions are charged against. + Metering modelv1.MeteringDomain + // Plan is the vendor's plan name, where a subscription names one. + // Display only. + Plan *string + // Labels are non-secret, vendor-defined labels — a redacted account + // handle, a region, an organization name. MUST NOT carry key + // material, tokens, or a full account identifier. + Labels map[string]string + // Quotas are the budgets the vendor publishes for this account + // outside a completion. MAY be empty. + Quotas []RateLimitSnapshot + // FetchedAt is when this snapshot was read from the vendor, so a + // frontend can show how stale it is rather than presenting a cached + // reading as live. + FetchedAt *time.Time +} + // Capabilities is GetCapabilities' response payload, per // docs/specifications/model/data-types.md#modelspec and // docs/specifications/model/data-types.md#capabilitiessupported_hook_points @@ -112,6 +156,38 @@ type Capabilities struct { // SupportedHookPoints declares which hook points this plugin can serve // via HookSubscriberService.DispatchHook. MAY be empty. SupportedHookPoints []commonv1.HookPoint + // Auth describes how this plugin authenticated and which pool it + // meters against. Nil for a provider with one credential shape and + // nothing to disambiguate. + Auth *AuthDescriptor + // CatalogEtag is the vendor's version identifier for the model + // catalog this roster was built from, when the provider fetched one. + // Reported so staleness is detectable; the kernel does not yet + // re-fetch capabilities on a mismatch. + CatalogEtag *string + // CatalogFetchedAt is when this roster was fetched. Nil for a + // hand-written roster compiled into the plugin — which is the + // distinction it exists to make: a static roster is never stale. + CatalogFetchedAt *time.Time +} + +// AuthDescriptor is the non-secret description of how a plugin is +// authenticated. +// +// Nothing here may be a credential or derived from one in a way that +// leaks it: no key material, no token, no full account identifier. That +// applies to Labels too. +type AuthDescriptor struct { + // Method is the credential shape in use. + Method modelv1.AuthMethod + // Metering is what completions are charged against. + Metering modelv1.MeteringDomain + // Plan is the vendor's plan name, where a subscription names one. + // Display only; the kernel never routes on it. + Plan *string + // Labels are additional non-secret, vendor-defined labels — a + // redacted account handle, a region, an organization name. + Labels map[string]string } // Spec describes one model this provider can serve, per @@ -156,6 +232,77 @@ type Spec struct { // SupportsDocuments reports whether this model accepts a // DocumentBlock content block. SupportsDocuments bool + + // Catalog is human-facing metadata for a model picker. Nil for a + // hand-written roster with nothing to say beyond the id. Purely + // descriptive: a kernel ignoring it routes and bills identically. + Catalog *CatalogMetadata + // MaxContextWindow is the largest window this model can be + // configured with, where the vendor exposes a ceiling above + // ContextWindow. ContextWindow remains what the kernel budgets + // against. + MaxContextWindow *int64 + // EffectiveContextWindowPercent is the fraction of ContextWindow, + // 0-100, usable after the vendor's own fixed overhead. Nil means the + // whole window is usable. + EffectiveContextWindowPercent *float64 + // AutoCompactTokenLimit is the assembled-token count at which the + // vendor recommends compacting. Advisory only. + AutoCompactTokenLimit *int64 + // Verbosity is the model's output-verbosity control, where it + // exposes one distinct from thinking effort. + Verbosity *VerbositySpec + // ServiceTiers are the tiers this model can be served at, in the + // vendor's naming. Empty means no tier choice. + ServiceTiers []string + // APIBackend names which vendor API surface serves this model + // ("chat_completions", "responses"). Opaque to the kernel. + APIBackend *string + // TruncationPolicy is the vendor's policy name for truncating + // oversized tool output. Opaque to the kernel. + TruncationPolicy *string + // CompHash is the vendor's compaction-compatibility identifier. + // Models sharing a value can consume each other's compacted history. + CompHash *string +} + +// CatalogMetadata is the human-facing description of a model — what a +// picker shows, not what the kernel routes on. +type CatalogMetadata struct { + // DisplayName is the model's display name ("Grok 4.5"). Nil means a + // frontend falls back to Spec.ID. + DisplayName *string + // Description is a one-line description of what the model is for. + Description *string + // Visible reports whether a picker should offer this model by + // default. Nil means visible. + Visible *bool + // Priority is a sort weight for a picker, higher first. Nil means + // unranked. + Priority *int32 + // SupportedInAPI reports whether an API key can reach this model, as + // opposed to only a product session. With AuthDescriptor.Method it + // lets a frontend hide models the current credential cannot use. + SupportedInAPI *bool + // Aliases are other ids resolving to this same model. Do NOT also + // publish an alias as its own Spec — that is what makes one model + // look like several. + Aliases []string + // Family groups variants differing only by size or revision. + Family *string +} + +// VerbositySpec declares a model's output-verbosity control — answer +// length, as distinct from ThinkingSpec's reasoning depth. +type VerbositySpec struct { + // Supported reports whether this model accepts a verbosity setting. + // When false, Levels MUST be empty and Default nil. + Supported bool + // Levels are the accepted level names, least to most verbose. + Levels []string + // Default is the level applied when a request names none. MUST be + // one of Levels when set. + Default *string } // ThinkingSpec describes one model's extended-reasoning capability, per @@ -187,6 +334,13 @@ type ThinkingSpec struct { // Disable reports whether, and when, reasoning can be turned off. MUST // be set when Supported is true. Disable modelv1.ThinkingDisableSupport + // SupportsReasoningSummary reports whether this model can emit a + // reasoning summary distinct from its raw reasoning stream. + SupportsReasoningSummary *bool + // DefaultReasoningSummary is the summary mode applied when a request + // names none ("auto", "concise", "detailed"). Meaningless unless + // SupportsReasoningSummary is true. + DefaultReasoningSummary *string } // EffortControl declares that a model accepts a named reasoning-effort @@ -274,6 +428,12 @@ type Pricing struct { // (timestamp, input_token_count) pair — see capabilities.go's // validatePricing for the overlap check NewCapabilities performs. Tiers []PricingTier + // SourceUnit records the vendor's own pricing unit these rates were + // converted from, when the adapter converted. Audit only — the + // kernel bills from the per-MTok rates regardless. Set it when you + // convert, so a ledger figure disagreeing with an invoice can be + // traced to the rate or the arithmetic. + SourceUnit *string } // PricingTier is one time-bounded, input-size-bounded rate within a @@ -313,6 +473,12 @@ type PricingTier struct { // InputTokensUntil is the input-token count this tier stops applying // to, exclusive. Nil means unbounded above. InputTokensUntil *int64 + // ImageInputPerMtok is the price per million image input tokens, + // where the vendor rates image input separately. Nil means image + // input bills at InputPerMtok. + ImageInputPerMtok *float64 + // AudioInputPerMtok is the same for audio input. + AudioInputPerMtok *float64 } // Usage carries token accounting for one completion, per @@ -339,6 +505,64 @@ type Usage struct { // completion. MAY be empty; a Provider MUST NOT synthesize a snapshot // from its own bookkeeping — only report what the vendor published. RateLimits []RateLimitSnapshot + + // VendorCost is what the vendor says this completion cost, in the + // vendor's own denomination. Nil when the vendor reports no figure. + // + // Reported, never authoritative: the kernel still computes cost_usd + // from the token counts above and the model's PricingTier, and every + // rollup and budget reads that. Set this when the vendor publishes a + // price so the two can be reconciled — do not compute it yourself. + VendorCost *VendorCost + + // VendorTotalTokens is the vendor's own total-token figure, when it + // publishes one that is not simply the sum of the parts above. Leave + // nil rather than filling it in by addition; a derived value here + // destroys the disagreement the field exists to expose. + VendorTotalTokens *int64 + + // Components are vendor-defined counters with no first-class field: + // per-modality input tokens, accepted/rejected prediction tokens, + // hosted-tool source counts. The kernel stores and surfaces these + // without interpreting them. + Components []UsageComponent + + // ReasoningAlreadyCounted reports that ReasoningTokens is already + // included in OutputTokens, because the vendor said so out of band. + // Nil means "not stated" and the kernel applies the documented + // default (a distinct count). Set it only on an explicit vendor + // signal — the field exists to stop the kernel double-counting, so a + // guess defeats it. + ReasoningAlreadyCounted *bool +} + +// VendorCost is a vendor's own price for one completion, in whatever +// unit that vendor bills in. +type VendorCost struct { + // Amount is the cost as an exact decimal string ("0.00241", + // "24100000"). A string rather than a float because these are exact + // monetary quantities that binary floating point cannot represent + // exactly. MUST parse as a decimal number, with no currency symbol, + // separators, or exponent. + Amount string + // Unit names the vendor's denomination: "usd", "xai_ticks_1e10". + // The kernel never converts between units. + Unit string + // Currency is the ISO 4217 code, when Unit is currency-denominated + // and the vendor bills in something other than USD. + Currency *string +} + +// UsageComponent is one vendor-defined counter the protocol has no typed +// field for. +type UsageComponent struct { + // Name is the counter's vendor-facing name, verbatim + // ("input_image_tokens", "num_sources_used"). MUST be set and unique + // within one Usage. + Name string + // Value is the counter's value. Not necessarily a token count — + // "num_sources_used" counts documents. + Value int64 } // RateLimitSnapshot is one of the vendor's rate-limit budgets as of one @@ -356,4 +580,30 @@ type RateLimitSnapshot struct { Limit *int64 // ResetAt is when this budget next resets. ResetAt *time.Time + // LimitID is the vendor's stable identifier for this budget, where + // it names one ("codex", "codex_other"). It lets two snapshots of + // the same budget be correlated across completions even when Kind + // and WindowRole match. + LimitID *string + // LimitName is a human-facing label, when the vendor supplies one + // worth showing. Never synthesize it from LimitID — a frontend falls + // back to Kind and WindowRole perfectly well, and an invented label + // reads as authoritative. + LimitName *string + // WindowRole distinguishes the several budgets a subscription + // product meters at once — which is the headline limit and which + // constrain bursts inside it. + WindowRole modelv1.WindowRole + // UsedPercent is how much of this budget is spent, 0-100, for + // products that publish only a percentage. + // + // Set this instead of faking Limit=100 and Remaining=100-percent to + // fit the absolute fields. A vendor publishing real counts sets + // Remaining/Limit and leaves this nil; one publishing a percentage + // sets this and leaves those nil. Never derive one form from the + // other. + UsedPercent *float64 + // WindowSeconds is the budget window's length. With ResetAt it lets + // a frontend say "5 hours" rather than only "resets at 14:00". + WindowSeconds *int64 } diff --git a/pkg/model/modeltest/runbinary_integration_test.go b/pkg/model/modeltest/runbinary_integration_test.go index 4a9d65b..9d68c00 100644 --- a/pkg/model/modeltest/runbinary_integration_test.go +++ b/pkg/model/modeltest/runbinary_integration_test.go @@ -3,32 +3,12 @@ package modeltest_test import ( - "os" - "os/exec" "path/filepath" "testing" "github.com/pluggableharness/agent/pkg/model/modeltest" ) -// TestRunBinary_againstTheExampleProvider drives the conformance suite -// through a real handshake and subprocess, against a binary built from -// examples/provider. -// -// This is the only path that exercises a plugin's own main() wiring — its -// handshake config, its Serve call, its identity stamping — none of which -// the in-process mode touches. It is also the mode a plugin written in -// another language would be checked by, since it speaks nothing but the -// wire protocol. -// -// The example is used as the subject because it is already this -// repository's proof that pkg/ works from outside the main module; -// running the suite against it closes the loop. -func TestRunBinary_againstTheExampleProvider(t *testing.T) { - binary := buildExampleProvider(t) - modeltest.RunBinary(t, binary) -} - // TestRunBinary_reportsALaunchFailureDistinctly asserts that a binary // which cannot start is reported as a launch error rather than as a // conformance violation. The two are genuinely different: a binary that @@ -41,27 +21,3 @@ func TestRunBinary_reportsALaunchFailureDistinctly(t *testing.T) { t.Fatal("CheckBinary() = nil error for a missing binary, want a launch failure") } } - -// buildExampleProvider compiles examples/provider into the repo's bin/ -// and returns the path. -// -// bin/, not t.TempDir(): the project CLAUDE.md's "bin/ only, no -// exceptions" rule covers test fixtures too, even where a temp dir would -// be the obvious choice. -func buildExampleProvider(t *testing.T) string { - t.Helper() - - root, err := filepath.Abs(filepath.Join("..", "..", "..")) - if err != nil { - t.Fatalf("resolve repo root: %v", err) - } - out := filepath.Join(root, "bin", "modeltest-example-provider") - - cmd := exec.CommandContext(t.Context(), "go", "build", "-o", out, ".") - cmd.Dir = filepath.Join(root, "examples", "provider") - if combined, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("building the example provider: %v\n%s", err, combined) - } - t.Cleanup(func() { _ = os.Remove(out) }) - return out -} diff --git a/pkg/model/proto/v1/events.pb.go b/pkg/model/proto/v1/events.pb.go index fd49255..64c1d24 100644 --- a/pkg/model/proto/v1/events.pb.go +++ b/pkg/model/proto/v1/events.pb.go @@ -103,6 +103,120 @@ func (StopReason) EnumDescriptor() ([]byte, []int) { return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0} } +// ThinkingChannel distinguishes a vendor's reasoning streams. +type StreamEvent_ThinkingChannel int32 + +const ( + // The vendor draws no distinction between reasoning streams. + StreamEvent_THINKING_CHANNEL_UNSPECIFIED StreamEvent_ThinkingChannel = 0 + // The model's raw reasoning output. + StreamEvent_THINKING_CHANNEL_CONTENT StreamEvent_ThinkingChannel = 1 + // A vendor-generated readable summary of the reasoning, which is + // typically what a frontend should show when both are present. + StreamEvent_THINKING_CHANNEL_SUMMARY StreamEvent_ThinkingChannel = 2 +) + +// Enum value maps for StreamEvent_ThinkingChannel. +var ( + StreamEvent_ThinkingChannel_name = map[int32]string{ + 0: "THINKING_CHANNEL_UNSPECIFIED", + 1: "THINKING_CHANNEL_CONTENT", + 2: "THINKING_CHANNEL_SUMMARY", + } + StreamEvent_ThinkingChannel_value = map[string]int32{ + "THINKING_CHANNEL_UNSPECIFIED": 0, + "THINKING_CHANNEL_CONTENT": 1, + "THINKING_CHANNEL_SUMMARY": 2, + } +) + +func (x StreamEvent_ThinkingChannel) Enum() *StreamEvent_ThinkingChannel { + p := new(StreamEvent_ThinkingChannel) + *p = x + return p +} + +func (x StreamEvent_ThinkingChannel) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (StreamEvent_ThinkingChannel) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_events_proto_enumTypes[1].Descriptor() +} + +func (StreamEvent_ThinkingChannel) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_events_proto_enumTypes[1] +} + +func (x StreamEvent_ThinkingChannel) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use StreamEvent_ThinkingChannel.Descriptor instead. +func (StreamEvent_ThinkingChannel) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 0} +} + +// SafetyKind names the vendor interventions a SafetyNotice reports. +type StreamEvent_SafetyKind int32 + +const ( + // Zero value. Never valid on a real notice. + StreamEvent_SAFETY_KIND_UNSPECIFIED StreamEvent_SafetyKind = 0 + // Output is being buffered for review before release, so a stall is + // expected and is not a hang. + StreamEvent_SAFETY_KIND_BUFFERING StreamEvent_SafetyKind = 1 + // A moderation decision was applied to this request. + StreamEvent_SAFETY_KIND_MODERATION StreamEvent_SafetyKind = 2 + // The account must complete a challenge before the request can + // proceed. The kernel cannot satisfy this itself; surfacing it is + // what lets an operator go and do so. + StreamEvent_SAFETY_KIND_VERIFICATION_REQUIRED StreamEvent_SafetyKind = 3 +) + +// Enum value maps for StreamEvent_SafetyKind. +var ( + StreamEvent_SafetyKind_name = map[int32]string{ + 0: "SAFETY_KIND_UNSPECIFIED", + 1: "SAFETY_KIND_BUFFERING", + 2: "SAFETY_KIND_MODERATION", + 3: "SAFETY_KIND_VERIFICATION_REQUIRED", + } + StreamEvent_SafetyKind_value = map[string]int32{ + "SAFETY_KIND_UNSPECIFIED": 0, + "SAFETY_KIND_BUFFERING": 1, + "SAFETY_KIND_MODERATION": 2, + "SAFETY_KIND_VERIFICATION_REQUIRED": 3, + } +) + +func (x StreamEvent_SafetyKind) Enum() *StreamEvent_SafetyKind { + p := new(StreamEvent_SafetyKind) + *p = x + return p +} + +func (x StreamEvent_SafetyKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (StreamEvent_SafetyKind) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_events_proto_enumTypes[2].Descriptor() +} + +func (StreamEvent_SafetyKind) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_events_proto_enumTypes[2] +} + +func (x StreamEvent_SafetyKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use StreamEvent_SafetyKind.Descriptor instead. +func (StreamEvent_SafetyKind) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 1} +} + // StreamEvent is one message in the stream StreamCompletion returns, per // model.md §4. Exactly one variant is set. type StreamEvent struct { @@ -120,6 +234,8 @@ type StreamEvent struct { // *StreamEvent_Error_ // *StreamEvent_RedactedThinking_ // *StreamEvent_StreamStart_ + // *StreamEvent_Metadata + // *StreamEvent_SafetyNotice_ Event isStreamEvent_Event `protobuf_oneof:"event"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -261,6 +377,24 @@ func (x *StreamEvent) GetStreamStart() *StreamEvent_StreamStart { return nil } +func (x *StreamEvent) GetMetadata() *StreamEvent_StreamMetadata { + if x != nil { + if x, ok := x.Event.(*StreamEvent_Metadata); ok { + return x.Metadata + } + } + return nil +} + +func (x *StreamEvent) GetSafetyNotice() *StreamEvent_SafetyNotice { + if x != nil { + if x, ok := x.Event.(*StreamEvent_SafetyNotice_); ok { + return x.SafetyNotice + } + } + return nil +} + type isStreamEvent_Event interface { isStreamEvent_Event() } @@ -321,6 +455,16 @@ type StreamEvent_StreamStart_ struct { StreamStart *StreamEvent_StreamStart `protobuf:"bytes,11,opt,name=stream_start,json=streamStart,proto3,oneof"` } +type StreamEvent_Metadata struct { + // Non-content facts about how the vendor is serving this request. + Metadata *StreamEvent_StreamMetadata `protobuf:"bytes,12,opt,name=metadata,proto3,oneof"` +} + +type StreamEvent_SafetyNotice_ struct { + // The vendor is interposing on this request. + SafetyNotice *StreamEvent_SafetyNotice `protobuf:"bytes,13,opt,name=safety_notice,json=safetyNotice,proto3,oneof"` +} + func (*StreamEvent_TextDelta_) isStreamEvent_Event() {} func (*StreamEvent_ThinkingDelta_) isStreamEvent_Event() {} @@ -343,6 +487,10 @@ func (*StreamEvent_RedactedThinking_) isStreamEvent_Event() {} func (*StreamEvent_StreamStart_) isStreamEvent_Event() {} +func (*StreamEvent_Metadata) isStreamEvent_Event() {} + +func (*StreamEvent_SafetyNotice_) isStreamEvent_Event() {} + // StreamStart carries the vendor's own identifier for this request, as // soon as the adapter learns it — normally from response headers, // before any content streams. @@ -358,8 +506,21 @@ type StreamEvent_StreamStart struct { // `request-id` header, an OpenAI `x-request-id`). Opaque to the // kernel: logged and surfaced, never parsed. ProviderRequestId string `protobuf:"bytes,1,opt,name=provider_request_id,json=providerRequestId,proto3" json:"provider_request_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Every other handle this request is known by, keyed by the + // vendor's own name for it ("x-request-id", "response_id", + // "cf-ray"). + // + // One id is not enough in practice: vendors log the same request + // under several, and a support conversation asks for whichever one + // that vendor's own tooling indexes. provider_request_id stays the + // single canonical handle; this carries the rest rather than forcing + // an adapter to choose which to discard. + // + // The kernel MUST serialize this with sorted keys wherever it + // reaches a persisted payload (.claude/rules/determinism.md). + CorrelationIds map[string]string `protobuf:"bytes,2,rep,name=correlation_ids,json=correlationIds,proto3" json:"correlation_ids,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StreamEvent_StreamStart) Reset() { @@ -399,6 +560,174 @@ func (x *StreamEvent_StreamStart) GetProviderRequestId() string { return "" } +func (x *StreamEvent_StreamStart) GetCorrelationIds() map[string]string { + if x != nil { + return x.CorrelationIds + } + return nil +} + +// StreamMetadata carries non-content facts about how the vendor is +// serving this request: which model actually answered, which build, +// which tier, and whatever budget state the response headers exposed. +// +// Separate from StreamStart because these arrive on a different +// schedule. StreamStart is emitted once when the vendor accepts the +// request; metadata may not be knowable until headers land, may +// change mid-stream, and MAY be emitted more than once — a later event +// supersedes an earlier one field by field, and an absent field means +// "no new information", never "cleared". +// +// This is not a block boundary. It carries no content, so a kernel +// accumulating a message MUST NOT close an open text or thinking block +// on receiving one; doing so would split a run of deltas that a vendor +// happened to interrupt with a late header. +type StreamEvent_StreamMetadata struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The model that actually served this completion, when it differs + // from the requested StreamCompletionRequest.model_id — vendors + // remap for safety routing, capacity, and deprecation + // (`grok-4` resolving to `grok-4.3`). + // + // Load-bearing for trust, not just for ops. A vendor silently + // serving a different model is how an operator experiences "it got + // worse today" with nothing to point at; dropping the fact makes + // the regression unattributable and, worse, makes the kernel's own + // cost computation cite pricing for a model that never ran. + ActualModel *string `protobuf:"bytes,1,opt,name=actual_model,json=actualModel,proto3,oneof" json:"actual_model,omitempty"` + // The vendor's opaque identifier for the backend build serving this + // request (OpenAI's `system_fingerprint`). Never parsed. + SystemFingerprint *string `protobuf:"bytes,2,opt,name=system_fingerprint,json=systemFingerprint,proto3,oneof" json:"system_fingerprint,omitempty"` + // The service or speed tier this request was served at, where the + // vendor exposes tiers that differ in latency or price. + ServiceTier *string `protobuf:"bytes,3,opt,name=service_tier,json=serviceTier,proto3,oneof" json:"service_tier,omitempty"` + // Budget state as of this point in the stream, when the vendor + // publishes it in response headers rather than in the terminal usage + // payload. + // + // The same RateLimitSnapshot shape Usage carries. Reported here it + // is visible while a long completion is still running, which is the + // whole point: a limit an operator learns about only after the turn + // that exhausted it has already stranded them. + RateLimits []*RateLimitSnapshot `protobuf:"bytes,4,rep,name=rate_limits,json=rateLimits,proto3" json:"rate_limits,omitempty"` + // The context window the vendor says applies to this request, when + // it reports one that supersedes the roster's static figure + // (xAI's `x-grok-context-window`). + LiveContextWindow *int64 `protobuf:"varint,5,opt,name=live_context_window,json=liveContextWindow,proto3,oneof" json:"live_context_window,omitempty"` + // The maximum output token count the vendor says applies to this + // request, on the same terms as live_context_window. + LiveMaxOutputTokens *int64 `protobuf:"varint,6,opt,name=live_max_output_tokens,json=liveMaxOutputTokens,proto3,oneof" json:"live_max_output_tokens,omitempty"` + // The vendor's current model-catalog version, when a response + // advertises one (xAI's `x-models-etag`). A value differing from the + // one the loaded roster was built from means the catalog moved. + CatalogEtag *string `protobuf:"bytes,7,opt,name=catalog_etag,json=catalogEtag,proto3,oneof" json:"catalog_etag,omitempty"` + // Vendor-defined metadata with no typed field above. Opaque to the + // kernel, which stores and surfaces it without interpretation. + // + // The kernel MUST serialize this with sorted keys wherever it + // reaches a persisted payload (.claude/rules/determinism.md). + Attrs map[string]string `protobuf:"bytes,8,rep,name=attrs,proto3" json:"attrs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // A handle to this turn's vendor-side state, for vendors that accept + // an incremental continuation on the next request. The kernel passes + // it back as StreamCompletionRequest.sticky_turn_token. + StickyTurnToken *string `protobuf:"bytes,9,opt,name=sticky_turn_token,json=stickyTurnToken,proto3,oneof" json:"sticky_turn_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_StreamMetadata) Reset() { + *x = StreamEvent_StreamMetadata{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_StreamMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_StreamMetadata) ProtoMessage() {} + +func (x *StreamEvent_StreamMetadata) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamEvent_StreamMetadata.ProtoReflect.Descriptor instead. +func (*StreamEvent_StreamMetadata) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 1} +} + +func (x *StreamEvent_StreamMetadata) GetActualModel() string { + if x != nil && x.ActualModel != nil { + return *x.ActualModel + } + return "" +} + +func (x *StreamEvent_StreamMetadata) GetSystemFingerprint() string { + if x != nil && x.SystemFingerprint != nil { + return *x.SystemFingerprint + } + return "" +} + +func (x *StreamEvent_StreamMetadata) GetServiceTier() string { + if x != nil && x.ServiceTier != nil { + return *x.ServiceTier + } + return "" +} + +func (x *StreamEvent_StreamMetadata) GetRateLimits() []*RateLimitSnapshot { + if x != nil { + return x.RateLimits + } + return nil +} + +func (x *StreamEvent_StreamMetadata) GetLiveContextWindow() int64 { + if x != nil && x.LiveContextWindow != nil { + return *x.LiveContextWindow + } + return 0 +} + +func (x *StreamEvent_StreamMetadata) GetLiveMaxOutputTokens() int64 { + if x != nil && x.LiveMaxOutputTokens != nil { + return *x.LiveMaxOutputTokens + } + return 0 +} + +func (x *StreamEvent_StreamMetadata) GetCatalogEtag() string { + if x != nil && x.CatalogEtag != nil { + return *x.CatalogEtag + } + return "" +} + +func (x *StreamEvent_StreamMetadata) GetAttrs() map[string]string { + if x != nil { + return x.Attrs + } + return nil +} + +func (x *StreamEvent_StreamMetadata) GetStickyTurnToken() string { + if x != nil && x.StickyTurnToken != nil { + return *x.StickyTurnToken + } + return "" +} + // TextDelta carries one incremental fragment of assistant text output. // MUST be supported by every plugin, both directions (model.md §5). type StreamEvent_TextDelta struct { @@ -411,7 +740,7 @@ type StreamEvent_TextDelta struct { func (x *StreamEvent_TextDelta) Reset() { *x = StreamEvent_TextDelta{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[2] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -423,7 +752,7 @@ func (x *StreamEvent_TextDelta) String() string { func (*StreamEvent_TextDelta) ProtoMessage() {} func (x *StreamEvent_TextDelta) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[2] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -436,7 +765,7 @@ func (x *StreamEvent_TextDelta) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_TextDelta.ProtoReflect.Descriptor instead. func (*StreamEvent_TextDelta) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 1} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 2} } func (x *StreamEvent_TextDelta) GetText() string { @@ -452,14 +781,30 @@ func (x *StreamEvent_TextDelta) GetText() string { type StreamEvent_ThinkingDelta struct { state protoimpl.MessageState `protogen:"open.v1"` // The reasoning-text fragment. - Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + // Which reasoning stream this fragment belongs to, for vendors that + // emit a readable summary alongside (or instead of) raw reasoning. + // + // UNSPECIFIED means the vendor draws no distinction, which is the + // correct reading for every provider written before this field and + // the reason it is not a required field. + Channel StreamEvent_ThinkingChannel `protobuf:"varint,2,opt,name=channel,proto3,enum=pluggableharness.model.v1.StreamEvent_ThinkingChannel" json:"channel,omitempty"` + // Which reasoning part this fragment belongs to, where a vendor + // emits several in parallel. Fragments sharing a part_index within a + // channel are one block; absent means a single part. + // + // Unlike tool calls, which correlate by an explicit id, reasoning + // fragments have historically been correlated by adjacency alone — + // this makes a vendor's own part structure representable without + // changing that default. + PartIndex *int32 `protobuf:"varint,3,opt,name=part_index,json=partIndex,proto3,oneof" json:"part_index,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StreamEvent_ThinkingDelta) Reset() { *x = StreamEvent_ThinkingDelta{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[3] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -471,7 +816,7 @@ func (x *StreamEvent_ThinkingDelta) String() string { func (*StreamEvent_ThinkingDelta) ProtoMessage() {} func (x *StreamEvent_ThinkingDelta) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[3] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -484,7 +829,7 @@ func (x *StreamEvent_ThinkingDelta) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_ThinkingDelta.ProtoReflect.Descriptor instead. func (*StreamEvent_ThinkingDelta) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 2} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 3} } func (x *StreamEvent_ThinkingDelta) GetText() string { @@ -494,6 +839,94 @@ func (x *StreamEvent_ThinkingDelta) GetText() string { return "" } +func (x *StreamEvent_ThinkingDelta) GetChannel() StreamEvent_ThinkingChannel { + if x != nil { + return x.Channel + } + return StreamEvent_THINKING_CHANNEL_UNSPECIFIED +} + +func (x *StreamEvent_ThinkingDelta) GetPartIndex() int32 { + if x != nil && x.PartIndex != nil { + return *x.PartIndex + } + return 0 +} + +// SafetyNotice reports that the vendor is interposing on this request: +// holding output for review, applying a moderation decision, or +// requiring an account challenge before continuing. +// +// It carries no content and is not a block boundary. A kernel that does +// not understand a given kind MUST ignore the event rather than failing +// the turn — this exists so a frontend can explain a stall, and an +// unexplained stall is strictly worse than an unrecognized notice. +type StreamEvent_SafetyNotice struct { + state protoimpl.MessageState `protogen:"open.v1"` + // What the vendor is doing. + Kind StreamEvent_SafetyKind `protobuf:"varint,1,opt,name=kind,proto3,enum=pluggableharness.model.v1.StreamEvent_SafetyKind" json:"kind,omitempty"` + // A human-readable explanation, where the vendor supplies one worth + // showing. Never synthesized. + Message *string `protobuf:"bytes,2,opt,name=message,proto3,oneof" json:"message,omitempty"` + // Vendor-defined detail with no typed field. The kernel MUST + // serialize this with sorted keys wherever it reaches a persisted + // payload (.claude/rules/determinism.md). + Attrs map[string]string `protobuf:"bytes,3,rep,name=attrs,proto3" json:"attrs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEvent_SafetyNotice) Reset() { + *x = StreamEvent_SafetyNotice{} + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEvent_SafetyNotice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEvent_SafetyNotice) ProtoMessage() {} + +func (x *StreamEvent_SafetyNotice) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamEvent_SafetyNotice.ProtoReflect.Descriptor instead. +func (*StreamEvent_SafetyNotice) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 4} +} + +func (x *StreamEvent_SafetyNotice) GetKind() StreamEvent_SafetyKind { + if x != nil { + return x.Kind + } + return StreamEvent_SAFETY_KIND_UNSPECIFIED +} + +func (x *StreamEvent_SafetyNotice) GetMessage() string { + if x != nil && x.Message != nil { + return *x.Message + } + return "" +} + +func (x *StreamEvent_SafetyNotice) GetAttrs() map[string]string { + if x != nil { + return x.Attrs + } + return nil +} + // ThinkingSignature carries the vendor's opaque integrity token for the // reasoning block just completed. MUST be emitted if the vendor's // thinking blocks carry an integrity signature (model.md §4/§5); the @@ -509,7 +942,7 @@ type StreamEvent_ThinkingSignature struct { func (x *StreamEvent_ThinkingSignature) Reset() { *x = StreamEvent_ThinkingSignature{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[4] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -521,7 +954,7 @@ func (x *StreamEvent_ThinkingSignature) String() string { func (*StreamEvent_ThinkingSignature) ProtoMessage() {} func (x *StreamEvent_ThinkingSignature) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[4] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -534,7 +967,7 @@ func (x *StreamEvent_ThinkingSignature) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_ThinkingSignature.ProtoReflect.Descriptor instead. func (*StreamEvent_ThinkingSignature) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 3} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 5} } func (x *StreamEvent_ThinkingSignature) GetSignature() []byte { @@ -559,7 +992,7 @@ type StreamEvent_ToolCallStart struct { func (x *StreamEvent_ToolCallStart) Reset() { *x = StreamEvent_ToolCallStart{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[5] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -571,7 +1004,7 @@ func (x *StreamEvent_ToolCallStart) String() string { func (*StreamEvent_ToolCallStart) ProtoMessage() {} func (x *StreamEvent_ToolCallStart) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[5] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -584,7 +1017,7 @@ func (x *StreamEvent_ToolCallStart) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_ToolCallStart.ProtoReflect.Descriptor instead. func (*StreamEvent_ToolCallStart) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 4} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 6} } func (x *StreamEvent_ToolCallStart) GetId() string { @@ -616,7 +1049,7 @@ type StreamEvent_ToolCallDelta struct { func (x *StreamEvent_ToolCallDelta) Reset() { *x = StreamEvent_ToolCallDelta{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[6] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -628,7 +1061,7 @@ func (x *StreamEvent_ToolCallDelta) String() string { func (*StreamEvent_ToolCallDelta) ProtoMessage() {} func (x *StreamEvent_ToolCallDelta) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[6] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -641,7 +1074,7 @@ func (x *StreamEvent_ToolCallDelta) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_ToolCallDelta.ProtoReflect.Descriptor instead. func (*StreamEvent_ToolCallDelta) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 5} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 7} } func (x *StreamEvent_ToolCallDelta) GetId() string { @@ -670,7 +1103,7 @@ type StreamEvent_ToolCallDone struct { func (x *StreamEvent_ToolCallDone) Reset() { *x = StreamEvent_ToolCallDone{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[7] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -682,7 +1115,7 @@ func (x *StreamEvent_ToolCallDone) String() string { func (*StreamEvent_ToolCallDone) ProtoMessage() {} func (x *StreamEvent_ToolCallDone) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[7] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -695,7 +1128,7 @@ func (x *StreamEvent_ToolCallDone) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_ToolCallDone.ProtoReflect.Descriptor instead. func (*StreamEvent_ToolCallDone) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 6} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 8} } func (x *StreamEvent_ToolCallDone) GetId() string { @@ -714,13 +1147,22 @@ type StreamEvent_Stop struct { // reason == STOP_REASON_STOP_SEQUENCE; MUST be omitted for every // other reason. MatchedStopSequence *string `protobuf:"bytes,2,opt,name=matched_stop_sequence,json=matchedStopSequence,proto3,oneof" json:"matched_stop_sequence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the vendor reported that the model itself declared the turn + // finished, as opposed to the stream merely ending. + // + // STOP_REASON_END_TURN already covers the ordinary case; this + // separates "the model said it was done" from "nothing further + // arrived", which vendors exposing an explicit end-of-turn signal can + // distinguish and the kernel otherwise cannot. Absent means the + // vendor said nothing, not that the model failed to affirm. + ModelAffirmed *bool `protobuf:"varint,3,opt,name=model_affirmed,json=modelAffirmed,proto3,oneof" json:"model_affirmed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StreamEvent_Stop) Reset() { *x = StreamEvent_Stop{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[8] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -732,7 +1174,7 @@ func (x *StreamEvent_Stop) String() string { func (*StreamEvent_Stop) ProtoMessage() {} func (x *StreamEvent_Stop) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[8] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -745,7 +1187,7 @@ func (x *StreamEvent_Stop) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_Stop.ProtoReflect.Descriptor instead. func (*StreamEvent_Stop) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 7} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 9} } func (x *StreamEvent_Stop) GetReason() StopReason { @@ -762,6 +1204,13 @@ func (x *StreamEvent_Stop) GetMatchedStopSequence() string { return "" } +func (x *StreamEvent_Stop) GetModelAffirmed() bool { + if x != nil && x.ModelAffirmed != nil { + return *x.ModelAffirmed + } + return false +} + // Error signals the completion failed. type StreamEvent_Error struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -773,7 +1222,7 @@ type StreamEvent_Error struct { func (x *StreamEvent_Error) Reset() { *x = StreamEvent_Error{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[9] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -785,7 +1234,7 @@ func (x *StreamEvent_Error) String() string { func (*StreamEvent_Error) ProtoMessage() {} func (x *StreamEvent_Error) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[9] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -798,7 +1247,7 @@ func (x *StreamEvent_Error) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_Error.ProtoReflect.Descriptor instead. func (*StreamEvent_Error) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 8} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 10} } func (x *StreamEvent_Error) GetError() *ModelError { @@ -827,7 +1276,7 @@ type StreamEvent_RedactedThinking struct { func (x *StreamEvent_RedactedThinking) Reset() { *x = StreamEvent_RedactedThinking{} - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[10] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -839,7 +1288,7 @@ func (x *StreamEvent_RedactedThinking) String() string { func (*StreamEvent_RedactedThinking) ProtoMessage() {} func (x *StreamEvent_RedactedThinking) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_events_proto_msgTypes[10] + mi := &file_pluggableharness_model_v1_events_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -852,7 +1301,7 @@ func (x *StreamEvent_RedactedThinking) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamEvent_RedactedThinking.ProtoReflect.Descriptor instead. func (*StreamEvent_RedactedThinking) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 9} + return file_pluggableharness_model_v1_events_proto_rawDescGZIP(), []int{0, 11} } func (x *StreamEvent_RedactedThinking) GetData() []byte { @@ -866,7 +1315,7 @@ var File_pluggableharness_model_v1_events_proto protoreflect.FileDescriptor const file_pluggableharness_model_v1_events_proto_rawDesc = "" + "\n" + - "&pluggableharness/model/v1/events.proto\x12\x19pluggableharness.model.v1\x1a&pluggableharness/model/v1/errors.proto\x1a%pluggableharness/model/v1/types.proto\"\xba\f\n" + + "&pluggableharness/model/v1/events.proto\x12\x19pluggableharness.model.v1\x1a&pluggableharness/model/v1/errors.proto\x1a%pluggableharness/model/v1/types.proto\"\xc6\x1a\n" + "\vStreamEvent\x12Q\n" + "\n" + "text_delta\x18\x01 \x01(\v20.pluggableharness.model.v1.StreamEvent.TextDeltaH\x00R\ttextDelta\x12]\n" + @@ -880,13 +1329,55 @@ const file_pluggableharness_model_v1_events_proto_rawDesc = "" + "\x05error\x18\t \x01(\v2,.pluggableharness.model.v1.StreamEvent.ErrorH\x00R\x05error\x12f\n" + "\x11redacted_thinking\x18\n" + " \x01(\v27.pluggableharness.model.v1.StreamEvent.RedactedThinkingH\x00R\x10redactedThinking\x12W\n" + - "\fstream_start\x18\v \x01(\v22.pluggableharness.model.v1.StreamEvent.StreamStartH\x00R\vstreamStart\x1a=\n" + + "\fstream_start\x18\v \x01(\v22.pluggableharness.model.v1.StreamEvent.StreamStartH\x00R\vstreamStart\x12S\n" + + "\bmetadata\x18\f \x01(\v25.pluggableharness.model.v1.StreamEvent.StreamMetadataH\x00R\bmetadata\x12Z\n" + + "\rsafety_notice\x18\r \x01(\v23.pluggableharness.model.v1.StreamEvent.SafetyNoticeH\x00R\fsafetyNotice\x1a\xf1\x01\n" + "\vStreamStart\x12.\n" + - "\x13provider_request_id\x18\x01 \x01(\tR\x11providerRequestId\x1a\x1f\n" + + "\x13provider_request_id\x18\x01 \x01(\tR\x11providerRequestId\x12o\n" + + "\x0fcorrelation_ids\x18\x02 \x03(\v2F.pluggableharness.model.v1.StreamEvent.StreamStart.CorrelationIdsEntryR\x0ecorrelationIds\x1aA\n" + + "\x13CorrelationIdsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a\xd0\x05\n" + + "\x0eStreamMetadata\x12&\n" + + "\factual_model\x18\x01 \x01(\tH\x00R\vactualModel\x88\x01\x01\x122\n" + + "\x12system_fingerprint\x18\x02 \x01(\tH\x01R\x11systemFingerprint\x88\x01\x01\x12&\n" + + "\fservice_tier\x18\x03 \x01(\tH\x02R\vserviceTier\x88\x01\x01\x12M\n" + + "\vrate_limits\x18\x04 \x03(\v2,.pluggableharness.model.v1.RateLimitSnapshotR\n" + + "rateLimits\x123\n" + + "\x13live_context_window\x18\x05 \x01(\x03H\x03R\x11liveContextWindow\x88\x01\x01\x128\n" + + "\x16live_max_output_tokens\x18\x06 \x01(\x03H\x04R\x13liveMaxOutputTokens\x88\x01\x01\x12&\n" + + "\fcatalog_etag\x18\a \x01(\tH\x05R\vcatalogEtag\x88\x01\x01\x12V\n" + + "\x05attrs\x18\b \x03(\v2@.pluggableharness.model.v1.StreamEvent.StreamMetadata.AttrsEntryR\x05attrs\x12/\n" + + "\x11sticky_turn_token\x18\t \x01(\tH\x06R\x0fstickyTurnToken\x88\x01\x01\x1a8\n" + + "\n" + + "AttrsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x0f\n" + + "\r_actual_modelB\x15\n" + + "\x13_system_fingerprintB\x0f\n" + + "\r_service_tierB\x16\n" + + "\x14_live_context_windowB\x19\n" + + "\x17_live_max_output_tokensB\x0f\n" + + "\r_catalog_etagB\x14\n" + + "\x12_sticky_turn_token\x1a\x1f\n" + "\tTextDelta\x12\x12\n" + - "\x04text\x18\x01 \x01(\tR\x04text\x1a#\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x1a\xa8\x01\n" + "\rThinkingDelta\x12\x12\n" + - "\x04text\x18\x01 \x01(\tR\x04text\x1a1\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x12P\n" + + "\achannel\x18\x02 \x01(\x0e26.pluggableharness.model.v1.StreamEvent.ThinkingChannelR\achannel\x12\"\n" + + "\n" + + "part_index\x18\x03 \x01(\x05H\x00R\tpartIndex\x88\x01\x01B\r\n" + + "\v_part_index\x1a\x90\x02\n" + + "\fSafetyNotice\x12E\n" + + "\x04kind\x18\x01 \x01(\x0e21.pluggableharness.model.v1.StreamEvent.SafetyKindR\x04kind\x12\x1d\n" + + "\amessage\x18\x02 \x01(\tH\x00R\amessage\x88\x01\x01\x12T\n" + + "\x05attrs\x18\x03 \x03(\v2>.pluggableharness.model.v1.StreamEvent.SafetyNotice.AttrsEntryR\x05attrs\x1a8\n" + + "\n" + + "AttrsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\n" + + "\n" + + "\b_message\x1a1\n" + "\x11ThinkingSignature\x12\x1c\n" + "\tsignature\x18\x01 \x01(\fR\tsignature\x1a3\n" + "\rToolCallStart\x12\x0e\n" + @@ -896,15 +1387,27 @@ const file_pluggableharness_model_v1_events_proto_rawDesc = "" + "\x02id\x18\x01 \x01(\tR\x02id\x12-\n" + "\x12arguments_fragment\x18\x02 \x01(\tR\x11argumentsFragment\x1a\x1e\n" + "\fToolCallDone\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x1a\x98\x01\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x1a\xd7\x01\n" + "\x04Stop\x12=\n" + "\x06reason\x18\x01 \x01(\x0e2%.pluggableharness.model.v1.StopReasonR\x06reason\x127\n" + - "\x15matched_stop_sequence\x18\x02 \x01(\tH\x00R\x13matchedStopSequence\x88\x01\x01B\x18\n" + - "\x16_matched_stop_sequence\x1aD\n" + + "\x15matched_stop_sequence\x18\x02 \x01(\tH\x00R\x13matchedStopSequence\x88\x01\x01\x12*\n" + + "\x0emodel_affirmed\x18\x03 \x01(\bH\x01R\rmodelAffirmed\x88\x01\x01B\x18\n" + + "\x16_matched_stop_sequenceB\x11\n" + + "\x0f_model_affirmed\x1aD\n" + "\x05Error\x12;\n" + "\x05error\x18\x01 \x01(\v2%.pluggableharness.model.v1.ModelErrorR\x05error\x1a&\n" + "\x10RedactedThinking\x12\x12\n" + - "\x04data\x18\x01 \x01(\fR\x04dataB\a\n" + + "\x04data\x18\x01 \x01(\fR\x04data\"o\n" + + "\x0fThinkingChannel\x12 \n" + + "\x1cTHINKING_CHANNEL_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18THINKING_CHANNEL_CONTENT\x10\x01\x12\x1c\n" + + "\x18THINKING_CHANNEL_SUMMARY\x10\x02\"\x87\x01\n" + + "\n" + + "SafetyKind\x12\x1b\n" + + "\x17SAFETY_KIND_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15SAFETY_KIND_BUFFERING\x10\x01\x12\x1a\n" + + "\x16SAFETY_KIND_MODERATION\x10\x02\x12%\n" + + "!SAFETY_KIND_VERIFICATION_REQUIRED\x10\x03B\a\n" + "\x05event*\xee\x01\n" + "\n" + "StopReason\x12\x1b\n" + @@ -929,43 +1432,59 @@ func file_pluggableharness_model_v1_events_proto_rawDescGZIP() []byte { return file_pluggableharness_model_v1_events_proto_rawDescData } -var file_pluggableharness_model_v1_events_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_model_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_pluggableharness_model_v1_events_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_pluggableharness_model_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_pluggableharness_model_v1_events_proto_goTypes = []any{ (StopReason)(0), // 0: pluggableharness.model.v1.StopReason - (*StreamEvent)(nil), // 1: pluggableharness.model.v1.StreamEvent - (*StreamEvent_StreamStart)(nil), // 2: pluggableharness.model.v1.StreamEvent.StreamStart - (*StreamEvent_TextDelta)(nil), // 3: pluggableharness.model.v1.StreamEvent.TextDelta - (*StreamEvent_ThinkingDelta)(nil), // 4: pluggableharness.model.v1.StreamEvent.ThinkingDelta - (*StreamEvent_ThinkingSignature)(nil), // 5: pluggableharness.model.v1.StreamEvent.ThinkingSignature - (*StreamEvent_ToolCallStart)(nil), // 6: pluggableharness.model.v1.StreamEvent.ToolCallStart - (*StreamEvent_ToolCallDelta)(nil), // 7: pluggableharness.model.v1.StreamEvent.ToolCallDelta - (*StreamEvent_ToolCallDone)(nil), // 8: pluggableharness.model.v1.StreamEvent.ToolCallDone - (*StreamEvent_Stop)(nil), // 9: pluggableharness.model.v1.StreamEvent.Stop - (*StreamEvent_Error)(nil), // 10: pluggableharness.model.v1.StreamEvent.Error - (*StreamEvent_RedactedThinking)(nil), // 11: pluggableharness.model.v1.StreamEvent.RedactedThinking - (*Usage)(nil), // 12: pluggableharness.model.v1.Usage - (*ModelError)(nil), // 13: pluggableharness.model.v1.ModelError + (StreamEvent_ThinkingChannel)(0), // 1: pluggableharness.model.v1.StreamEvent.ThinkingChannel + (StreamEvent_SafetyKind)(0), // 2: pluggableharness.model.v1.StreamEvent.SafetyKind + (*StreamEvent)(nil), // 3: pluggableharness.model.v1.StreamEvent + (*StreamEvent_StreamStart)(nil), // 4: pluggableharness.model.v1.StreamEvent.StreamStart + (*StreamEvent_StreamMetadata)(nil), // 5: pluggableharness.model.v1.StreamEvent.StreamMetadata + (*StreamEvent_TextDelta)(nil), // 6: pluggableharness.model.v1.StreamEvent.TextDelta + (*StreamEvent_ThinkingDelta)(nil), // 7: pluggableharness.model.v1.StreamEvent.ThinkingDelta + (*StreamEvent_SafetyNotice)(nil), // 8: pluggableharness.model.v1.StreamEvent.SafetyNotice + (*StreamEvent_ThinkingSignature)(nil), // 9: pluggableharness.model.v1.StreamEvent.ThinkingSignature + (*StreamEvent_ToolCallStart)(nil), // 10: pluggableharness.model.v1.StreamEvent.ToolCallStart + (*StreamEvent_ToolCallDelta)(nil), // 11: pluggableharness.model.v1.StreamEvent.ToolCallDelta + (*StreamEvent_ToolCallDone)(nil), // 12: pluggableharness.model.v1.StreamEvent.ToolCallDone + (*StreamEvent_Stop)(nil), // 13: pluggableharness.model.v1.StreamEvent.Stop + (*StreamEvent_Error)(nil), // 14: pluggableharness.model.v1.StreamEvent.Error + (*StreamEvent_RedactedThinking)(nil), // 15: pluggableharness.model.v1.StreamEvent.RedactedThinking + nil, // 16: pluggableharness.model.v1.StreamEvent.StreamStart.CorrelationIdsEntry + nil, // 17: pluggableharness.model.v1.StreamEvent.StreamMetadata.AttrsEntry + nil, // 18: pluggableharness.model.v1.StreamEvent.SafetyNotice.AttrsEntry + (*Usage)(nil), // 19: pluggableharness.model.v1.Usage + (*RateLimitSnapshot)(nil), // 20: pluggableharness.model.v1.RateLimitSnapshot + (*ModelError)(nil), // 21: pluggableharness.model.v1.ModelError } var file_pluggableharness_model_v1_events_proto_depIdxs = []int32{ - 3, // 0: pluggableharness.model.v1.StreamEvent.text_delta:type_name -> pluggableharness.model.v1.StreamEvent.TextDelta - 4, // 1: pluggableharness.model.v1.StreamEvent.thinking_delta:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingDelta - 5, // 2: pluggableharness.model.v1.StreamEvent.thinking_signature:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingSignature - 6, // 3: pluggableharness.model.v1.StreamEvent.tool_call_start:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallStart - 7, // 4: pluggableharness.model.v1.StreamEvent.tool_call_delta:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallDelta - 8, // 5: pluggableharness.model.v1.StreamEvent.tool_call_done:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallDone - 12, // 6: pluggableharness.model.v1.StreamEvent.usage:type_name -> pluggableharness.model.v1.Usage - 9, // 7: pluggableharness.model.v1.StreamEvent.stop:type_name -> pluggableharness.model.v1.StreamEvent.Stop - 10, // 8: pluggableharness.model.v1.StreamEvent.error:type_name -> pluggableharness.model.v1.StreamEvent.Error - 11, // 9: pluggableharness.model.v1.StreamEvent.redacted_thinking:type_name -> pluggableharness.model.v1.StreamEvent.RedactedThinking - 2, // 10: pluggableharness.model.v1.StreamEvent.stream_start:type_name -> pluggableharness.model.v1.StreamEvent.StreamStart - 0, // 11: pluggableharness.model.v1.StreamEvent.Stop.reason:type_name -> pluggableharness.model.v1.StopReason - 13, // 12: pluggableharness.model.v1.StreamEvent.Error.error:type_name -> pluggableharness.model.v1.ModelError - 13, // [13:13] is the sub-list for method output_type - 13, // [13:13] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 6, // 0: pluggableharness.model.v1.StreamEvent.text_delta:type_name -> pluggableharness.model.v1.StreamEvent.TextDelta + 7, // 1: pluggableharness.model.v1.StreamEvent.thinking_delta:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingDelta + 9, // 2: pluggableharness.model.v1.StreamEvent.thinking_signature:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingSignature + 10, // 3: pluggableharness.model.v1.StreamEvent.tool_call_start:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallStart + 11, // 4: pluggableharness.model.v1.StreamEvent.tool_call_delta:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallDelta + 12, // 5: pluggableharness.model.v1.StreamEvent.tool_call_done:type_name -> pluggableharness.model.v1.StreamEvent.ToolCallDone + 19, // 6: pluggableharness.model.v1.StreamEvent.usage:type_name -> pluggableharness.model.v1.Usage + 13, // 7: pluggableharness.model.v1.StreamEvent.stop:type_name -> pluggableharness.model.v1.StreamEvent.Stop + 14, // 8: pluggableharness.model.v1.StreamEvent.error:type_name -> pluggableharness.model.v1.StreamEvent.Error + 15, // 9: pluggableharness.model.v1.StreamEvent.redacted_thinking:type_name -> pluggableharness.model.v1.StreamEvent.RedactedThinking + 4, // 10: pluggableharness.model.v1.StreamEvent.stream_start:type_name -> pluggableharness.model.v1.StreamEvent.StreamStart + 5, // 11: pluggableharness.model.v1.StreamEvent.metadata:type_name -> pluggableharness.model.v1.StreamEvent.StreamMetadata + 8, // 12: pluggableharness.model.v1.StreamEvent.safety_notice:type_name -> pluggableharness.model.v1.StreamEvent.SafetyNotice + 16, // 13: pluggableharness.model.v1.StreamEvent.StreamStart.correlation_ids:type_name -> pluggableharness.model.v1.StreamEvent.StreamStart.CorrelationIdsEntry + 20, // 14: pluggableharness.model.v1.StreamEvent.StreamMetadata.rate_limits:type_name -> pluggableharness.model.v1.RateLimitSnapshot + 17, // 15: pluggableharness.model.v1.StreamEvent.StreamMetadata.attrs:type_name -> pluggableharness.model.v1.StreamEvent.StreamMetadata.AttrsEntry + 1, // 16: pluggableharness.model.v1.StreamEvent.ThinkingDelta.channel:type_name -> pluggableharness.model.v1.StreamEvent.ThinkingChannel + 2, // 17: pluggableharness.model.v1.StreamEvent.SafetyNotice.kind:type_name -> pluggableharness.model.v1.StreamEvent.SafetyKind + 18, // 18: pluggableharness.model.v1.StreamEvent.SafetyNotice.attrs:type_name -> pluggableharness.model.v1.StreamEvent.SafetyNotice.AttrsEntry + 0, // 19: pluggableharness.model.v1.StreamEvent.Stop.reason:type_name -> pluggableharness.model.v1.StopReason + 21, // 20: pluggableharness.model.v1.StreamEvent.Error.error:type_name -> pluggableharness.model.v1.ModelError + 21, // [21:21] is the sub-list for method output_type + 21, // [21:21] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name } func init() { file_pluggableharness_model_v1_events_proto_init() } @@ -987,15 +1506,20 @@ func file_pluggableharness_model_v1_events_proto_init() { (*StreamEvent_Error_)(nil), (*StreamEvent_RedactedThinking_)(nil), (*StreamEvent_StreamStart_)(nil), + (*StreamEvent_Metadata)(nil), + (*StreamEvent_SafetyNotice_)(nil), } - file_pluggableharness_model_v1_events_proto_msgTypes[8].OneofWrappers = []any{} + file_pluggableharness_model_v1_events_proto_msgTypes[2].OneofWrappers = []any{} + file_pluggableharness_model_v1_events_proto_msgTypes[4].OneofWrappers = []any{} + file_pluggableharness_model_v1_events_proto_msgTypes[5].OneofWrappers = []any{} + file_pluggableharness_model_v1_events_proto_msgTypes[10].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_events_proto_rawDesc), len(file_pluggableharness_model_v1_events_proto_rawDesc)), - NumEnums: 1, - NumMessages: 11, + NumEnums: 3, + NumMessages: 16, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/model/proto/v1/rpc_request.pb.go b/pkg/model/proto/v1/rpc_request.pb.go index 8b1b995..3becbae 100644 --- a/pkg/model/proto/v1/rpc_request.pb.go +++ b/pkg/model/proto/v1/rpc_request.pb.go @@ -229,6 +229,21 @@ type StreamCompletionRequest struct { // revision is the fix; teaching the kernel to read this field is not. // See model/data-types.md#provider_options. ProviderOptions *structpb.Struct `protobuf:"bytes,8,opt,name=provider_options,json=providerOptions,proto3,oneof" json:"provider_options,omitempty"` + // An opaque handle to the vendor-side state of a prior turn, for + // vendors that keep conversation state server-side and accept an + // incremental continuation instead of a full history resend (an + // OpenAI `previous_response_id`). + // + // Typed rather than left to provider_options because using it changes + // what the kernel must send: a continuation carries only the new + // messages, so `messages` above and this field are not independent. + // The kernel therefore has to know whether it is in use, which is + // exactly the "a field the kernel reads" test data-types.md applies to + // provider_options. + // + // A provider that publishes such a handle returns it on StreamMetadata; + // absent here means send the full history as normal. + StickyTurnToken *string `protobuf:"bytes,9,opt,name=sticky_turn_token,json=stickyTurnToken,proto3,oneof" json:"sticky_turn_token,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -319,6 +334,13 @@ func (x *StreamCompletionRequest) GetProviderOptions() *structpb.Struct { return nil } +func (x *StreamCompletionRequest) GetStickyTurnToken() string { + if x != nil && x.StickyTurnToken != nil { + return *x.StickyTurnToken + } + return "" +} + // CountTokensRequest is CountTokens' request: the request whose input // tokens are being counted, per model/protocol.md#counttokens. // @@ -475,6 +497,44 @@ func (x *RenderRequest) GetSchemaVersion() string { return "" } +// GetAccountRequest is empty: a plugin serves exactly one credential, +// fixed at Configure, so there is nothing for the kernel to select. +type GetAccountRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAccountRequest) Reset() { + *x = GetAccountRequest{} + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAccountRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAccountRequest) ProtoMessage() {} + +func (x *GetAccountRequest) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_request_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAccountRequest.ProtoReflect.Descriptor instead. +func (*GetAccountRequest) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_request_proto_rawDescGZIP(), []int{6} +} + var File_pluggableharness_model_v1_rpc_request_proto protoreflect.FileDescriptor const file_pluggableharness_model_v1_rpc_request_proto_rawDesc = "" + @@ -483,7 +543,7 @@ const file_pluggableharness_model_v1_rpc_request_proto_rawDesc = "" + "\x16GetCapabilitiesRequest\"C\n" + "\x10ConfigureRequest\x12/\n" + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"\x11\n" + - "\x0fDescribeRequest\"\xea\x04\n" + + "\x0fDescribeRequest\"\xb1\x05\n" + "\x17StreamCompletionRequest\x12@\n" + "\bmessages\x18\x01 \x03(\v2$.pluggableharness.content.v1.MessageR\bmessages\x12\x19\n" + "\bmodel_id\x18\x02 \x01(\tR\amodelId\x12@\n" + @@ -492,9 +552,11 @@ const file_pluggableharness_model_v1_rpc_request_proto_rawDesc = "" + "\x11assembled_context\x18\x05 \x03(\v2+.pluggableharness.content.v1.ContextSectionR\x10assembledContext\x12J\n" + "\fcall_context\x18\x06 \x01(\v2'.pluggableharness.common.v1.CallContextR\vcallContext\x12W\n" + "\x11cache_breakpoints\x18\a \x03(\v2*.pluggableharness.model.v1.CacheBreakpointR\x10cacheBreakpoints\x12G\n" + - "\x10provider_options\x18\b \x01(\v2\x17.google.protobuf.StructH\x01R\x0fproviderOptions\x88\x01\x01B\t\n" + + "\x10provider_options\x18\b \x01(\v2\x17.google.protobuf.StructH\x01R\x0fproviderOptions\x88\x01\x01\x12/\n" + + "\x11sticky_turn_token\x18\t \x01(\tH\x02R\x0fstickyTurnToken\x88\x01\x01B\t\n" + "\a_paramsB\x13\n" + - "\x11_provider_options\"\x99\x02\n" + + "\x11_provider_optionsB\x14\n" + + "\x12_sticky_turn_token\"\x99\x02\n" + "\x12CountTokensRequest\x12\x19\n" + "\bmodel_id\x18\x02 \x01(\tR\amodelId\x12@\n" + "\bmessages\x18\x03 \x03(\v2$.pluggableharness.content.v1.MessageR\bmessages\x12X\n" + @@ -502,7 +564,8 @@ const file_pluggableharness_model_v1_rpc_request_proto_rawDesc = "" + "\x05tools\x18\x05 \x03(\v2*.pluggableharness.model.v1.ToolDeclarationR\x05toolsJ\x04\b\x01\x10\x02R\x04text\"P\n" + "\rRenderRequest\x12\x18\n" + "\apayload\x18\x01 \x01(\fR\apayload\x12%\n" + - "\x0eschema_version\x18\x02 \x01(\tR\rschemaVersionB>ZZ google.protobuf.Struct - 7, // 1: pluggableharness.model.v1.StreamCompletionRequest.messages:type_name -> pluggableharness.content.v1.Message - 8, // 2: pluggableharness.model.v1.StreamCompletionRequest.tools:type_name -> pluggableharness.model.v1.ToolDeclaration - 9, // 3: pluggableharness.model.v1.StreamCompletionRequest.params:type_name -> pluggableharness.model.v1.GenerationParams - 10, // 4: pluggableharness.model.v1.StreamCompletionRequest.assembled_context:type_name -> pluggableharness.content.v1.ContextSection - 11, // 5: pluggableharness.model.v1.StreamCompletionRequest.call_context:type_name -> pluggableharness.common.v1.CallContext - 12, // 6: pluggableharness.model.v1.StreamCompletionRequest.cache_breakpoints:type_name -> pluggableharness.model.v1.CacheBreakpoint - 6, // 7: pluggableharness.model.v1.StreamCompletionRequest.provider_options:type_name -> google.protobuf.Struct - 7, // 8: pluggableharness.model.v1.CountTokensRequest.messages:type_name -> pluggableharness.content.v1.Message - 10, // 9: pluggableharness.model.v1.CountTokensRequest.assembled_context:type_name -> pluggableharness.content.v1.ContextSection - 8, // 10: pluggableharness.model.v1.CountTokensRequest.tools:type_name -> pluggableharness.model.v1.ToolDeclaration + 7, // 0: pluggableharness.model.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct + 8, // 1: pluggableharness.model.v1.StreamCompletionRequest.messages:type_name -> pluggableharness.content.v1.Message + 9, // 2: pluggableharness.model.v1.StreamCompletionRequest.tools:type_name -> pluggableharness.model.v1.ToolDeclaration + 10, // 3: pluggableharness.model.v1.StreamCompletionRequest.params:type_name -> pluggableharness.model.v1.GenerationParams + 11, // 4: pluggableharness.model.v1.StreamCompletionRequest.assembled_context:type_name -> pluggableharness.content.v1.ContextSection + 12, // 5: pluggableharness.model.v1.StreamCompletionRequest.call_context:type_name -> pluggableharness.common.v1.CallContext + 13, // 6: pluggableharness.model.v1.StreamCompletionRequest.cache_breakpoints:type_name -> pluggableharness.model.v1.CacheBreakpoint + 7, // 7: pluggableharness.model.v1.StreamCompletionRequest.provider_options:type_name -> google.protobuf.Struct + 8, // 8: pluggableharness.model.v1.CountTokensRequest.messages:type_name -> pluggableharness.content.v1.Message + 11, // 9: pluggableharness.model.v1.CountTokensRequest.assembled_context:type_name -> pluggableharness.content.v1.ContextSection + 9, // 10: pluggableharness.model.v1.CountTokensRequest.tools:type_name -> pluggableharness.model.v1.ToolDeclaration 11, // [11:11] is the sub-list for method output_type 11, // [11:11] is the sub-list for method input_type 11, // [11:11] is the sub-list for extension type_name @@ -564,7 +628,7 @@ func file_pluggableharness_model_v1_rpc_request_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_model_v1_rpc_request_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/model/proto/v1/rpc_response.pb.go b/pkg/model/proto/v1/rpc_response.pb.go index 48a9741..e4eb518 100644 --- a/pkg/model/proto/v1/rpc_response.pb.go +++ b/pkg/model/proto/v1/rpc_response.pb.go @@ -253,6 +253,56 @@ func (x *RenderResponse) GetTree() *v11.RenderTree { return nil } +// GetAccountResponse carries the live account snapshot, per model.md's +// GetAccount RPC. +type GetAccountResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The account state. MUST be set on a successful response — a provider + // with nothing to report returns codes.Unimplemented rather than an + // empty snapshot, so "does not participate" stays distinguishable from + // "participates and currently knows nothing". + Account *AccountSnapshot `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAccountResponse) Reset() { + *x = GetAccountResponse{} + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAccountResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAccountResponse) ProtoMessage() {} + +func (x *GetAccountResponse) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_rpc_response_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAccountResponse.ProtoReflect.Descriptor instead. +func (*GetAccountResponse) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_rpc_response_proto_rawDescGZIP(), []int{5} +} + +func (x *GetAccountResponse) GetAccount() *AccountSnapshot { + if x != nil { + return x.Account + } + return nil +} + var File_pluggableharness_model_v1_rpc_response_proto protoreflect.FileDescriptor const file_pluggableharness_model_v1_rpc_response_proto_rawDesc = "" + @@ -266,7 +316,9 @@ const file_pluggableharness_model_v1_rpc_response_proto_rawDesc = "" + "\x13CountTokensResponse\x12\x14\n" + "\x05count\x18\x01 \x01(\x03R\x05count\"L\n" + "\x0eRenderResponse\x12:\n" + - "\x04tree\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\x04treeB>ZZ pluggableharness.model.v1.Capabilities - 6, // 1: pluggableharness.model.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef - 7, // 2: pluggableharness.model.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 6, // 0: pluggableharness.model.v1.GetCapabilitiesResponse.capabilities:type_name -> pluggableharness.model.v1.Capabilities + 7, // 1: pluggableharness.model.v1.DescribeResponse.producer:type_name -> pluggableharness.common.v1.ProducerRef + 8, // 2: pluggableharness.model.v1.RenderResponse.tree:type_name -> pluggableharness.render.v1.RenderTree + 9, // 3: pluggableharness.model.v1.GetAccountResponse.account:type_name -> pluggableharness.model.v1.AccountSnapshot + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_pluggableharness_model_v1_rpc_response_proto_init() } @@ -314,7 +369,7 @@ func file_pluggableharness_model_v1_rpc_response_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_rpc_response_proto_rawDesc), len(file_pluggableharness_model_v1_rpc_response_proto_rawDesc)), NumEnums: 0, - NumMessages: 5, + NumMessages: 6, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/model/proto/v1/service.pb.go b/pkg/model/proto/v1/service.pb.go index 54781d0..7913156 100644 --- a/pkg/model/proto/v1/service.pb.go +++ b/pkg/model/proto/v1/service.pb.go @@ -34,14 +34,16 @@ var File_pluggableharness_model_v1_service_proto protoreflect.FileDescriptor const file_pluggableharness_model_v1_service_proto_rawDesc = "" + "\n" + - "'pluggableharness/model/v1/service.proto\x12\x19pluggableharness.model.v1\x1a&pluggableharness/model/v1/events.proto\x1a+pluggableharness/model/v1/rpc_request.proto\x1a,pluggableharness/model/v1/rpc_response.proto2\x94\x05\n" + + "'pluggableharness/model/v1/service.proto\x12\x19pluggableharness.model.v1\x1a&pluggableharness/model/v1/events.proto\x1a+pluggableharness/model/v1/rpc_request.proto\x1a,pluggableharness/model/v1/rpc_response.proto2\xff\x05\n" + "\fModelService\x12x\n" + "\x0fGetCapabilities\x121.pluggableharness.model.v1.GetCapabilitiesRequest\x1a2.pluggableharness.model.v1.GetCapabilitiesResponse\x12f\n" + "\tConfigure\x12+.pluggableharness.model.v1.ConfigureRequest\x1a,.pluggableharness.model.v1.ConfigureResponse\x12p\n" + "\x10StreamCompletion\x122.pluggableharness.model.v1.StreamCompletionRequest\x1a&.pluggableharness.model.v1.StreamEvent0\x01\x12l\n" + "\vCountTokens\x12-.pluggableharness.model.v1.CountTokensRequest\x1a..pluggableharness.model.v1.CountTokensResponse\x12]\n" + "\x06Render\x12(.pluggableharness.model.v1.RenderRequest\x1a).pluggableharness.model.v1.RenderResponse\x12c\n" + - "\bDescribe\x12*.pluggableharness.model.v1.DescribeRequest\x1a+.pluggableharness.model.v1.DescribeResponseB>ZZ pluggableharness.model.v1.GetCapabilitiesRequest @@ -64,14 +68,16 @@ var file_pluggableharness_model_v1_service_proto_depIdxs = []int32{ 3, // 3: pluggableharness.model.v1.ModelService.CountTokens:input_type -> pluggableharness.model.v1.CountTokensRequest 4, // 4: pluggableharness.model.v1.ModelService.Render:input_type -> pluggableharness.model.v1.RenderRequest 5, // 5: pluggableharness.model.v1.ModelService.Describe:input_type -> pluggableharness.model.v1.DescribeRequest - 6, // 6: pluggableharness.model.v1.ModelService.GetCapabilities:output_type -> pluggableharness.model.v1.GetCapabilitiesResponse - 7, // 7: pluggableharness.model.v1.ModelService.Configure:output_type -> pluggableharness.model.v1.ConfigureResponse - 8, // 8: pluggableharness.model.v1.ModelService.StreamCompletion:output_type -> pluggableharness.model.v1.StreamEvent - 9, // 9: pluggableharness.model.v1.ModelService.CountTokens:output_type -> pluggableharness.model.v1.CountTokensResponse - 10, // 10: pluggableharness.model.v1.ModelService.Render:output_type -> pluggableharness.model.v1.RenderResponse - 11, // 11: pluggableharness.model.v1.ModelService.Describe:output_type -> pluggableharness.model.v1.DescribeResponse - 6, // [6:12] is the sub-list for method output_type - 0, // [0:6] is the sub-list for method input_type + 6, // 6: pluggableharness.model.v1.ModelService.GetAccount:input_type -> pluggableharness.model.v1.GetAccountRequest + 7, // 7: pluggableharness.model.v1.ModelService.GetCapabilities:output_type -> pluggableharness.model.v1.GetCapabilitiesResponse + 8, // 8: pluggableharness.model.v1.ModelService.Configure:output_type -> pluggableharness.model.v1.ConfigureResponse + 9, // 9: pluggableharness.model.v1.ModelService.StreamCompletion:output_type -> pluggableharness.model.v1.StreamEvent + 10, // 10: pluggableharness.model.v1.ModelService.CountTokens:output_type -> pluggableharness.model.v1.CountTokensResponse + 11, // 11: pluggableharness.model.v1.ModelService.Render:output_type -> pluggableharness.model.v1.RenderResponse + 12, // 12: pluggableharness.model.v1.ModelService.Describe:output_type -> pluggableharness.model.v1.DescribeResponse + 13, // 13: pluggableharness.model.v1.ModelService.GetAccount:output_type -> pluggableharness.model.v1.GetAccountResponse + 7, // [7:14] is the sub-list for method output_type + 0, // [0:7] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/pkg/model/proto/v1/service_grpc.pb.go b/pkg/model/proto/v1/service_grpc.pb.go index 1547e2e..c4ccadf 100644 --- a/pkg/model/proto/v1/service_grpc.pb.go +++ b/pkg/model/proto/v1/service_grpc.pb.go @@ -35,6 +35,7 @@ const ( ModelService_CountTokens_FullMethodName = "/pluggableharness.model.v1.ModelService/CountTokens" ModelService_Render_FullMethodName = "/pluggableharness.model.v1.ModelService/Render" ModelService_Describe_FullMethodName = "/pluggableharness.model.v1.ModelService/Describe" + ModelService_GetAccount_FullMethodName = "/pluggableharness.model.v1.ModelService/GetAccount" ) // ModelServiceClient is the client API for ModelService service. @@ -103,6 +104,30 @@ type ModelServiceClient interface { // explanation, shared verbatim across all seven category protocols that // gain this RPC in this same protocol revision. Describe(ctx context.Context, in *DescribeRequest, opts ...grpc.CallOption) (*DescribeResponse, error) + // GetAccount reports the live account and entitlement state behind this + // plugin's credential: which pool completions are charged against, what + // plan is in force, and whatever quota the vendor publishes outside a + // completion. + // + // MAY be implemented. A provider that has no account concept — a bare + // API key against a metered endpoint, a local model — returns + // codes.Unimplemented, and the kernel MUST tolerate that exactly as it + // tolerates an absent Render (tool/protocol.md's Preview rule is the + // precedent). Absence means "no account state to report", never an + // error. + // + // Separate from GetCapabilities because the two have different + // lifetimes. Capabilities are the static roster fixed at Configure; + // account state is live, changes as quota burns down, and is the only + // way an operator learns a subscription pool is nearly empty *before* + // the turn that strands them. It is not part of the capability + // advertisement and MUST NOT be cached as if it were. + // + // Deliberately not persisted into the session event log: it is a live + // reading of external state, so recording it would put a value into + // the replay path that no replay can reproduce + // (.claude/rules/determinism.md). + GetAccount(ctx context.Context, in *GetAccountRequest, opts ...grpc.CallOption) (*GetAccountResponse, error) } type modelServiceClient struct { @@ -182,6 +207,16 @@ func (c *modelServiceClient) Describe(ctx context.Context, in *DescribeRequest, return out, nil } +func (c *modelServiceClient) GetAccount(ctx context.Context, in *GetAccountRequest, opts ...grpc.CallOption) (*GetAccountResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetAccountResponse) + err := c.cc.Invoke(ctx, ModelService_GetAccount_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ModelServiceServer is the server API for ModelService service. // All implementations must embed UnimplementedModelServiceServer // for forward compatibility. @@ -248,6 +283,30 @@ type ModelServiceServer interface { // explanation, shared verbatim across all seven category protocols that // gain this RPC in this same protocol revision. Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) + // GetAccount reports the live account and entitlement state behind this + // plugin's credential: which pool completions are charged against, what + // plan is in force, and whatever quota the vendor publishes outside a + // completion. + // + // MAY be implemented. A provider that has no account concept — a bare + // API key against a metered endpoint, a local model — returns + // codes.Unimplemented, and the kernel MUST tolerate that exactly as it + // tolerates an absent Render (tool/protocol.md's Preview rule is the + // precedent). Absence means "no account state to report", never an + // error. + // + // Separate from GetCapabilities because the two have different + // lifetimes. Capabilities are the static roster fixed at Configure; + // account state is live, changes as quota burns down, and is the only + // way an operator learns a subscription pool is nearly empty *before* + // the turn that strands them. It is not part of the capability + // advertisement and MUST NOT be cached as if it were. + // + // Deliberately not persisted into the session event log: it is a live + // reading of external state, so recording it would put a value into + // the replay path that no replay can reproduce + // (.claude/rules/determinism.md). + GetAccount(context.Context, *GetAccountRequest) (*GetAccountResponse, error) mustEmbedUnimplementedModelServiceServer() } @@ -276,6 +335,9 @@ func (UnimplementedModelServiceServer) Render(context.Context, *RenderRequest) ( func (UnimplementedModelServiceServer) Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) { return nil, status.Error(codes.Unimplemented, "method Describe not implemented") } +func (UnimplementedModelServiceServer) GetAccount(context.Context, *GetAccountRequest) (*GetAccountResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetAccount not implemented") +} func (UnimplementedModelServiceServer) mustEmbedUnimplementedModelServiceServer() {} func (UnimplementedModelServiceServer) testEmbeddedByValue() {} @@ -398,6 +460,24 @@ func _ModelService_Describe_Handler(srv interface{}, ctx context.Context, dec fu return interceptor(ctx, in, info, handler) } +func _ModelService_GetAccount_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetAccountRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ModelServiceServer).GetAccount(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ModelService_GetAccount_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ModelServiceServer).GetAccount(ctx, req.(*GetAccountRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ModelService_ServiceDesc is the grpc.ServiceDesc for ModelService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -425,6 +505,10 @@ var ModelService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Describe", Handler: _ModelService_Describe_Handler, }, + { + MethodName: "GetAccount", + Handler: _ModelService_GetAccount_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/pkg/model/proto/v1/types.pb.go b/pkg/model/proto/v1/types.pb.go index 218da7e..0cb07b8 100644 --- a/pkg/model/proto/v1/types.pb.go +++ b/pkg/model/proto/v1/types.pb.go @@ -25,6 +25,129 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// AuthMethod names the credential shape a model plugin is running under. +// +// It matters beyond bookkeeping: which models a vendor exposes, which +// endpoint answers, and which terms of service apply all differ by +// credential. A frontend that cannot tell a subscription session from a +// console key cannot warn an operator which one they are spending. +type AuthMethod int32 + +const ( + // Zero value: the provider did not say. Not an error — declaring this + // is optional. + AuthMethod_AUTH_METHOD_UNSPECIFIED AuthMethod = 0 + // A console/platform API key billed per token. + AuthMethod_AUTH_METHOD_API_KEY AuthMethod = 1 + // An interactive product session (a ChatGPT or Grok login), metered + // against that product's subscription pool. + AuthMethod_AUTH_METHOD_PRODUCT_SESSION AuthMethod = 2 + // A cloud deployment credential (Azure, Bedrock, Vertex) where billing + // belongs to the hosting account rather than the model vendor. + AuthMethod_AUTH_METHOD_DEPLOYMENT_KEY AuthMethod = 3 +) + +// Enum value maps for AuthMethod. +var ( + AuthMethod_name = map[int32]string{ + 0: "AUTH_METHOD_UNSPECIFIED", + 1: "AUTH_METHOD_API_KEY", + 2: "AUTH_METHOD_PRODUCT_SESSION", + 3: "AUTH_METHOD_DEPLOYMENT_KEY", + } + AuthMethod_value = map[string]int32{ + "AUTH_METHOD_UNSPECIFIED": 0, + "AUTH_METHOD_API_KEY": 1, + "AUTH_METHOD_PRODUCT_SESSION": 2, + "AUTH_METHOD_DEPLOYMENT_KEY": 3, + } +) + +func (x AuthMethod) Enum() *AuthMethod { + p := new(AuthMethod) + *p = x + return p +} + +func (x AuthMethod) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AuthMethod) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_types_proto_enumTypes[0].Descriptor() +} + +func (AuthMethod) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_types_proto_enumTypes[0] +} + +func (x AuthMethod) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AuthMethod.Descriptor instead. +func (AuthMethod) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{0} +} + +// MeteringDomain names what a completion is actually charged against. +// +// Separate from AuthMethod because the two are not one-to-one: a product +// session can be billed against credits once a pool is exhausted. +type MeteringDomain int32 + +const ( + // Zero value: the provider did not say. + MeteringDomain_METERING_DOMAIN_UNSPECIFIED MeteringDomain = 0 + // A subscription pool, where the scarce resource is quota rather than + // currency and computed cost_usd is not what the operator is spending. + MeteringDomain_METERING_DOMAIN_SUBSCRIPTION_POOL MeteringDomain = 1 + // Per-token billing against an invoice, where computed cost_usd is a + // real prediction of a real charge. + MeteringDomain_METERING_DOMAIN_METERED_API MeteringDomain = 2 +) + +// Enum value maps for MeteringDomain. +var ( + MeteringDomain_name = map[int32]string{ + 0: "METERING_DOMAIN_UNSPECIFIED", + 1: "METERING_DOMAIN_SUBSCRIPTION_POOL", + 2: "METERING_DOMAIN_METERED_API", + } + MeteringDomain_value = map[string]int32{ + "METERING_DOMAIN_UNSPECIFIED": 0, + "METERING_DOMAIN_SUBSCRIPTION_POOL": 1, + "METERING_DOMAIN_METERED_API": 2, + } +) + +func (x MeteringDomain) Enum() *MeteringDomain { + p := new(MeteringDomain) + *p = x + return p +} + +func (x MeteringDomain) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MeteringDomain) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_types_proto_enumTypes[1].Descriptor() +} + +func (MeteringDomain) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_types_proto_enumTypes[1] +} + +func (x MeteringDomain) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MeteringDomain.Descriptor instead. +func (MeteringDomain) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{1} +} + // ThinkingDisableSupport describes whether a model's reasoning can be // turned off, per model/data-types.md#thinkingspec. A plain bool cannot // express the real answer for every model: Anthropic's Opus 5 accepts an @@ -78,11 +201,11 @@ func (x ThinkingDisableSupport) String() string { } func (ThinkingDisableSupport) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_model_v1_types_proto_enumTypes[0].Descriptor() + return file_pluggableharness_model_v1_types_proto_enumTypes[2].Descriptor() } func (ThinkingDisableSupport) Type() protoreflect.EnumType { - return &file_pluggableharness_model_v1_types_proto_enumTypes[0] + return &file_pluggableharness_model_v1_types_proto_enumTypes[2] } func (x ThinkingDisableSupport) Number() protoreflect.EnumNumber { @@ -91,7 +214,64 @@ func (x ThinkingDisableSupport) Number() protoreflect.EnumNumber { // Deprecated: Use ThinkingDisableSupport.Descriptor instead. func (ThinkingDisableSupport) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{2} +} + +// ResponseFormatKind names the structured-output modes. +type ResponseFormatKind int32 + +const ( + // Zero value. Never valid on a set ResponseFormat. + ResponseFormatKind_RESPONSE_FORMAT_KIND_UNSPECIFIED ResponseFormatKind = 0 + // Ordinary free-form text. + ResponseFormatKind_RESPONSE_FORMAT_KIND_TEXT ResponseFormatKind = 1 + // Any syntactically valid JSON, with no schema constraint. + ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_OBJECT ResponseFormatKind = 2 + // JSON conforming to json_schema. + ResponseFormatKind_RESPONSE_FORMAT_KIND_JSON_SCHEMA ResponseFormatKind = 3 +) + +// Enum value maps for ResponseFormatKind. +var ( + ResponseFormatKind_name = map[int32]string{ + 0: "RESPONSE_FORMAT_KIND_UNSPECIFIED", + 1: "RESPONSE_FORMAT_KIND_TEXT", + 2: "RESPONSE_FORMAT_KIND_JSON_OBJECT", + 3: "RESPONSE_FORMAT_KIND_JSON_SCHEMA", + } + ResponseFormatKind_value = map[string]int32{ + "RESPONSE_FORMAT_KIND_UNSPECIFIED": 0, + "RESPONSE_FORMAT_KIND_TEXT": 1, + "RESPONSE_FORMAT_KIND_JSON_OBJECT": 2, + "RESPONSE_FORMAT_KIND_JSON_SCHEMA": 3, + } +) + +func (x ResponseFormatKind) Enum() *ResponseFormatKind { + p := new(ResponseFormatKind) + *p = x + return p +} + +func (x ResponseFormatKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ResponseFormatKind) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_types_proto_enumTypes[3].Descriptor() +} + +func (ResponseFormatKind) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_types_proto_enumTypes[3] +} + +func (x ResponseFormatKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ResponseFormatKind.Descriptor instead. +func (ResponseFormatKind) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{3} } // ToolChoiceMode enumerates the tool-invocation constraint shapes found @@ -144,11 +324,11 @@ func (x ToolChoiceMode) String() string { } func (ToolChoiceMode) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_model_v1_types_proto_enumTypes[1].Descriptor() + return file_pluggableharness_model_v1_types_proto_enumTypes[4].Descriptor() } func (ToolChoiceMode) Type() protoreflect.EnumType { - return &file_pluggableharness_model_v1_types_proto_enumTypes[1] + return &file_pluggableharness_model_v1_types_proto_enumTypes[4] } func (x ToolChoiceMode) Number() protoreflect.EnumNumber { @@ -157,7 +337,7 @@ func (x ToolChoiceMode) Number() protoreflect.EnumNumber { // Deprecated: Use ToolChoiceMode.Descriptor instead. func (ToolChoiceMode) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{4} } // RateLimitKind names which vendor budget a RateLimitSnapshot describes. @@ -175,6 +355,9 @@ const ( RateLimitKind_RATE_LIMIT_KIND_INPUT_TOKENS RateLimitKind = 3 // Output tokens per window, where the vendor meters them separately. RateLimitKind_RATE_LIMIT_KIND_OUTPUT_TOKENS RateLimitKind = 4 + // A credit or currency balance metered independently of requests and + // tokens, as subscription products bill overage against. + RateLimitKind_RATE_LIMIT_KIND_CREDITS RateLimitKind = 5 ) // Enum value maps for RateLimitKind. @@ -185,6 +368,7 @@ var ( 2: "RATE_LIMIT_KIND_TOKENS", 3: "RATE_LIMIT_KIND_INPUT_TOKENS", 4: "RATE_LIMIT_KIND_OUTPUT_TOKENS", + 5: "RATE_LIMIT_KIND_CREDITS", } RateLimitKind_value = map[string]int32{ "RATE_LIMIT_KIND_UNSPECIFIED": 0, @@ -192,6 +376,7 @@ var ( "RATE_LIMIT_KIND_TOKENS": 2, "RATE_LIMIT_KIND_INPUT_TOKENS": 3, "RATE_LIMIT_KIND_OUTPUT_TOKENS": 4, + "RATE_LIMIT_KIND_CREDITS": 5, } ) @@ -206,11 +391,11 @@ func (x RateLimitKind) String() string { } func (RateLimitKind) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_model_v1_types_proto_enumTypes[2].Descriptor() + return file_pluggableharness_model_v1_types_proto_enumTypes[5].Descriptor() } func (RateLimitKind) Type() protoreflect.EnumType { - return &file_pluggableharness_model_v1_types_proto_enumTypes[2] + return &file_pluggableharness_model_v1_types_proto_enumTypes[5] } func (x RateLimitKind) Number() protoreflect.EnumNumber { @@ -219,7 +404,69 @@ func (x RateLimitKind) Number() protoreflect.EnumNumber { // Deprecated: Use RateLimitKind.Descriptor instead. func (RateLimitKind) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{5} +} + +// WindowRole distinguishes the several budgets a subscription product +// meters at once, where one is the headline limit and the others +// constrain bursts within it. +// +// Without it an adapter facing a product that publishes a primary and a +// secondary window has to pick one RateLimitKind for each and hope the +// frontend guesses right — the mapping 27-missing-openai-protocol.md +// calls a "semantic lie". +type WindowRole int32 + +const ( + // Zero value: the vendor publishes one undifferentiated budget, or + // says nothing about role. Not an error. + WindowRole_WINDOW_ROLE_UNSPECIFIED WindowRole = 0 + // The headline budget an operator thinks of as "my limit". + WindowRole_WINDOW_ROLE_PRIMARY WindowRole = 1 + // A shorter or narrower budget that constrains bursts inside the + // primary one. + WindowRole_WINDOW_ROLE_SECONDARY WindowRole = 2 +) + +// Enum value maps for WindowRole. +var ( + WindowRole_name = map[int32]string{ + 0: "WINDOW_ROLE_UNSPECIFIED", + 1: "WINDOW_ROLE_PRIMARY", + 2: "WINDOW_ROLE_SECONDARY", + } + WindowRole_value = map[string]int32{ + "WINDOW_ROLE_UNSPECIFIED": 0, + "WINDOW_ROLE_PRIMARY": 1, + "WINDOW_ROLE_SECONDARY": 2, + } +) + +func (x WindowRole) Enum() *WindowRole { + p := new(WindowRole) + *p = x + return p +} + +func (x WindowRole) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WindowRole) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_model_v1_types_proto_enumTypes[6].Descriptor() +} + +func (WindowRole) Type() protoreflect.EnumType { + return &file_pluggableharness_model_v1_types_proto_enumTypes[6] +} + +func (x WindowRole) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WindowRole.Descriptor instead. +func (WindowRole) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{6} } // Capabilities is GetCapabilities' response payload: every model this @@ -253,8 +500,26 @@ type Capabilities struct { // HookPoint itself lives in common.v1 for exactly this reason (see // common/v1/types.proto), already imported here for CallContext/Describe. SupportedHookPoints []v1.HookPoint `protobuf:"varint,4,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // How this plugin authenticated and which pool it meters against, when + // it can say. MAY be absent — a provider with one credential shape has + // nothing to disambiguate. + Auth *AuthDescriptor `protobuf:"bytes,5,opt,name=auth,proto3,oneof" json:"auth,omitempty"` + // The vendor's version identifier for the model catalog this roster + // was built from (an `x-models-etag`), when the provider fetched one. + // + // Reported so a mismatch against a StreamMetadata.catalog_etag on a + // later completion is *detectable*. Acting on it requires the kernel + // to be able to re-fetch capabilities, which this protocol revision + // does not add — the roster is still resolved once at Configure. Ship + // this now so a provider need not re-advertise when refresh lands. + CatalogEtag *string `protobuf:"bytes,6,opt,name=catalog_etag,json=catalogEtag,proto3,oneof" json:"catalog_etag,omitempty"` + // When this roster was fetched from the vendor. Absent for a + // hand-written roster compiled into the plugin, which is exactly the + // distinction it exists to make: a static roster is never stale, while + // a fetched one has an age. + CatalogFetchedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=catalog_fetched_at,json=catalogFetchedAt,proto3,oneof" json:"catalog_fetched_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Capabilities) Reset() { @@ -315,6 +580,223 @@ func (x *Capabilities) GetSupportedHookPoints() []v1.HookPoint { return nil } +func (x *Capabilities) GetAuth() *AuthDescriptor { + if x != nil { + return x.Auth + } + return nil +} + +func (x *Capabilities) GetCatalogEtag() string { + if x != nil && x.CatalogEtag != nil { + return *x.CatalogEtag + } + return "" +} + +func (x *Capabilities) GetCatalogFetchedAt() *timestamppb.Timestamp { + if x != nil { + return x.CatalogFetchedAt + } + return nil +} + +// AuthDescriptor is the non-secret description of how a model plugin is +// authenticated. +// +// Nothing here is a credential, and nothing here may be derived from one +// in a way that leaks it: no key material, no token, no refresh token, +// no full account identifier. .claude/rules/logging-telemetry.md's +// no-secrets rule applies to every field with no exception, including +// the labels map. +type AuthDescriptor struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The credential shape in use. + Method AuthMethod `protobuf:"varint,1,opt,name=method,proto3,enum=pluggableharness.model.v1.AuthMethod" json:"method,omitempty"` + // What completions are charged against. + Metering MeteringDomain `protobuf:"varint,2,opt,name=metering,proto3,enum=pluggableharness.model.v1.MeteringDomain" json:"metering,omitempty"` + // The vendor's plan name, where a subscription names one ("plus", + // "pro", "SuperGrok"). Display only; the kernel never routes on it. + Plan *string `protobuf:"bytes,3,opt,name=plan,proto3,oneof" json:"plan,omitempty"` + // Additional non-secret, vendor-defined labels — a redacted account + // handle, a region, an organization display name. + // + // The kernel MUST serialize this with sorted keys wherever it reaches + // a persisted payload; Go map iteration order is randomized and would + // otherwise make the same state serialize differently across runs + // (.claude/rules/determinism.md). + Labels map[string]string `protobuf:"bytes,4,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AuthDescriptor) Reset() { + *x = AuthDescriptor{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AuthDescriptor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthDescriptor) ProtoMessage() {} + +func (x *AuthDescriptor) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthDescriptor.ProtoReflect.Descriptor instead. +func (*AuthDescriptor) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *AuthDescriptor) GetMethod() AuthMethod { + if x != nil { + return x.Method + } + return AuthMethod_AUTH_METHOD_UNSPECIFIED +} + +func (x *AuthDescriptor) GetMetering() MeteringDomain { + if x != nil { + return x.Metering + } + return MeteringDomain_METERING_DOMAIN_UNSPECIFIED +} + +func (x *AuthDescriptor) GetPlan() string { + if x != nil && x.Plan != nil { + return *x.Plan + } + return "" +} + +func (x *AuthDescriptor) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +// AccountSnapshot is the live account and entitlement state behind a +// model plugin's credential, as returned by GetAccount. +// +// It answers the question a subscription operator actually has — "how +// much of my pool is left, and on which plan" — which no per-completion +// message can answer before the first completion runs. The quota list +// reuses RateLimitSnapshot rather than introducing a parallel shape: +// pool headroom and a rate-limit budget are the same concept read at +// different times, and two types for it would guarantee two frontend +// renderers that disagree. +type AccountSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The credential shape in use. + Method AuthMethod `protobuf:"varint,1,opt,name=method,proto3,enum=pluggableharness.model.v1.AuthMethod" json:"method,omitempty"` + // What completions are charged against. + Metering MeteringDomain `protobuf:"varint,2,opt,name=metering,proto3,enum=pluggableharness.model.v1.MeteringDomain" json:"metering,omitempty"` + // The vendor's plan name, where a subscription names one. Display + // only; the kernel never routes on it. + Plan *string `protobuf:"bytes,3,opt,name=plan,proto3,oneof" json:"plan,omitempty"` + // Non-secret, vendor-defined labels — a redacted account handle, a + // region, an organization display name. The no-secrets rule on + // AuthDescriptor.labels applies here identically. + Labels map[string]string `protobuf:"bytes,4,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Every budget the vendor publishes for this account outside a + // completion: pool percentages, credit balances, request ceilings. + // MAY be empty — a vendor that publishes budgets only in completion + // response headers reports them on Usage instead. + Quotas []*RateLimitSnapshot `protobuf:"bytes,5,rep,name=quotas,proto3" json:"quotas,omitempty"` + // When this snapshot was read from the vendor. Lets a frontend say how + // stale the figure is rather than presenting a cached reading as live, + // which is the specific failure mode that makes an operator distrust a + // usage meter. + FetchedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=fetched_at,json=fetchedAt,proto3,oneof" json:"fetched_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountSnapshot) Reset() { + *x = AccountSnapshot{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountSnapshot) ProtoMessage() {} + +func (x *AccountSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountSnapshot.ProtoReflect.Descriptor instead. +func (*AccountSnapshot) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{2} +} + +func (x *AccountSnapshot) GetMethod() AuthMethod { + if x != nil { + return x.Method + } + return AuthMethod_AUTH_METHOD_UNSPECIFIED +} + +func (x *AccountSnapshot) GetMetering() MeteringDomain { + if x != nil { + return x.Metering + } + return MeteringDomain_METERING_DOMAIN_UNSPECIFIED +} + +func (x *AccountSnapshot) GetPlan() string { + if x != nil && x.Plan != nil { + return *x.Plan + } + return "" +} + +func (x *AccountSnapshot) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *AccountSnapshot) GetQuotas() []*RateLimitSnapshot { + if x != nil { + return x.Quotas + } + return nil +} + +func (x *AccountSnapshot) GetFetchedAt() *timestamppb.Timestamp { + if x != nil { + return x.FetchedAt + } + return nil +} + // ModelSpec describes one model this provider can serve, per // model.md §2. Every field below is MUST unless its comment says // otherwise. @@ -367,13 +849,57 @@ type ModelSpec struct { // the kernel MUST reject a DocumentBlock sent to a model where this is // false, with invalid_request, rather than silently dropping it. SupportsDocuments bool `protobuf:"varint,12,opt,name=supports_documents,json=supportsDocuments,proto3" json:"supports_documents,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Human-facing catalog metadata for a model picker. Absent for a + // provider with a hand-written roster and nothing to say beyond the + // id. + Catalog *CatalogMetadata `protobuf:"bytes,13,opt,name=catalog,proto3,oneof" json:"catalog,omitempty"` + // The largest context window this model can be configured with, where + // the vendor exposes a ceiling above the default context_window (a + // long-context variant billed at a different tier). + // + // context_window remains the figure the kernel budgets against; + // this is the headroom a picker can offer, not a silent upgrade. + MaxContextWindow *int64 `protobuf:"varint,14,opt,name=max_context_window,json=maxContextWindow,proto3,oneof" json:"max_context_window,omitempty"` + // The fraction of context_window, 0-100, that is actually usable for + // conversation after the vendor's own fixed overhead. + // + // Vendors publish a round context_window and then reserve part of it, + // which is why a session can hit a limit well below the advertised + // number. Absent means the whole window is usable. + EffectiveContextWindowPercent *float64 `protobuf:"fixed64,15,opt,name=effective_context_window_percent,json=effectiveContextWindowPercent,proto3,oneof" json:"effective_context_window_percent,omitempty"` + // The assembled-token count at which a harness should compact this + // model's history, where the vendor recommends one. Advisory: the + // kernel's own compaction policy decides, and MUST NOT treat this as a + // hard bound. + AutoCompactTokenLimit *int64 `protobuf:"varint,16,opt,name=auto_compact_token_limit,json=autoCompactTokenLimit,proto3,oneof" json:"auto_compact_token_limit,omitempty"` + // The model's output-verbosity control, where it exposes one. + Verbosity *VerbositySpec `protobuf:"bytes,17,opt,name=verbosity,proto3,oneof" json:"verbosity,omitempty"` + // The service or speed tiers this model can be served at, in the + // vendor's own naming. Empty means the vendor exposes no tier choice. + ServiceTiers []string `protobuf:"bytes,18,rep,name=service_tiers,json=serviceTiers,proto3" json:"service_tiers,omitempty"` + // Which vendor API surface serves this model ("chat_completions", + // "responses", "messages"), for vendors exposing several with + // different capabilities. + // + // Opaque to the kernel — it never routes on this. It exists so a + // provider serving one roster across two backends can record which is + // which instead of splitting into two plugins. + ApiBackend *string `protobuf:"bytes,19,opt,name=api_backend,json=apiBackend,proto3,oneof" json:"api_backend,omitempty"` + // The vendor's policy name for truncating oversized tool output, where + // it defines one. Opaque to the kernel. + TruncationPolicy *string `protobuf:"bytes,20,opt,name=truncation_policy,json=truncationPolicy,proto3,oneof" json:"truncation_policy,omitempty"` + // The vendor's compaction-compatibility identifier, where it publishes + // one. Two models sharing a value can consume each other's compacted + // history; differing values mean a compaction cannot be carried + // across. Opaque to the kernel. + CompHash *string `protobuf:"bytes,21,opt,name=comp_hash,json=compHash,proto3,oneof" json:"comp_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ModelSpec) Reset() { *x = ModelSpec{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[1] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -385,7 +911,7 @@ func (x *ModelSpec) String() string { func (*ModelSpec) ProtoMessage() {} func (x *ModelSpec) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[1] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -398,7 +924,7 @@ func (x *ModelSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use ModelSpec.ProtoReflect.Descriptor instead. func (*ModelSpec) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{3} } func (x *ModelSpec) GetId() string { @@ -485,6 +1011,261 @@ func (x *ModelSpec) GetSupportsDocuments() bool { return false } +func (x *ModelSpec) GetCatalog() *CatalogMetadata { + if x != nil { + return x.Catalog + } + return nil +} + +func (x *ModelSpec) GetMaxContextWindow() int64 { + if x != nil && x.MaxContextWindow != nil { + return *x.MaxContextWindow + } + return 0 +} + +func (x *ModelSpec) GetEffectiveContextWindowPercent() float64 { + if x != nil && x.EffectiveContextWindowPercent != nil { + return *x.EffectiveContextWindowPercent + } + return 0 +} + +func (x *ModelSpec) GetAutoCompactTokenLimit() int64 { + if x != nil && x.AutoCompactTokenLimit != nil { + return *x.AutoCompactTokenLimit + } + return 0 +} + +func (x *ModelSpec) GetVerbosity() *VerbositySpec { + if x != nil { + return x.Verbosity + } + return nil +} + +func (x *ModelSpec) GetServiceTiers() []string { + if x != nil { + return x.ServiceTiers + } + return nil +} + +func (x *ModelSpec) GetApiBackend() string { + if x != nil && x.ApiBackend != nil { + return *x.ApiBackend + } + return "" +} + +func (x *ModelSpec) GetTruncationPolicy() string { + if x != nil && x.TruncationPolicy != nil { + return *x.TruncationPolicy + } + return "" +} + +func (x *ModelSpec) GetCompHash() string { + if x != nil && x.CompHash != nil { + return *x.CompHash + } + return "" +} + +// CatalogMetadata is the human-facing description of a model — what a +// picker shows, not what the kernel routes on. +// +// Grouped into its own message rather than flattened onto ModelSpec +// because none of it is behavioral: a kernel that ignored this message +// entirely would route, budget, and bill identically. Keeping the +// separation makes that obvious at a glance instead of leaving a reader +// to work out which of twenty ModelSpec fields change behavior. +type CatalogMetadata struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The model's display name ("Grok 4.5", "GPT-5 Codex"), for a picker. + // Absent means a frontend falls back to ModelSpec.id. + DisplayName *string `protobuf:"bytes,1,opt,name=display_name,json=displayName,proto3,oneof" json:"display_name,omitempty"` + // A one-line description of what the model is for. + Description *string `protobuf:"bytes,2,opt,name=description,proto3,oneof" json:"description,omitempty"` + // Whether this model should be offered in a picker by default. + // Vendors publish models that exist but are deprecated, internal, or + // gated; absent means visible. + Visible *bool `protobuf:"varint,3,opt,name=visible,proto3,oneof" json:"visible,omitempty"` + // A sort weight for a picker, higher first. Absent means unranked, and + // a frontend orders by whatever it likes. + Priority *int32 `protobuf:"varint,4,opt,name=priority,proto3,oneof" json:"priority,omitempty"` + // Whether this model is reachable with an API key, as opposed to only + // through a product session. + // + // Paired with AuthDescriptor.method this is what lets a frontend hide + // models the current credential cannot actually reach, instead of + // offering one that fails at first use. + SupportedInApi *bool `protobuf:"varint,5,opt,name=supported_in_api,json=supportedInApi,proto3,oneof" json:"supported_in_api,omitempty"` + // Other ids that resolve to this same model. A vendor publishing + // `grok-4` as an alias of `grok-4.3` lists it here. + // + // Aliases are NOT separate ModelSpec entries: expanding them into one + // spec each is what makes a catalog appear to hold several distinct + // models that are one model, and makes a picker offer the same thing + // three times. + Aliases []string `protobuf:"bytes,6,rep,name=aliases,proto3" json:"aliases,omitempty"` + // The model family this belongs to, for grouping variants that differ + // only by size or revision. Opaque to the kernel. + Family *string `protobuf:"bytes,7,opt,name=family,proto3,oneof" json:"family,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CatalogMetadata) Reset() { + *x = CatalogMetadata{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CatalogMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CatalogMetadata) ProtoMessage() {} + +func (x *CatalogMetadata) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CatalogMetadata.ProtoReflect.Descriptor instead. +func (*CatalogMetadata) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{4} +} + +func (x *CatalogMetadata) GetDisplayName() string { + if x != nil && x.DisplayName != nil { + return *x.DisplayName + } + return "" +} + +func (x *CatalogMetadata) GetDescription() string { + if x != nil && x.Description != nil { + return *x.Description + } + return "" +} + +func (x *CatalogMetadata) GetVisible() bool { + if x != nil && x.Visible != nil { + return *x.Visible + } + return false +} + +func (x *CatalogMetadata) GetPriority() int32 { + if x != nil && x.Priority != nil { + return *x.Priority + } + return 0 +} + +func (x *CatalogMetadata) GetSupportedInApi() bool { + if x != nil && x.SupportedInApi != nil { + return *x.SupportedInApi + } + return false +} + +func (x *CatalogMetadata) GetAliases() []string { + if x != nil { + return x.Aliases + } + return nil +} + +func (x *CatalogMetadata) GetFamily() string { + if x != nil && x.Family != nil { + return *x.Family + } + return "" +} + +// VerbositySpec declares a model's output-verbosity control, where the +// vendor exposes one — a knob distinct from thinking effort, which +// governs reasoning depth rather than answer length. +type VerbositySpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether this model accepts a verbosity setting at all. When false, + // levels MUST be empty and default MUST be absent. + Supported bool `protobuf:"varint,1,opt,name=supported,proto3" json:"supported,omitempty"` + // The accepted level names, in the vendor's own vocabulary ("low", + // "medium", "high"), ordered least to most verbose. + Levels []string `protobuf:"bytes,2,rep,name=levels,proto3" json:"levels,omitempty"` + // The level applied when a request names none. MUST be one of levels + // when set. + Default *string `protobuf:"bytes,3,opt,name=default,proto3,oneof" json:"default,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerbositySpec) Reset() { + *x = VerbositySpec{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerbositySpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerbositySpec) ProtoMessage() {} + +func (x *VerbositySpec) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerbositySpec.ProtoReflect.Descriptor instead. +func (*VerbositySpec) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{5} +} + +func (x *VerbositySpec) GetSupported() bool { + if x != nil { + return x.Supported + } + return false +} + +func (x *VerbositySpec) GetLevels() []string { + if x != nil { + return x.Levels + } + return nil +} + +func (x *VerbositySpec) GetDefault() string { + if x != nil && x.Default != nil { + return *x.Default + } + return "" +} + // ThinkingBudgetRange bounds the token budget a caller may request on a // model whose ThinkingSpec declares a BudgetControl. Both bounds are // inclusive. @@ -500,7 +1281,7 @@ type ThinkingBudgetRange struct { func (x *ThinkingBudgetRange) Reset() { *x = ThinkingBudgetRange{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[2] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -512,7 +1293,7 @@ func (x *ThinkingBudgetRange) String() string { func (*ThinkingBudgetRange) ProtoMessage() {} func (x *ThinkingBudgetRange) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[2] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -525,7 +1306,7 @@ func (x *ThinkingBudgetRange) ProtoReflect() protoreflect.Message { // Deprecated: Use ThinkingBudgetRange.ProtoReflect.Descriptor instead. func (*ThinkingBudgetRange) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{6} } func (x *ThinkingBudgetRange) GetMin() int64 { @@ -562,7 +1343,7 @@ type EffortControl struct { func (x *EffortControl) Reset() { *x = EffortControl{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[3] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -574,7 +1355,7 @@ func (x *EffortControl) String() string { func (*EffortControl) ProtoMessage() {} func (x *EffortControl) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[3] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -587,7 +1368,7 @@ func (x *EffortControl) ProtoReflect() protoreflect.Message { // Deprecated: Use EffortControl.ProtoReflect.Descriptor instead. func (*EffortControl) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{3} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{7} } func (x *EffortControl) GetLevels() []string { @@ -626,7 +1407,7 @@ type BudgetControl struct { func (x *BudgetControl) Reset() { *x = BudgetControl{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[4] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -638,7 +1419,7 @@ func (x *BudgetControl) String() string { func (*BudgetControl) ProtoMessage() {} func (x *BudgetControl) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[4] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -651,7 +1432,7 @@ func (x *BudgetControl) ProtoReflect() protoreflect.Message { // Deprecated: Use BudgetControl.ProtoReflect.Descriptor instead. func (*BudgetControl) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{4} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{8} } func (x *BudgetControl) GetRange() *ThinkingBudgetRange { @@ -707,14 +1488,22 @@ type ThinkingSpec struct { AdaptiveByDefault bool `protobuf:"varint,9,opt,name=adaptive_by_default,json=adaptiveByDefault,proto3" json:"adaptive_by_default,omitempty"` // Whether, and when, reasoning can be turned off. MUST be set when // supported is true. - Disable ThinkingDisableSupport `protobuf:"varint,10,opt,name=disable,proto3,enum=pluggableharness.model.v1.ThinkingDisableSupport" json:"disable,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Disable ThinkingDisableSupport `protobuf:"varint,10,opt,name=disable,proto3,enum=pluggableharness.model.v1.ThinkingDisableSupport" json:"disable,omitempty"` + // Whether this model can emit a reasoning *summary* distinct from its + // raw reasoning stream. When true, a StreamEvent's thinking deltas may + // carry a channel distinguishing the two. + SupportsReasoningSummary *bool `protobuf:"varint,11,opt,name=supports_reasoning_summary,json=supportsReasoningSummary,proto3,oneof" json:"supports_reasoning_summary,omitempty"` + // The summary mode applied when a request names none, in the vendor's + // own vocabulary ("auto", "concise", "detailed"). Meaningless unless + // supports_reasoning_summary is true. + DefaultReasoningSummary *string `protobuf:"bytes,12,opt,name=default_reasoning_summary,json=defaultReasoningSummary,proto3,oneof" json:"default_reasoning_summary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ThinkingSpec) Reset() { *x = ThinkingSpec{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[5] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -726,7 +1515,7 @@ func (x *ThinkingSpec) String() string { func (*ThinkingSpec) ProtoMessage() {} func (x *ThinkingSpec) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[5] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -739,7 +1528,7 @@ func (x *ThinkingSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use ThinkingSpec.ProtoReflect.Descriptor instead. func (*ThinkingSpec) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{5} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{9} } func (x *ThinkingSpec) GetSupported() bool { @@ -777,6 +1566,20 @@ func (x *ThinkingSpec) GetDisable() ThinkingDisableSupport { return ThinkingDisableSupport_THINKING_DISABLE_SUPPORT_UNSPECIFIED } +func (x *ThinkingSpec) GetSupportsReasoningSummary() bool { + if x != nil && x.SupportsReasoningSummary != nil { + return *x.SupportsReasoningSummary + } + return false +} + +func (x *ThinkingSpec) GetDefaultReasoningSummary() string { + if x != nil && x.DefaultReasoningSummary != nil { + return *x.DefaultReasoningSummary + } + return "" +} + // CachingSpec describes one model's prompt-caching capability, per // model/data-types.md#cachingspec. // @@ -822,7 +1625,7 @@ type CachingSpec struct { func (x *CachingSpec) Reset() { *x = CachingSpec{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[6] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -834,7 +1637,7 @@ func (x *CachingSpec) String() string { func (*CachingSpec) ProtoMessage() {} func (x *CachingSpec) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[6] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -847,7 +1650,7 @@ func (x *CachingSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use CachingSpec.ProtoReflect.Descriptor instead. func (*CachingSpec) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{6} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{10} } func (x *CachingSpec) GetSupported() bool { @@ -927,13 +1730,23 @@ type PricingTier struct { // Omitted means unbounded above. Half-open with input_tokens_from, // matching effective_from/effective_until's half-open convention. InputTokensUntil *int64 `protobuf:"varint,10,opt,name=input_tokens_until,json=inputTokensUntil,proto3,oneof" json:"input_tokens_until,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Price per million image input tokens, where the vendor rates image + // input separately from text. + // + // Absent means image input bills at input_per_mtok — the correct + // reading for every vendor that does not price it separately, and the + // behavior before this field existed. + ImageInputPerMtok *float64 `protobuf:"fixed64,11,opt,name=image_input_per_mtok,json=imageInputPerMtok,proto3,oneof" json:"image_input_per_mtok,omitempty"` + // Price per million audio input tokens, on the same terms as + // image_input_per_mtok. + AudioInputPerMtok *float64 `protobuf:"fixed64,12,opt,name=audio_input_per_mtok,json=audioInputPerMtok,proto3,oneof" json:"audio_input_per_mtok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PricingTier) Reset() { *x = PricingTier{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[7] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -945,7 +1758,7 @@ func (x *PricingTier) String() string { func (*PricingTier) ProtoMessage() {} func (x *PricingTier) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[7] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -958,7 +1771,7 @@ func (x *PricingTier) ProtoReflect() protoreflect.Message { // Deprecated: Use PricingTier.ProtoReflect.Descriptor instead. func (*PricingTier) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{7} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{11} } func (x *PricingTier) GetEffectiveFrom() *timestamppb.Timestamp { @@ -1031,6 +1844,20 @@ func (x *PricingTier) GetInputTokensUntil() int64 { return 0 } +func (x *PricingTier) GetImageInputPerMtok() float64 { + if x != nil && x.ImageInputPerMtok != nil { + return *x.ImageInputPerMtok + } + return 0 +} + +func (x *PricingTier) GetAudioInputPerMtok() float64 { + if x != nil && x.AudioInputPerMtok != nil { + return *x.AudioInputPerMtok + } + return 0 +} + // Pricing describes one model's cost structure, per model.md §2. MUST // be present on every ModelSpec, even a free one. type Pricing struct { @@ -1046,14 +1873,24 @@ type Pricing struct { // least one entry unless free == true. Exactly one tier MUST match any // given timestamp; the kernel MUST reject overlapping or gapped tiers // at capability-load time. - Tiers []*PricingTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"` + Tiers []*PricingTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"` + // The vendor's own pricing unit these rates were converted from, when + // the adapter had to convert — an integer per-token price, a tick + // scale, a per-thousand rate. + // + // Recorded for audit, never used in computation: the kernel bills from + // the per-MTok rates above regardless. It exists because the + // conversion is otherwise adapter-private, which makes a ledger figure + // that disagrees with a vendor invoice impossible to trace back to + // whether the rate or the arithmetic was wrong. + SourceUnit *string `protobuf:"bytes,4,opt,name=source_unit,json=sourceUnit,proto3,oneof" json:"source_unit,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Pricing) Reset() { *x = Pricing{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[8] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1065,7 +1902,7 @@ func (x *Pricing) String() string { func (*Pricing) ProtoMessage() {} func (x *Pricing) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[8] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1078,7 +1915,7 @@ func (x *Pricing) ProtoReflect() protoreflect.Message { // Deprecated: Use Pricing.ProtoReflect.Descriptor instead. func (*Pricing) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{8} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{12} } func (x *Pricing) GetCurrency() string { @@ -1102,6 +1939,13 @@ func (x *Pricing) GetTiers() []*PricingTier { return nil } +func (x *Pricing) GetSourceUnit() string { + if x != nil && x.SourceUnit != nil { + return *x.SourceUnit + } + return "" +} + // CacheBreakpoint marks one position in a StreamCompletionRequest where // the kernel wants the adapter to insert a vendor-native cache-control // marker, per StreamCompletionRequest.cache_breakpoints above. @@ -1121,7 +1965,7 @@ type CacheBreakpoint struct { func (x *CacheBreakpoint) Reset() { *x = CacheBreakpoint{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[9] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1133,7 +1977,7 @@ func (x *CacheBreakpoint) String() string { func (*CacheBreakpoint) ProtoMessage() {} func (x *CacheBreakpoint) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[9] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1146,7 +1990,7 @@ func (x *CacheBreakpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use CacheBreakpoint.ProtoReflect.Descriptor instead. func (*CacheBreakpoint) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{9} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{13} } func (x *CacheBreakpoint) GetPosition() isCacheBreakpoint_Position { @@ -1231,7 +2075,7 @@ type ToolDeclaration struct { func (x *ToolDeclaration) Reset() { *x = ToolDeclaration{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[10] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1243,7 +2087,7 @@ func (x *ToolDeclaration) String() string { func (*ToolDeclaration) ProtoMessage() {} func (x *ToolDeclaration) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[10] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1256,7 +2100,7 @@ func (x *ToolDeclaration) ProtoReflect() protoreflect.Message { // Deprecated: Use ToolDeclaration.ProtoReflect.Descriptor instead. func (*ToolDeclaration) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{10} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{14} } func (x *ToolDeclaration) GetName() string { @@ -1313,14 +2157,41 @@ type GenerationParams struct { // thinking_effort/thinking_budget_tokens' ThinkingSpec-validation rule // above — an unsupported mode is a kernel-level reject-or-fallback, not // something forwarded to the vendor. - ToolChoice *ToolChoice `protobuf:"bytes,6,opt,name=tool_choice,json=toolChoice,proto3,oneof" json:"tool_choice,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ToolChoice *ToolChoice `protobuf:"bytes,6,opt,name=tool_choice,json=toolChoice,proto3,oneof" json:"tool_choice,omitempty"` + // The service or speed tier to serve this request at. MUST be one of + // the target ModelSpec.service_tiers; the kernel rejects a tier the + // model does not advertise rather than forwarding it, matching + // tool_choice's own validate-then-send rule above. + ServiceTier *string `protobuf:"bytes,7,opt,name=service_tier,json=serviceTier,proto3,oneof" json:"service_tier,omitempty"` + // The output-verbosity level — answer length, distinct from + // thinking_effort's reasoning depth. MUST be one of the target + // model's VerbositySpec.levels. + Verbosity *string `protobuf:"bytes,8,opt,name=verbosity,proto3,oneof" json:"verbosity,omitempty"` + // Constrains the response to a structured format. + ResponseFormat *ResponseFormat `protobuf:"bytes,9,opt,name=response_format,json=responseFormat,proto3,oneof" json:"response_format,omitempty"` + // An opaque key grouping requests the vendor should cache together. + // + // Typed rather than left to provider_options because the kernel does + // act on caching — cache_read_tokens and cache_write_tokens feed + // cost_usd, and data-types.md's provider_options rule is explicit that + // a field affecting cost computation cannot live there. + PromptCacheKey *string `protobuf:"bytes,10,opt,name=prompt_cache_key,json=promptCacheKey,proto3,oneof" json:"prompt_cache_key,omitempty"` + // Whether the vendor should retain this request server-side, for + // vendors that offer it. Absent leaves the vendor's own default. + Store *bool `protobuf:"varint,11,opt,name=store,proto3,oneof" json:"store,omitempty"` + // Overrides ModelSpec.supports_parallel_tool_calls for this one + // request. MUST NOT be set true for a model that does not support it. + ParallelToolCalls *bool `protobuf:"varint,12,opt,name=parallel_tool_calls,json=parallelToolCalls,proto3,oneof" json:"parallel_tool_calls,omitempty"` + // The reasoning-summary mode for this request, where the model + // advertises ThinkingSpec.supports_reasoning_summary. + ReasoningSummary *string `protobuf:"bytes,13,opt,name=reasoning_summary,json=reasoningSummary,proto3,oneof" json:"reasoning_summary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GenerationParams) Reset() { *x = GenerationParams{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[11] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1332,7 +2203,7 @@ func (x *GenerationParams) String() string { func (*GenerationParams) ProtoMessage() {} func (x *GenerationParams) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[11] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1345,7 +2216,7 @@ func (x *GenerationParams) ProtoReflect() protoreflect.Message { // Deprecated: Use GenerationParams.ProtoReflect.Descriptor instead. func (*GenerationParams) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{11} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{15} } func (x *GenerationParams) GetThinkingEffort() string { @@ -1390,6 +2261,126 @@ func (x *GenerationParams) GetToolChoice() *ToolChoice { return nil } +func (x *GenerationParams) GetServiceTier() string { + if x != nil && x.ServiceTier != nil { + return *x.ServiceTier + } + return "" +} + +func (x *GenerationParams) GetVerbosity() string { + if x != nil && x.Verbosity != nil { + return *x.Verbosity + } + return "" +} + +func (x *GenerationParams) GetResponseFormat() *ResponseFormat { + if x != nil { + return x.ResponseFormat + } + return nil +} + +func (x *GenerationParams) GetPromptCacheKey() string { + if x != nil && x.PromptCacheKey != nil { + return *x.PromptCacheKey + } + return "" +} + +func (x *GenerationParams) GetStore() bool { + if x != nil && x.Store != nil { + return *x.Store + } + return false +} + +func (x *GenerationParams) GetParallelToolCalls() bool { + if x != nil && x.ParallelToolCalls != nil { + return *x.ParallelToolCalls + } + return false +} + +func (x *GenerationParams) GetReasoningSummary() string { + if x != nil && x.ReasoningSummary != nil { + return *x.ReasoningSummary + } + return "" +} + +// ResponseFormat constrains a completion's shape, for vendors offering +// structured output. +type ResponseFormat struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The format kind. + Kind ResponseFormatKind `protobuf:"varint,1,opt,name=kind,proto3,enum=pluggableharness.model.v1.ResponseFormatKind" json:"kind,omitempty"` + // The JSON Schema the response MUST conform to. Required when kind is + // RESPONSE_FORMAT_KIND_JSON_SCHEMA, meaningless otherwise. + // + // Uses the same restricted JSON-Schema subset as tool parameters + // (schema.v1), deliberately: a vendor accepting one and not the other + // is an adapter concern, and two schema dialects in one protocol would + // be two things for a plugin author to learn. + JsonSchema *v12.Schema `protobuf:"bytes,2,opt,name=json_schema,json=jsonSchema,proto3,oneof" json:"json_schema,omitempty"` + // A name for the schema, where the vendor requires one. + SchemaName *string `protobuf:"bytes,3,opt,name=schema_name,json=schemaName,proto3,oneof" json:"schema_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResponseFormat) Reset() { + *x = ResponseFormat{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResponseFormat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResponseFormat) ProtoMessage() {} + +func (x *ResponseFormat) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResponseFormat.ProtoReflect.Descriptor instead. +func (*ResponseFormat) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{16} +} + +func (x *ResponseFormat) GetKind() ResponseFormatKind { + if x != nil { + return x.Kind + } + return ResponseFormatKind_RESPONSE_FORMAT_KIND_UNSPECIFIED +} + +func (x *ResponseFormat) GetJsonSchema() *v12.Schema { + if x != nil { + return x.JsonSchema + } + return nil +} + +func (x *ResponseFormat) GetSchemaName() string { + if x != nil && x.SchemaName != nil { + return *x.SchemaName + } + return "" +} + // ToolChoice carries one request's tool-invocation constraint, per // GenerationParams.tool_choice above. type ToolChoice struct { @@ -1407,7 +2398,7 @@ type ToolChoice struct { func (x *ToolChoice) Reset() { *x = ToolChoice{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[12] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1419,7 +2410,7 @@ func (x *ToolChoice) String() string { func (*ToolChoice) ProtoMessage() {} func (x *ToolChoice) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[12] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1432,7 +2423,7 @@ func (x *ToolChoice) ProtoReflect() protoreflect.Message { // Deprecated: Use ToolChoice.ProtoReflect.Descriptor instead. func (*ToolChoice) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{12} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{17} } func (x *ToolChoice) GetMode() ToolChoiceMode { @@ -1485,14 +2476,51 @@ type Usage struct { // token headers, Anthropic reports input and output separately. Naming // which budget is close to empty is the whole point — "you have 2% // left" is unactionable without saying 2% of what. - RateLimits []*RateLimitSnapshot `protobuf:"bytes,6,rep,name=rate_limits,json=rateLimits,proto3" json:"rate_limits,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + RateLimits []*RateLimitSnapshot `protobuf:"bytes,6,rep,name=rate_limits,json=rateLimits,proto3" json:"rate_limits,omitempty"` + // What the vendor says this completion cost, in the vendor's own + // denomination, when it reports a figure at all (xAI returns + // `cost_in_usd_ticks`). + // + // Reported, never authoritative. The kernel still computes and + // persists cost_usd from the token counts above plus the matching + // PricingTier, and every rollup, budget, and replay reads that + // computed figure — see model.md §4.1. This field is persisted + // alongside it for display and reconciliation, so an operator can see + // that list price and actual bill disagree instead of having to guess. + // Making it authoritative would put two costs in the ledger with no + // deterministic rule for which one a replay reproduces + // (.claude/rules/determinism.md). + VendorCost *VendorCost `protobuf:"bytes,7,opt,name=vendor_cost,json=vendorCost,proto3,oneof" json:"vendor_cost,omitempty"` + // The vendor's own total-token figure, when it publishes one that is + // not simply the sum of the parts above. Recorded rather than + // recomputed precisely so a disagreement stays visible; the kernel + // never derives this and never bills from it. + VendorTotalTokens *int64 `protobuf:"varint,8,opt,name=vendor_total_tokens,json=vendorTotalTokens,proto3,oneof" json:"vendor_total_tokens,omitempty"` + // Vendor-defined counters with no first-class field: per-modality + // input tokens (text/image/audio), accepted/rejected prediction + // tokens, hosted-tool source counts. Opaque to the kernel, which + // stores and surfaces them without interpretation. + // + // The kernel MUST sort these by name before persisting: a repeated + // field reaching a persisted payload in adapter-emission order would + // make the event log depend on map iteration inside the adapter + // (.claude/rules/determinism.md). + Components []*UsageComponent `protobuf:"bytes,9,rep,name=components,proto3" json:"components,omitempty"` + // Whether reasoning_tokens is already included in output_tokens + // because the vendor said so out of band (OpenAI's + // `X-Reasoning-Included`). Absent means "not stated", which the kernel + // treats as the documented default for reasoning_tokens above: a + // distinct count, not folded in. Set true only on a vendor's explicit + // signal — it exists to stop the kernel double-counting reasoning in + // its own estimates, so guessing defeats the purpose. + ReasoningAlreadyCounted *bool `protobuf:"varint,10,opt,name=reasoning_already_counted,json=reasoningAlreadyCounted,proto3,oneof" json:"reasoning_already_counted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Usage) Reset() { *x = Usage{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[13] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1504,7 +2532,7 @@ func (x *Usage) String() string { func (*Usage) ProtoMessage() {} func (x *Usage) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[13] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1517,7 +2545,7 @@ func (x *Usage) ProtoReflect() protoreflect.Message { // Deprecated: Use Usage.ProtoReflect.Descriptor instead. func (*Usage) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{13} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{18} } func (x *Usage) GetInputTokens() int64 { @@ -1562,6 +2590,176 @@ func (x *Usage) GetRateLimits() []*RateLimitSnapshot { return nil } +func (x *Usage) GetVendorCost() *VendorCost { + if x != nil { + return x.VendorCost + } + return nil +} + +func (x *Usage) GetVendorTotalTokens() int64 { + if x != nil && x.VendorTotalTokens != nil { + return *x.VendorTotalTokens + } + return 0 +} + +func (x *Usage) GetComponents() []*UsageComponent { + if x != nil { + return x.Components + } + return nil +} + +func (x *Usage) GetReasoningAlreadyCounted() bool { + if x != nil && x.ReasoningAlreadyCounted != nil { + return *x.ReasoningAlreadyCounted + } + return false +} + +// VendorCost is a vendor's own price for one completion, in whatever +// unit that vendor bills in. +// +// The amount is a decimal string rather than a double because these are +// exact monetary quantities and binary floating point cannot represent +// them exactly — a ledger that must reconcile against an invoice cannot +// afford the rounding. +type VendorCost struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The amount, as an exact decimal string ("0.00241", "24100000"). + // MUST parse as a decimal number; MUST NOT carry a currency symbol, + // thousands separators, or exponent notation. + Amount string `protobuf:"bytes,1,opt,name=amount,proto3" json:"amount,omitempty"` + // The unit `amount` is denominated in, naming the vendor's own scale + // where it is not plain currency: "usd", "xai_ticks_1e10". Opaque to + // the kernel, which never converts between units — a conversion the + // kernel invented would be one more unaudited number in the ledger. + Unit string `protobuf:"bytes,2,opt,name=unit,proto3" json:"unit,omitempty"` + // The ISO 4217 currency, when `unit` is a currency-denominated one and + // the vendor bills in something other than USD. + Currency *string `protobuf:"bytes,3,opt,name=currency,proto3,oneof" json:"currency,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VendorCost) Reset() { + *x = VendorCost{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VendorCost) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VendorCost) ProtoMessage() {} + +func (x *VendorCost) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VendorCost.ProtoReflect.Descriptor instead. +func (*VendorCost) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{19} +} + +func (x *VendorCost) GetAmount() string { + if x != nil { + return x.Amount + } + return "" +} + +func (x *VendorCost) GetUnit() string { + if x != nil { + return x.Unit + } + return "" +} + +func (x *VendorCost) GetCurrency() string { + if x != nil && x.Currency != nil { + return *x.Currency + } + return "" +} + +// UsageComponent is one vendor-defined counter the protocol has no +// typed field for. +// +// A repeated name/value pair rather than a growing list of optional +// int64s because the set is vendor-specific and open-ended: every vendor +// meters a slightly different decomposition, and promoting each one to a +// field would churn this message on every provider added. +type UsageComponent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The counter's vendor-facing name, verbatim + // ("input_image_tokens", "num_sources_used", + // "accepted_prediction_tokens"). MUST be set and MUST be unique within + // one Usage. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The counter's value. Named `value` rather than `tokens` because not + // every vendor counter is a token count — `num_sources_used` counts + // documents. + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UsageComponent) Reset() { + *x = UsageComponent{} + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UsageComponent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UsageComponent) ProtoMessage() {} + +func (x *UsageComponent) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UsageComponent.ProtoReflect.Descriptor instead. +func (*UsageComponent) Descriptor() ([]byte, []int) { + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{20} +} + +func (x *UsageComponent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UsageComponent) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + // RateLimitSnapshot is one of the vendor's rate-limit budgets as of one // completion, per model/data-types.md#streamevent. // @@ -1578,14 +2776,39 @@ type RateLimitSnapshot struct { // This budget's ceiling for the current window. Limit *int64 `protobuf:"varint,3,opt,name=limit,proto3,oneof" json:"limit,omitempty"` // When this budget next resets. - ResetAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=reset_at,json=resetAt,proto3,oneof" json:"reset_at,omitempty"` + ResetAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=reset_at,json=resetAt,proto3,oneof" json:"reset_at,omitempty"` + // The vendor's stable identifier for this budget, where it names one + // ("codex", "codex_other"). Opaque to the kernel; it exists so two + // snapshots of the same budget can be correlated across completions + // even when kind and window_role are identical. + LimitId *string `protobuf:"bytes,5,opt,name=limit_id,json=limitId,proto3,oneof" json:"limit_id,omitempty"` + // A human-facing label for this budget, when the vendor supplies one + // worth showing. Never synthesized from limit_id — a frontend can fall + // back to kind and window_role perfectly well, and an invented label + // reads as authoritative. + LimitName *string `protobuf:"bytes,6,opt,name=limit_name,json=limitName,proto3,oneof" json:"limit_name,omitempty"` + // Which of the vendor's several budgets this is. + WindowRole WindowRole `protobuf:"varint,7,opt,name=window_role,json=windowRole,proto3,enum=pluggableharness.model.v1.WindowRole" json:"window_role,omitempty"` + // How much of this budget is spent, 0-100, for the products that + // publish only a percentage and never absolute counts. + // + // This exists so those adapters stop faking `limit = 100` and + // `remaining = 100 - percent` to fit the absolute fields. A vendor + // publishing real counts sets remaining/limit and leaves this unset; a + // vendor publishing only a percentage sets this and leaves those + // unset. An adapter MUST NOT derive one form from the other. + UsedPercent *float64 `protobuf:"fixed64,8,opt,name=used_percent,json=usedPercent,proto3,oneof" json:"used_percent,omitempty"` + // The budget window's length. Paired with reset_at it lets a frontend + // say "5 hours" rather than only "resets at 14:00", which is what + // makes a limit predictable instead of a surprise. + WindowSeconds *int64 `protobuf:"varint,9,opt,name=window_seconds,json=windowSeconds,proto3,oneof" json:"window_seconds,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RateLimitSnapshot) Reset() { *x = RateLimitSnapshot{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[14] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1597,7 +2820,7 @@ func (x *RateLimitSnapshot) String() string { func (*RateLimitSnapshot) ProtoMessage() {} func (x *RateLimitSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[14] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1610,7 +2833,7 @@ func (x *RateLimitSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use RateLimitSnapshot.ProtoReflect.Descriptor instead. func (*RateLimitSnapshot) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{14} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{21} } func (x *RateLimitSnapshot) GetKind() RateLimitKind { @@ -1641,6 +2864,41 @@ func (x *RateLimitSnapshot) GetResetAt() *timestamppb.Timestamp { return nil } +func (x *RateLimitSnapshot) GetLimitId() string { + if x != nil && x.LimitId != nil { + return *x.LimitId + } + return "" +} + +func (x *RateLimitSnapshot) GetLimitName() string { + if x != nil && x.LimitName != nil { + return *x.LimitName + } + return "" +} + +func (x *RateLimitSnapshot) GetWindowRole() WindowRole { + if x != nil { + return x.WindowRole + } + return WindowRole_WINDOW_ROLE_UNSPECIFIED +} + +func (x *RateLimitSnapshot) GetUsedPercent() float64 { + if x != nil && x.UsedPercent != nil { + return *x.UsedPercent + } + return 0 +} + +func (x *RateLimitSnapshot) GetWindowSeconds() int64 { + if x != nil && x.WindowSeconds != nil { + return *x.WindowSeconds + } + return 0 +} + // ModelTarget describes the model a context or memory contribution is // being assembled for, derived from that model's ModelSpec // (model.md §2). Carried on context.md's ContextRequest and memory.md's @@ -1666,7 +2924,7 @@ type ModelTarget struct { func (x *ModelTarget) Reset() { *x = ModelTarget{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[15] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1678,7 +2936,7 @@ func (x *ModelTarget) String() string { func (*ModelTarget) ProtoMessage() {} func (x *ModelTarget) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[15] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1691,7 +2949,7 @@ func (x *ModelTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use ModelTarget.ProtoReflect.Descriptor instead. func (*ModelTarget) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{15} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{22} } func (x *ModelTarget) GetId() string { @@ -1732,7 +2990,7 @@ type ModelRef struct { func (x *ModelRef) Reset() { *x = ModelRef{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[16] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1744,7 +3002,7 @@ func (x *ModelRef) String() string { func (*ModelRef) ProtoMessage() {} func (x *ModelRef) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[16] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1757,7 +3015,7 @@ func (x *ModelRef) ProtoReflect() protoreflect.Message { // Deprecated: Use ModelRef.ProtoReflect.Descriptor instead. func (*ModelRef) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{16} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{23} } func (x *ModelRef) GetProvider() string { @@ -1784,7 +3042,7 @@ type CacheBreakpoint_AfterAssembledContext struct { func (x *CacheBreakpoint_AfterAssembledContext) Reset() { *x = CacheBreakpoint_AfterAssembledContext{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[17] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1796,7 +3054,7 @@ func (x *CacheBreakpoint_AfterAssembledContext) String() string { func (*CacheBreakpoint_AfterAssembledContext) ProtoMessage() {} func (x *CacheBreakpoint_AfterAssembledContext) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[17] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1809,7 +3067,7 @@ func (x *CacheBreakpoint_AfterAssembledContext) ProtoReflect() protoreflect.Mess // Deprecated: Use CacheBreakpoint_AfterAssembledContext.ProtoReflect.Descriptor instead. func (*CacheBreakpoint_AfterAssembledContext) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{9, 0} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{13, 0} } // AfterTools is an empty marker message: its presence as the set oneof @@ -1822,7 +3080,7 @@ type CacheBreakpoint_AfterTools struct { func (x *CacheBreakpoint_AfterTools) Reset() { *x = CacheBreakpoint_AfterTools{} - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[18] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1834,7 +3092,7 @@ func (x *CacheBreakpoint_AfterTools) String() string { func (*CacheBreakpoint_AfterTools) ProtoMessage() {} func (x *CacheBreakpoint_AfterTools) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_model_v1_types_proto_msgTypes[18] + mi := &file_pluggableharness_model_v1_types_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1847,19 +3105,48 @@ func (x *CacheBreakpoint_AfterTools) ProtoReflect() protoreflect.Message { // Deprecated: Use CacheBreakpoint_AfterTools.ProtoReflect.Descriptor instead. func (*CacheBreakpoint_AfterTools) Descriptor() ([]byte, []int) { - return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{9, 1} + return file_pluggableharness_model_v1_types_proto_rawDescGZIP(), []int{13, 1} } var File_pluggableharness_model_v1_types_proto protoreflect.FileDescriptor const file_pluggableharness_model_v1_types_proto_rawDesc = "" + "\n" + - "%pluggableharness/model/v1/types.proto\x12\x19pluggableharness.model.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/schema/v1/types.proto\"\xce\x02\n" + + "%pluggableharness/model/v1/types.proto\x12\x19pluggableharness.model.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/schema/v1/types.proto\"\xba\x04\n" + "\fCapabilities\x12<\n" + "\x06models\x18\x01 \x03(\v2$.pluggableharness.model.v1.ModelSpecR\x06models\x12V\n" + "\x0eslash_commands\x18\x02 \x03(\v2/.pluggableharness.common.v1.PromptExpansionSpecR\rslashCommands\x12M\n" + "\rconfig_schema\x18\x03 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + - "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\"\xb7\x05\n" + + "\x15supported_hook_points\x18\x04 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPoints\x12B\n" + + "\x04auth\x18\x05 \x01(\v2).pluggableharness.model.v1.AuthDescriptorH\x00R\x04auth\x88\x01\x01\x12&\n" + + "\fcatalog_etag\x18\x06 \x01(\tH\x01R\vcatalogEtag\x88\x01\x01\x12M\n" + + "\x12catalog_fetched_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampH\x02R\x10catalogFetchedAt\x88\x01\x01B\a\n" + + "\x05_authB\x0f\n" + + "\r_catalog_etagB\x15\n" + + "\x13_catalog_fetched_at\"\xc2\x02\n" + + "\x0eAuthDescriptor\x12=\n" + + "\x06method\x18\x01 \x01(\x0e2%.pluggableharness.model.v1.AuthMethodR\x06method\x12E\n" + + "\bmetering\x18\x02 \x01(\x0e2).pluggableharness.model.v1.MeteringDomainR\bmetering\x12\x17\n" + + "\x04plan\x18\x03 \x01(\tH\x00R\x04plan\x88\x01\x01\x12M\n" + + "\x06labels\x18\x04 \x03(\v25.pluggableharness.model.v1.AuthDescriptor.LabelsEntryR\x06labels\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\a\n" + + "\x05_plan\"\xd9\x03\n" + + "\x0fAccountSnapshot\x12=\n" + + "\x06method\x18\x01 \x01(\x0e2%.pluggableharness.model.v1.AuthMethodR\x06method\x12E\n" + + "\bmetering\x18\x02 \x01(\x0e2).pluggableharness.model.v1.MeteringDomainR\bmetering\x12\x17\n" + + "\x04plan\x18\x03 \x01(\tH\x00R\x04plan\x88\x01\x01\x12N\n" + + "\x06labels\x18\x04 \x03(\v26.pluggableharness.model.v1.AccountSnapshot.LabelsEntryR\x06labels\x12D\n" + + "\x06quotas\x18\x05 \x03(\v2,.pluggableharness.model.v1.RateLimitSnapshotR\x06quotas\x12>\n" + + "\n" + + "fetched_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\tfetchedAt\x88\x01\x01\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\a\n" + + "\x05_planB\r\n" + + "\v_fetched_at\"\xd4\n" + + "\n" + "\tModelSpec\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12%\n" + "\x0econtext_window\x18\x02 \x01(\x03R\rcontextWindow\x12*\n" + @@ -1873,8 +3160,50 @@ const file_pluggableharness_model_v1_types_proto_rawDesc = "" + "\apricing\x18\n" + " \x01(\v2\".pluggableharness.model.v1.PricingR\apricing\x12h\n" + "\x1bsupported_tool_choice_modes\x18\v \x03(\x0e2).pluggableharness.model.v1.ToolChoiceModeR\x18supportedToolChoiceModes\x12-\n" + - "\x12supports_documents\x18\f \x01(\bR\x11supportsDocumentsB\x1f\n" + - "\x1d_supports_parallel_tool_calls\"9\n" + + "\x12supports_documents\x18\f \x01(\bR\x11supportsDocuments\x12I\n" + + "\acatalog\x18\r \x01(\v2*.pluggableharness.model.v1.CatalogMetadataH\x01R\acatalog\x88\x01\x01\x121\n" + + "\x12max_context_window\x18\x0e \x01(\x03H\x02R\x10maxContextWindow\x88\x01\x01\x12L\n" + + " effective_context_window_percent\x18\x0f \x01(\x01H\x03R\x1deffectiveContextWindowPercent\x88\x01\x01\x12<\n" + + "\x18auto_compact_token_limit\x18\x10 \x01(\x03H\x04R\x15autoCompactTokenLimit\x88\x01\x01\x12K\n" + + "\tverbosity\x18\x11 \x01(\v2(.pluggableharness.model.v1.VerbositySpecH\x05R\tverbosity\x88\x01\x01\x12#\n" + + "\rservice_tiers\x18\x12 \x03(\tR\fserviceTiers\x12$\n" + + "\vapi_backend\x18\x13 \x01(\tH\x06R\n" + + "apiBackend\x88\x01\x01\x120\n" + + "\x11truncation_policy\x18\x14 \x01(\tH\aR\x10truncationPolicy\x88\x01\x01\x12 \n" + + "\tcomp_hash\x18\x15 \x01(\tH\bR\bcompHash\x88\x01\x01B\x1f\n" + + "\x1d_supports_parallel_tool_callsB\n" + + "\n" + + "\b_catalogB\x15\n" + + "\x13_max_context_windowB#\n" + + "!_effective_context_window_percentB\x1b\n" + + "\x19_auto_compact_token_limitB\f\n" + + "\n" + + "_verbosityB\x0e\n" + + "\f_api_backendB\x14\n" + + "\x12_truncation_policyB\f\n" + + "\n" + + "_comp_hash\"\xe0\x02\n" + + "\x0fCatalogMetadata\x12&\n" + + "\fdisplay_name\x18\x01 \x01(\tH\x00R\vdisplayName\x88\x01\x01\x12%\n" + + "\vdescription\x18\x02 \x01(\tH\x01R\vdescription\x88\x01\x01\x12\x1d\n" + + "\avisible\x18\x03 \x01(\bH\x02R\avisible\x88\x01\x01\x12\x1f\n" + + "\bpriority\x18\x04 \x01(\x05H\x03R\bpriority\x88\x01\x01\x12-\n" + + "\x10supported_in_api\x18\x05 \x01(\bH\x04R\x0esupportedInApi\x88\x01\x01\x12\x18\n" + + "\aaliases\x18\x06 \x03(\tR\aaliases\x12\x1b\n" + + "\x06family\x18\a \x01(\tH\x05R\x06family\x88\x01\x01B\x0f\n" + + "\r_display_nameB\x0e\n" + + "\f_descriptionB\n" + + "\n" + + "\b_visibleB\v\n" + + "\t_priorityB\x13\n" + + "\x11_supported_in_apiB\t\n" + + "\a_family\"p\n" + + "\rVerbositySpec\x12\x1c\n" + + "\tsupported\x18\x01 \x01(\bR\tsupported\x12\x16\n" + + "\x06levels\x18\x02 \x03(\tR\x06levels\x12\x1d\n" + + "\adefault\x18\x03 \x01(\tH\x00R\adefault\x88\x01\x01B\n" + + "\n" + + "\b_default\"9\n" + "\x13ThinkingBudgetRange\x12\x10\n" + "\x03min\x18\x01 \x01(\x03R\x03min\x12\x10\n" + "\x03max\x18\x02 \x01(\x03R\x03max\"A\n" + @@ -1888,21 +3217,25 @@ const file_pluggableharness_model_v1_types_proto_rawDesc = "" + "deprecated\x18\x03 \x01(\bR\n" + "deprecatedB\n" + "\n" + - "\b_default\"\x8c\x03\n" + + "\b_default\"\xcd\x04\n" + "\fThinkingSpec\x12\x1c\n" + "\tsupported\x18\x01 \x01(\bR\tsupported\x12E\n" + "\x06effort\x18\a \x01(\v2(.pluggableharness.model.v1.EffortControlH\x00R\x06effort\x88\x01\x01\x12E\n" + "\x06budget\x18\b \x01(\v2(.pluggableharness.model.v1.BudgetControlH\x01R\x06budget\x88\x01\x01\x12.\n" + "\x13adaptive_by_default\x18\t \x01(\bR\x11adaptiveByDefault\x12K\n" + "\adisable\x18\n" + - " \x01(\x0e21.pluggableharness.model.v1.ThinkingDisableSupportR\adisableB\t\n" + + " \x01(\x0e21.pluggableharness.model.v1.ThinkingDisableSupportR\adisable\x12A\n" + + "\x1asupports_reasoning_summary\x18\v \x01(\bH\x02R\x18supportsReasoningSummary\x88\x01\x01\x12?\n" + + "\x19default_reasoning_summary\x18\f \x01(\tH\x03R\x17defaultReasoningSummary\x88\x01\x01B\t\n" + "\a_effortB\t\n" + - "\a_budgetJ\x04\b\x02\x10\aR\fbudget_rangeR\vcan_disableR\adefaultR\reffort_levelsR\x04mode\"\xc2\x01\n" + + "\a_budgetB\x1d\n" + + "\x1b_supports_reasoning_summaryB\x1c\n" + + "\x1a_default_reasoning_summaryJ\x04\b\x02\x10\aR\fbudget_rangeR\vcan_disableR\adefaultR\reffort_levelsR\x04mode\"\xc2\x01\n" + "\vCachingSpec\x12\x1c\n" + "\tsupported\x18\x01 \x01(\bR\tsupported\x12/\n" + "\x13keepalive_supported\x18\x03 \x01(\bR\x12keepaliveSupported\x12)\n" + "\x10explicit_markers\x18\x04 \x01(\bR\x0fexplicitMarkers\x12-\n" + - "\x12implicit_automatic\x18\x05 \x01(\bR\x11implicitAutomaticJ\x04\b\x02\x10\x03R\x04mode\"\xe1\x05\n" + + "\x12implicit_automatic\x18\x05 \x01(\bR\x11implicitAutomaticJ\x04\b\x02\x10\x03R\x04mode\"\xff\x06\n" + "\vPricingTier\x12F\n" + "\x0eeffective_from\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampH\x00R\reffectiveFrom\x88\x01\x01\x12H\n" + "\x0feffective_until\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\x0eeffectiveUntil\x88\x01\x01\x12$\n" + @@ -1914,7 +3247,9 @@ const file_pluggableharness_model_v1_types_proto_rawDesc = "" + "\x15batch_output_per_mtok\x18\b \x01(\x01H\x05R\x12batchOutputPerMtok\x88\x01\x01\x12/\n" + "\x11input_tokens_from\x18\t \x01(\x03H\x06R\x0finputTokensFrom\x88\x01\x01\x121\n" + "\x12input_tokens_until\x18\n" + - " \x01(\x03H\aR\x10inputTokensUntil\x88\x01\x01B\x11\n" + + " \x01(\x03H\aR\x10inputTokensUntil\x88\x01\x01\x124\n" + + "\x14image_input_per_mtok\x18\v \x01(\x01H\bR\x11imageInputPerMtok\x88\x01\x01\x124\n" + + "\x14audio_input_per_mtok\x18\f \x01(\x01H\tR\x11audioInputPerMtok\x88\x01\x01B\x11\n" + "\x0f_effective_fromB\x12\n" + "\x10_effective_untilB\x17\n" + "\x15_cache_write_per_mtokB\x16\n" + @@ -1922,11 +3257,16 @@ const file_pluggableharness_model_v1_types_proto_rawDesc = "" + "\x15_batch_input_per_mtokB\x18\n" + "\x16_batch_output_per_mtokB\x14\n" + "\x12_input_tokens_fromB\x15\n" + - "\x13_input_tokens_until\"w\n" + + "\x13_input_tokens_untilB\x17\n" + + "\x15_image_input_per_mtokB\x17\n" + + "\x15_audio_input_per_mtok\"\xad\x01\n" + "\aPricing\x12\x1a\n" + "\bcurrency\x18\x01 \x01(\tR\bcurrency\x12\x12\n" + "\x04free\x18\x02 \x01(\bR\x04free\x12<\n" + - "\x05tiers\x18\x03 \x03(\v2&.pluggableharness.model.v1.PricingTierR\x05tiers\"\xcc\x02\n" + + "\x05tiers\x18\x03 \x03(\v2&.pluggableharness.model.v1.PricingTierR\x05tiers\x12$\n" + + "\vsource_unit\x18\x04 \x01(\tH\x00R\n" + + "sourceUnit\x88\x01\x01B\x0e\n" + + "\f_source_unit\"\xcc\x02\n" + "\x0fCacheBreakpoint\x12z\n" + "\x17after_assembled_context\x18\x01 \x01(\v2@.pluggableharness.model.v1.CacheBreakpoint.AfterAssembledContextH\x00R\x15afterAssembledContext\x12X\n" + "\vafter_tools\x18\x02 \x01(\v25.pluggableharness.model.v1.CacheBreakpoint.AfterToolsH\x00R\n" + @@ -1940,7 +3280,7 @@ const file_pluggableharness_model_v1_types_proto_rawDesc = "" + "\x0fToolDeclaration\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12E\n" + - "\finput_schema\x18\x03 \x01(\v2\".pluggableharness.schema.v1.SchemaR\vinputSchema\"\xac\x03\n" + + "\finput_schema\x18\x03 \x01(\v2\".pluggableharness.schema.v1.SchemaR\vinputSchema\"\x81\a\n" + "\x10GenerationParams\x12,\n" + "\x0fthinking_effort\x18\x01 \x01(\tH\x00R\x0ethinkingEffort\x88\x01\x01\x129\n" + "\x16thinking_budget_tokens\x18\x02 \x01(\x03H\x01R\x14thinkingBudgetTokens\x88\x01\x01\x12/\n" + @@ -1948,18 +3288,43 @@ const file_pluggableharness_model_v1_types_proto_rawDesc = "" + "\vtemperature\x18\x04 \x01(\x01H\x03R\vtemperature\x88\x01\x01\x12%\n" + "\x0estop_sequences\x18\x05 \x03(\tR\rstopSequences\x12K\n" + "\vtool_choice\x18\x06 \x01(\v2%.pluggableharness.model.v1.ToolChoiceH\x04R\n" + - "toolChoice\x88\x01\x01B\x12\n" + + "toolChoice\x88\x01\x01\x12&\n" + + "\fservice_tier\x18\a \x01(\tH\x05R\vserviceTier\x88\x01\x01\x12!\n" + + "\tverbosity\x18\b \x01(\tH\x06R\tverbosity\x88\x01\x01\x12W\n" + + "\x0fresponse_format\x18\t \x01(\v2).pluggableharness.model.v1.ResponseFormatH\aR\x0eresponseFormat\x88\x01\x01\x12-\n" + + "\x10prompt_cache_key\x18\n" + + " \x01(\tH\bR\x0epromptCacheKey\x88\x01\x01\x12\x19\n" + + "\x05store\x18\v \x01(\bH\tR\x05store\x88\x01\x01\x123\n" + + "\x13parallel_tool_calls\x18\f \x01(\bH\n" + + "R\x11parallelToolCalls\x88\x01\x01\x120\n" + + "\x11reasoning_summary\x18\r \x01(\tH\vR\x10reasoningSummary\x88\x01\x01B\x12\n" + "\x10_thinking_effortB\x19\n" + "\x17_thinking_budget_tokensB\x14\n" + "\x12_max_output_tokensB\x0e\n" + "\f_temperatureB\x0e\n" + - "\f_tool_choice\"{\n" + + "\f_tool_choiceB\x0f\n" + + "\r_service_tierB\f\n" + + "\n" + + "_verbosityB\x12\n" + + "\x10_response_formatB\x13\n" + + "\x11_prompt_cache_keyB\b\n" + + "\x06_storeB\x16\n" + + "\x14_parallel_tool_callsB\x14\n" + + "\x12_reasoning_summary\"\xe3\x01\n" + + "\x0eResponseFormat\x12A\n" + + "\x04kind\x18\x01 \x01(\x0e2-.pluggableharness.model.v1.ResponseFormatKindR\x04kind\x12H\n" + + "\vjson_schema\x18\x02 \x01(\v2\".pluggableharness.schema.v1.SchemaH\x00R\n" + + "jsonSchema\x88\x01\x01\x12$\n" + + "\vschema_name\x18\x03 \x01(\tH\x01R\n" + + "schemaName\x88\x01\x01B\x0e\n" + + "\f_json_schemaB\x0e\n" + + "\f_schema_name\"{\n" + "\n" + "ToolChoice\x12=\n" + "\x04mode\x18\x01 \x01(\x0e2).pluggableharness.model.v1.ToolChoiceModeR\x04mode\x12 \n" + "\ttool_name\x18\x02 \x01(\tH\x00R\btoolName\x88\x01\x01B\f\n" + "\n" + - "_tool_name\"\xf4\x02\n" + + "_tool_name\"\xc8\x05\n" + "\x05Usage\x12!\n" + "\finput_tokens\x18\x01 \x01(\x03R\vinputTokens\x12#\n" + "\routput_tokens\x18\x02 \x01(\x03R\foutputTokens\x12/\n" + @@ -1967,43 +3332,95 @@ const file_pluggableharness_model_v1_types_proto_rawDesc = "" + "\x12cache_write_tokens\x18\x04 \x01(\x03H\x01R\x10cacheWriteTokens\x88\x01\x01\x12.\n" + "\x10reasoning_tokens\x18\x05 \x01(\x03H\x02R\x0freasoningTokens\x88\x01\x01\x12M\n" + "\vrate_limits\x18\x06 \x03(\v2,.pluggableharness.model.v1.RateLimitSnapshotR\n" + - "rateLimitsB\x14\n" + + "rateLimits\x12K\n" + + "\vvendor_cost\x18\a \x01(\v2%.pluggableharness.model.v1.VendorCostH\x03R\n" + + "vendorCost\x88\x01\x01\x123\n" + + "\x13vendor_total_tokens\x18\b \x01(\x03H\x04R\x11vendorTotalTokens\x88\x01\x01\x12I\n" + + "\n" + + "components\x18\t \x03(\v2).pluggableharness.model.v1.UsageComponentR\n" + + "components\x12?\n" + + "\x19reasoning_already_counted\x18\n" + + " \x01(\bH\x05R\x17reasoningAlreadyCounted\x88\x01\x01B\x14\n" + "\x12_cache_read_tokensB\x15\n" + "\x13_cache_write_tokensB\x13\n" + - "\x11_reasoning_tokens\"\xf0\x01\n" + + "\x11_reasoning_tokensB\x0e\n" + + "\f_vendor_costB\x16\n" + + "\x14_vendor_total_tokensB\x1c\n" + + "\x1a_reasoning_already_counted\"f\n" + + "\n" + + "VendorCost\x12\x16\n" + + "\x06amount\x18\x01 \x01(\tR\x06amount\x12\x12\n" + + "\x04unit\x18\x02 \x01(\tR\x04unit\x12\x1f\n" + + "\bcurrency\x18\x03 \x01(\tH\x00R\bcurrency\x88\x01\x01B\v\n" + + "\t_currency\":\n" + + "\x0eUsageComponent\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value\"\x90\x04\n" + "\x11RateLimitSnapshot\x12<\n" + "\x04kind\x18\x01 \x01(\x0e2(.pluggableharness.model.v1.RateLimitKindR\x04kind\x12!\n" + "\tremaining\x18\x02 \x01(\x03H\x00R\tremaining\x88\x01\x01\x12\x19\n" + "\x05limit\x18\x03 \x01(\x03H\x01R\x05limit\x88\x01\x01\x12:\n" + - "\breset_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampH\x02R\aresetAt\x88\x01\x01B\f\n" + + "\breset_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampH\x02R\aresetAt\x88\x01\x01\x12\x1e\n" + + "\blimit_id\x18\x05 \x01(\tH\x03R\alimitId\x88\x01\x01\x12\"\n" + + "\n" + + "limit_name\x18\x06 \x01(\tH\x04R\tlimitName\x88\x01\x01\x12F\n" + + "\vwindow_role\x18\a \x01(\x0e2%.pluggableharness.model.v1.WindowRoleR\n" + + "windowRole\x12&\n" + + "\fused_percent\x18\b \x01(\x01H\x05R\vusedPercent\x88\x01\x01\x12*\n" + + "\x0ewindow_seconds\x18\t \x01(\x03H\x06R\rwindowSeconds\x88\x01\x01B\f\n" + "\n" + "_remainingB\b\n" + "\x06_limitB\v\n" + - "\t_reset_at\"q\n" + + "\t_reset_atB\v\n" + + "\t_limit_idB\r\n" + + "\v_limit_nameB\x0f\n" + + "\r_used_percentB\x11\n" + + "\x0f_window_seconds\"q\n" + "\vModelTarget\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12%\n" + "\x0econtext_window\x18\x02 \x01(\x03R\rcontextWindow\x12+\n" + "\x11effective_ceiling\x18\x03 \x01(\x03R\x10effectiveCeiling\"6\n" + "\bModelRef\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x0e\n" + - "\x02id\x18\x02 \x01(\tR\x02id*\xb5\x01\n" + + "\x02id\x18\x02 \x01(\tR\x02id*\x83\x01\n" + + "\n" + + "AuthMethod\x12\x1b\n" + + "\x17AUTH_METHOD_UNSPECIFIED\x10\x00\x12\x17\n" + + "\x13AUTH_METHOD_API_KEY\x10\x01\x12\x1f\n" + + "\x1bAUTH_METHOD_PRODUCT_SESSION\x10\x02\x12\x1e\n" + + "\x1aAUTH_METHOD_DEPLOYMENT_KEY\x10\x03*y\n" + + "\x0eMeteringDomain\x12\x1f\n" + + "\x1bMETERING_DOMAIN_UNSPECIFIED\x10\x00\x12%\n" + + "!METERING_DOMAIN_SUBSCRIPTION_POOL\x10\x01\x12\x1f\n" + + "\x1bMETERING_DOMAIN_METERED_API\x10\x02*\xb5\x01\n" + "\x16ThinkingDisableSupport\x12(\n" + "$THINKING_DISABLE_SUPPORT_UNSPECIFIED\x10\x00\x12\"\n" + "\x1eTHINKING_DISABLE_SUPPORT_NEVER\x10\x01\x12#\n" + "\x1fTHINKING_DISABLE_SUPPORT_ALWAYS\x10\x02\x12(\n" + - "$THINKING_DISABLE_SUPPORT_CONDITIONAL\x10\x03*\xa1\x01\n" + + "$THINKING_DISABLE_SUPPORT_CONDITIONAL\x10\x03*\xa5\x01\n" + + "\x12ResponseFormatKind\x12$\n" + + " RESPONSE_FORMAT_KIND_UNSPECIFIED\x10\x00\x12\x1d\n" + + "\x19RESPONSE_FORMAT_KIND_TEXT\x10\x01\x12$\n" + + " RESPONSE_FORMAT_KIND_JSON_OBJECT\x10\x02\x12$\n" + + " RESPONSE_FORMAT_KIND_JSON_SCHEMA\x10\x03*\xa1\x01\n" + "\x0eToolChoiceMode\x12 \n" + "\x1cTOOL_CHOICE_MODE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15TOOL_CHOICE_MODE_AUTO\x10\x01\x12\x18\n" + "\x14TOOL_CHOICE_MODE_ANY\x10\x02\x12\x19\n" + "\x15TOOL_CHOICE_MODE_NONE\x10\x03\x12\x1d\n" + - "\x19TOOL_CHOICE_MODE_SPECIFIC\x10\x04*\xaf\x01\n" + + "\x19TOOL_CHOICE_MODE_SPECIFIC\x10\x04*\xcc\x01\n" + "\rRateLimitKind\x12\x1f\n" + "\x1bRATE_LIMIT_KIND_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18RATE_LIMIT_KIND_REQUESTS\x10\x01\x12\x1a\n" + "\x16RATE_LIMIT_KIND_TOKENS\x10\x02\x12 \n" + "\x1cRATE_LIMIT_KIND_INPUT_TOKENS\x10\x03\x12!\n" + - "\x1dRATE_LIMIT_KIND_OUTPUT_TOKENS\x10\x04B>ZZ pluggableharness.model.v1.ModelSpec - 22, // 1: pluggableharness.model.v1.Capabilities.slash_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec - 23, // 2: pluggableharness.model.v1.Capabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 24, // 3: pluggableharness.model.v1.Capabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 8, // 4: pluggableharness.model.v1.ModelSpec.thinking:type_name -> pluggableharness.model.v1.ThinkingSpec - 9, // 5: pluggableharness.model.v1.ModelSpec.caching:type_name -> pluggableharness.model.v1.CachingSpec - 11, // 6: pluggableharness.model.v1.ModelSpec.pricing:type_name -> pluggableharness.model.v1.Pricing - 1, // 7: pluggableharness.model.v1.ModelSpec.supported_tool_choice_modes:type_name -> pluggableharness.model.v1.ToolChoiceMode - 5, // 8: pluggableharness.model.v1.BudgetControl.range:type_name -> pluggableharness.model.v1.ThinkingBudgetRange - 6, // 9: pluggableharness.model.v1.ThinkingSpec.effort:type_name -> pluggableharness.model.v1.EffortControl - 7, // 10: pluggableharness.model.v1.ThinkingSpec.budget:type_name -> pluggableharness.model.v1.BudgetControl - 0, // 11: pluggableharness.model.v1.ThinkingSpec.disable:type_name -> pluggableharness.model.v1.ThinkingDisableSupport - 25, // 12: pluggableharness.model.v1.PricingTier.effective_from:type_name -> google.protobuf.Timestamp - 25, // 13: pluggableharness.model.v1.PricingTier.effective_until:type_name -> google.protobuf.Timestamp - 10, // 14: pluggableharness.model.v1.Pricing.tiers:type_name -> pluggableharness.model.v1.PricingTier - 20, // 15: pluggableharness.model.v1.CacheBreakpoint.after_assembled_context:type_name -> pluggableharness.model.v1.CacheBreakpoint.AfterAssembledContext - 21, // 16: pluggableharness.model.v1.CacheBreakpoint.after_tools:type_name -> pluggableharness.model.v1.CacheBreakpoint.AfterTools - 26, // 17: pluggableharness.model.v1.ToolDeclaration.input_schema:type_name -> pluggableharness.schema.v1.Schema - 15, // 18: pluggableharness.model.v1.GenerationParams.tool_choice:type_name -> pluggableharness.model.v1.ToolChoice - 1, // 19: pluggableharness.model.v1.ToolChoice.mode:type_name -> pluggableharness.model.v1.ToolChoiceMode - 17, // 20: pluggableharness.model.v1.Usage.rate_limits:type_name -> pluggableharness.model.v1.RateLimitSnapshot - 2, // 21: pluggableharness.model.v1.RateLimitSnapshot.kind:type_name -> pluggableharness.model.v1.RateLimitKind - 25, // 22: pluggableharness.model.v1.RateLimitSnapshot.reset_at:type_name -> google.protobuf.Timestamp - 23, // [23:23] is the sub-list for method output_type - 23, // [23:23] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name + 10, // 0: pluggableharness.model.v1.Capabilities.models:type_name -> pluggableharness.model.v1.ModelSpec + 35, // 1: pluggableharness.model.v1.Capabilities.slash_commands:type_name -> pluggableharness.common.v1.PromptExpansionSpec + 36, // 2: pluggableharness.model.v1.Capabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 37, // 3: pluggableharness.model.v1.Capabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 8, // 4: pluggableharness.model.v1.Capabilities.auth:type_name -> pluggableharness.model.v1.AuthDescriptor + 38, // 5: pluggableharness.model.v1.Capabilities.catalog_fetched_at:type_name -> google.protobuf.Timestamp + 0, // 6: pluggableharness.model.v1.AuthDescriptor.method:type_name -> pluggableharness.model.v1.AuthMethod + 1, // 7: pluggableharness.model.v1.AuthDescriptor.metering:type_name -> pluggableharness.model.v1.MeteringDomain + 31, // 8: pluggableharness.model.v1.AuthDescriptor.labels:type_name -> pluggableharness.model.v1.AuthDescriptor.LabelsEntry + 0, // 9: pluggableharness.model.v1.AccountSnapshot.method:type_name -> pluggableharness.model.v1.AuthMethod + 1, // 10: pluggableharness.model.v1.AccountSnapshot.metering:type_name -> pluggableharness.model.v1.MeteringDomain + 32, // 11: pluggableharness.model.v1.AccountSnapshot.labels:type_name -> pluggableharness.model.v1.AccountSnapshot.LabelsEntry + 28, // 12: pluggableharness.model.v1.AccountSnapshot.quotas:type_name -> pluggableharness.model.v1.RateLimitSnapshot + 38, // 13: pluggableharness.model.v1.AccountSnapshot.fetched_at:type_name -> google.protobuf.Timestamp + 16, // 14: pluggableharness.model.v1.ModelSpec.thinking:type_name -> pluggableharness.model.v1.ThinkingSpec + 17, // 15: pluggableharness.model.v1.ModelSpec.caching:type_name -> pluggableharness.model.v1.CachingSpec + 19, // 16: pluggableharness.model.v1.ModelSpec.pricing:type_name -> pluggableharness.model.v1.Pricing + 4, // 17: pluggableharness.model.v1.ModelSpec.supported_tool_choice_modes:type_name -> pluggableharness.model.v1.ToolChoiceMode + 11, // 18: pluggableharness.model.v1.ModelSpec.catalog:type_name -> pluggableharness.model.v1.CatalogMetadata + 12, // 19: pluggableharness.model.v1.ModelSpec.verbosity:type_name -> pluggableharness.model.v1.VerbositySpec + 13, // 20: pluggableharness.model.v1.BudgetControl.range:type_name -> pluggableharness.model.v1.ThinkingBudgetRange + 14, // 21: pluggableharness.model.v1.ThinkingSpec.effort:type_name -> pluggableharness.model.v1.EffortControl + 15, // 22: pluggableharness.model.v1.ThinkingSpec.budget:type_name -> pluggableharness.model.v1.BudgetControl + 2, // 23: pluggableharness.model.v1.ThinkingSpec.disable:type_name -> pluggableharness.model.v1.ThinkingDisableSupport + 38, // 24: pluggableharness.model.v1.PricingTier.effective_from:type_name -> google.protobuf.Timestamp + 38, // 25: pluggableharness.model.v1.PricingTier.effective_until:type_name -> google.protobuf.Timestamp + 18, // 26: pluggableharness.model.v1.Pricing.tiers:type_name -> pluggableharness.model.v1.PricingTier + 33, // 27: pluggableharness.model.v1.CacheBreakpoint.after_assembled_context:type_name -> pluggableharness.model.v1.CacheBreakpoint.AfterAssembledContext + 34, // 28: pluggableharness.model.v1.CacheBreakpoint.after_tools:type_name -> pluggableharness.model.v1.CacheBreakpoint.AfterTools + 39, // 29: pluggableharness.model.v1.ToolDeclaration.input_schema:type_name -> pluggableharness.schema.v1.Schema + 24, // 30: pluggableharness.model.v1.GenerationParams.tool_choice:type_name -> pluggableharness.model.v1.ToolChoice + 23, // 31: pluggableharness.model.v1.GenerationParams.response_format:type_name -> pluggableharness.model.v1.ResponseFormat + 3, // 32: pluggableharness.model.v1.ResponseFormat.kind:type_name -> pluggableharness.model.v1.ResponseFormatKind + 39, // 33: pluggableharness.model.v1.ResponseFormat.json_schema:type_name -> pluggableharness.schema.v1.Schema + 4, // 34: pluggableharness.model.v1.ToolChoice.mode:type_name -> pluggableharness.model.v1.ToolChoiceMode + 28, // 35: pluggableharness.model.v1.Usage.rate_limits:type_name -> pluggableharness.model.v1.RateLimitSnapshot + 26, // 36: pluggableharness.model.v1.Usage.vendor_cost:type_name -> pluggableharness.model.v1.VendorCost + 27, // 37: pluggableharness.model.v1.Usage.components:type_name -> pluggableharness.model.v1.UsageComponent + 5, // 38: pluggableharness.model.v1.RateLimitSnapshot.kind:type_name -> pluggableharness.model.v1.RateLimitKind + 38, // 39: pluggableharness.model.v1.RateLimitSnapshot.reset_at:type_name -> google.protobuf.Timestamp + 6, // 40: pluggableharness.model.v1.RateLimitSnapshot.window_role:type_name -> pluggableharness.model.v1.WindowRole + 41, // [41:41] is the sub-list for method output_type + 41, // [41:41] is the sub-list for method input_type + 41, // [41:41] is the sub-list for extension type_name + 41, // [41:41] is the sub-list for extension extendee + 0, // [0:41] is the sub-list for field type_name } func init() { file_pluggableharness_model_v1_types_proto_init() } @@ -2084,26 +3532,34 @@ func file_pluggableharness_model_v1_types_proto_init() { if File_pluggableharness_model_v1_types_proto != nil { return } + file_pluggableharness_model_v1_types_proto_msgTypes[0].OneofWrappers = []any{} file_pluggableharness_model_v1_types_proto_msgTypes[1].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[2].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[3].OneofWrappers = []any{} file_pluggableharness_model_v1_types_proto_msgTypes[4].OneofWrappers = []any{} file_pluggableharness_model_v1_types_proto_msgTypes[5].OneofWrappers = []any{} - file_pluggableharness_model_v1_types_proto_msgTypes[7].OneofWrappers = []any{} - file_pluggableharness_model_v1_types_proto_msgTypes[9].OneofWrappers = []any{ + file_pluggableharness_model_v1_types_proto_msgTypes[8].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[9].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[11].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[12].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[13].OneofWrappers = []any{ (*CacheBreakpoint_AfterAssembledContext_)(nil), (*CacheBreakpoint_AfterTools_)(nil), (*CacheBreakpoint_AfterMessageIndex)(nil), } - file_pluggableharness_model_v1_types_proto_msgTypes[11].OneofWrappers = []any{} - file_pluggableharness_model_v1_types_proto_msgTypes[12].OneofWrappers = []any{} - file_pluggableharness_model_v1_types_proto_msgTypes[13].OneofWrappers = []any{} - file_pluggableharness_model_v1_types_proto_msgTypes[14].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[15].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[16].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[17].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[18].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[19].OneofWrappers = []any{} + file_pluggableharness_model_v1_types_proto_msgTypes[21].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_model_v1_types_proto_rawDesc), len(file_pluggableharness_model_v1_types_proto_rawDesc)), - NumEnums: 3, - NumMessages: 19, + NumEnums: 7, + NumMessages: 28, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/model/server.go b/pkg/model/server.go index 69a2431..f8a687c 100644 --- a/pkg/model/server.go +++ b/pkg/model/server.go @@ -128,3 +128,20 @@ func (svc *Service) Render(ctx context.Context, req *modelv1.RenderRequest) (*mo } return &modelv1.RenderResponse{Tree: tree}, nil } + +// GetAccount delegates to svc.provider when it implements Accounter, per +// docs/specifications/model/protocol.md#getaccount (MAY). A Provider that +// doesn't implement Accounter returns codes.Unimplemented here, which the +// kernel treats as "this provider has no account state to report" rather +// than as a failure. +func (svc *Service) GetAccount(ctx context.Context, _ *modelv1.GetAccountRequest) (*modelv1.GetAccountResponse, error) { + a, ok := svc.provider.(Accounter) + if !ok { + return nil, status.Error(codes.Unimplemented, "model: GetAccount not implemented by this provider") + } + snap, err := a.GetAccount(ctx) + if err != nil { + return nil, statusFromErr(err) + } + return &modelv1.GetAccountResponse{Account: accountToProto(snap)}, nil +} diff --git a/pkg/model/stream.go b/pkg/model/stream.go index b7b7ed7..ad65ee3 100644 --- a/pkg/model/stream.go +++ b/pkg/model/stream.go @@ -208,3 +208,93 @@ func (s *Sink) Error(modelErr *Error) error { }, }, true) } + +// Metadata sends non-content facts about how the vendor is serving this +// request: which model actually answered, which build, which tier, and +// whatever budget state the response headers exposed. +// +// MAY be sent more than once and at any point before the terminal event. +// A later call supersedes an earlier one field by field; leaving a field +// nil means "no new information", never "cleared", so a provider can +// report actual_model once at the top of a stream and rate limits later +// without the first being lost. +// +// Send this as soon as the facts are known rather than saving them for +// the end — an operator learning that a limit was nearly exhausted only +// after the turn that exhausted it has learned nothing useful. +func (s *Sink) Metadata(m StreamMetadata) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_Metadata{Metadata: &modelv1.StreamEvent_StreamMetadata{ + ActualModel: m.ActualModel, + SystemFingerprint: m.SystemFingerprint, + ServiceTier: m.ServiceTier, + RateLimits: rateLimitsToProto(m.RateLimits), + LiveContextWindow: m.LiveContextWindow, + LiveMaxOutputTokens: m.LiveMaxOutputTokens, + CatalogEtag: m.CatalogEtag, + StickyTurnToken: m.StickyTurnToken, + Attrs: m.Attrs, + }}, + }, false) +} + +// SafetyNotice reports that the vendor is interposing on this request — +// buffering output for review, applying a moderation decision, or +// requiring an account challenge. +// +// Send it when the vendor says so, particularly for BUFFERING: it is what +// lets a frontend explain a stall instead of leaving it looking like a +// hang. +func (s *Sink) SafetyNotice(kind modelv1.StreamEvent_SafetyKind, message string, attrs map[string]string) error { + notice := &modelv1.StreamEvent_SafetyNotice{Kind: kind, Attrs: attrs} + if message != "" { + notice.Message = &message + } + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_SafetyNotice_{SafetyNotice: notice}, + }, false) +} + +// ThinkingDeltaOn sends a reasoning fragment tagged with which stream it +// belongs to, for vendors emitting a readable summary alongside raw +// reasoning. +// +// Use this instead of ThinkingDelta when the vendor distinguishes the +// two: the kernel keeps a summary run and a content run as separate +// blocks, and sending both through the untagged ThinkingDelta would +// concatenate them into one block that reads as neither. +func (s *Sink) ThinkingDeltaOn(text string, channel modelv1.StreamEvent_ThinkingChannel) error { + return s.send(&modelv1.StreamEvent{ + Event: &modelv1.StreamEvent_ThinkingDelta_{ + ThinkingDelta: &modelv1.StreamEvent_ThinkingDelta{Text: text, Channel: channel}, + }, + }, false) +} + +// StreamMetadata is Sink.Metadata's payload. Every field is optional: a +// provider reports what its vendor published and leaves the rest nil. +type StreamMetadata struct { + // ActualModel is the model that actually served this completion, when + // it differs from the requested id. Set it whenever the vendor says + // so — it is what makes a silent model substitution attributable. + ActualModel *string + // SystemFingerprint is the vendor's opaque backend-build identifier. + SystemFingerprint *string + // ServiceTier is the tier this request was served at. + ServiceTier *string + // RateLimits is budget state as of this point in the stream, from + // response headers. + RateLimits []RateLimitSnapshot + // LiveContextWindow is the context window the vendor says applies to + // this request, superseding the roster's static figure. + LiveContextWindow *int64 + // LiveMaxOutputTokens is the same for maximum output tokens. + LiveMaxOutputTokens *int64 + // CatalogEtag is the vendor's current model-catalog version. + CatalogEtag *string + // StickyTurnToken is a handle to this turn's vendor-side state, for + // vendors accepting an incremental continuation next request. + StickyTurnToken *string + // Attrs are vendor-defined metadata with no typed field. + Attrs map[string]string +} diff --git a/pkg/plan/proto/v1/types.pb.go b/pkg/plan/proto/v1/types.pb.go index a8a40cd..5d22e9c 100644 --- a/pkg/plan/proto/v1/types.pb.go +++ b/pkg/plan/proto/v1/types.pb.go @@ -34,6 +34,125 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ClientDecision is the operator's resolution of a pending plan item that +// policy evaluated as ASK — the allow/deny half of ResolvePlanDecision, +// orthogonal to PlanDecisionScope (how durably the verdict is remembered). +type ClientDecision int32 + +const ( + // Zero value. Never valid for a real decision. + ClientDecision_CLIENT_DECISION_UNSPECIFIED ClientDecision = 0 + // The operator approved the plan item as proposed (or as corrected). + ClientDecision_CLIENT_DECISION_ALLOW ClientDecision = 1 + // The operator rejected the plan item. + ClientDecision_CLIENT_DECISION_DENY ClientDecision = 2 +) + +// Enum value maps for ClientDecision. +var ( + ClientDecision_name = map[int32]string{ + 0: "CLIENT_DECISION_UNSPECIFIED", + 1: "CLIENT_DECISION_ALLOW", + 2: "CLIENT_DECISION_DENY", + } + ClientDecision_value = map[string]int32{ + "CLIENT_DECISION_UNSPECIFIED": 0, + "CLIENT_DECISION_ALLOW": 1, + "CLIENT_DECISION_DENY": 2, + } +) + +func (x ClientDecision) Enum() *ClientDecision { + p := new(ClientDecision) + *p = x + return p +} + +func (x ClientDecision) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ClientDecision) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_plan_v1_types_proto_enumTypes[0].Descriptor() +} + +func (ClientDecision) Type() protoreflect.EnumType { + return &file_pluggableharness_plan_v1_types_proto_enumTypes[0] +} + +func (x ClientDecision) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ClientDecision.Descriptor instead. +func (ClientDecision) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{0} +} + +// PlanDecisionScope is how durably a ResolvePlanDecision applies beyond +// the one PlanItem it names. Orthogonal to ClientDecision: decision says +// allow/deny, scope says how long that verdict is remembered. See +// agent-loop/plan-apply-gate.md's PlanDecisionScope semantics. +type PlanDecisionScope int32 + +const ( + // Zero value. Never valid for a real decision. + PlanDecisionScope_PLAN_DECISION_SCOPE_UNSPECIFIED PlanDecisionScope = 0 + // Applies to this PlanItem only. The default a frontend SHOULD send + // when the operator has not explicitly asked for a broader scope. + PlanDecisionScope_PLAN_DECISION_SCOPE_ONCE PlanDecisionScope = 1 + // Applies to the rest of this session for matching provider/operation + // calls — an in-memory, session-lifetime rule, not written to agent.hcl. + PlanDecisionScope_PLAN_DECISION_SCOPE_SESSION PlanDecisionScope = 2 + // The kernel persists this as policy beyond this session. A kernel that + // cannot persist policy MUST reject ALWAYS rather than silently + // downgrading to SESSION or ONCE. + PlanDecisionScope_PLAN_DECISION_SCOPE_ALWAYS PlanDecisionScope = 3 +) + +// Enum value maps for PlanDecisionScope. +var ( + PlanDecisionScope_name = map[int32]string{ + 0: "PLAN_DECISION_SCOPE_UNSPECIFIED", + 1: "PLAN_DECISION_SCOPE_ONCE", + 2: "PLAN_DECISION_SCOPE_SESSION", + 3: "PLAN_DECISION_SCOPE_ALWAYS", + } + PlanDecisionScope_value = map[string]int32{ + "PLAN_DECISION_SCOPE_UNSPECIFIED": 0, + "PLAN_DECISION_SCOPE_ONCE": 1, + "PLAN_DECISION_SCOPE_SESSION": 2, + "PLAN_DECISION_SCOPE_ALWAYS": 3, + } +) + +func (x PlanDecisionScope) Enum() *PlanDecisionScope { + p := new(PlanDecisionScope) + *p = x + return p +} + +func (x PlanDecisionScope) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PlanDecisionScope) Descriptor() protoreflect.EnumDescriptor { + return file_pluggableharness_plan_v1_types_proto_enumTypes[1].Descriptor() +} + +func (PlanDecisionScope) Type() protoreflect.EnumType { + return &file_pluggableharness_plan_v1_types_proto_enumTypes[1] +} + +func (x PlanDecisionScope) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PlanDecisionScope.Descriptor instead. +func (PlanDecisionScope) EnumDescriptor() ([]byte, []int) { + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{1} +} + // PlanDecision is the outcome of evaluating one PlanItem against policy. type PlanDecision int32 @@ -91,11 +210,11 @@ func (x PlanDecision) String() string { } func (PlanDecision) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_plan_v1_types_proto_enumTypes[0].Descriptor() + return file_pluggableharness_plan_v1_types_proto_enumTypes[2].Descriptor() } func (PlanDecision) Type() protoreflect.EnumType { - return &file_pluggableharness_plan_v1_types_proto_enumTypes[0] + return &file_pluggableharness_plan_v1_types_proto_enumTypes[2] } func (x PlanDecision) Number() protoreflect.EnumNumber { @@ -104,7 +223,7 @@ func (x PlanDecision) Number() protoreflect.EnumNumber { // Deprecated: Use PlanDecision.Descriptor instead. func (PlanDecision) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{0} + return file_pluggableharness_plan_v1_types_proto_rawDescGZIP(), []int{2} } // ApplyOutcome classifies how one plan item's apply attempt concluded. @@ -162,11 +281,11 @@ func (x ApplyResult_ApplyOutcome) String() string { } func (ApplyResult_ApplyOutcome) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_plan_v1_types_proto_enumTypes[1].Descriptor() + return file_pluggableharness_plan_v1_types_proto_enumTypes[3].Descriptor() } func (ApplyResult_ApplyOutcome) Type() protoreflect.EnumType { - return &file_pluggableharness_plan_v1_types_proto_enumTypes[1] + return &file_pluggableharness_plan_v1_types_proto_enumTypes[3] } func (x ApplyResult_ApplyOutcome) Number() protoreflect.EnumNumber { @@ -638,7 +757,16 @@ const file_pluggableharness_plan_v1_types_proto_rawDesc = "" + "\x15APPLY_OUTCOME_APPLIED\x10\x01\x12\x18\n" + "\x14APPLY_OUTCOME_FAILED\x10\x02\x12\x18\n" + "\x14APPLY_OUTCOME_DENIED\x10\x03\x12\x19\n" + - "\x15APPLY_OUTCOME_SKIPPED\x10\x04*\x90\x01\n" + + "\x15APPLY_OUTCOME_SKIPPED\x10\x04*f\n" + + "\x0eClientDecision\x12\x1f\n" + + "\x1bCLIENT_DECISION_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15CLIENT_DECISION_ALLOW\x10\x01\x12\x18\n" + + "\x14CLIENT_DECISION_DENY\x10\x02*\x97\x01\n" + + "\x11PlanDecisionScope\x12#\n" + + "\x1fPLAN_DECISION_SCOPE_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18PLAN_DECISION_SCOPE_ONCE\x10\x01\x12\x1f\n" + + "\x1bPLAN_DECISION_SCOPE_SESSION\x10\x02\x12\x1e\n" + + "\x1aPLAN_DECISION_SCOPE_ALWAYS\x10\x03*\x90\x01\n" + "\fPlanDecision\x12\x1d\n" + "\x19PLAN_DECISION_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15PLAN_DECISION_PENDING\x10\x01\x12\x17\n" + @@ -658,35 +786,37 @@ func file_pluggableharness_plan_v1_types_proto_rawDescGZIP() []byte { return file_pluggableharness_plan_v1_types_proto_rawDescData } -var file_pluggableharness_plan_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_pluggableharness_plan_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 4) var file_pluggableharness_plan_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_pluggableharness_plan_v1_types_proto_goTypes = []any{ - (PlanDecision)(0), // 0: pluggableharness.plan.v1.PlanDecision - (ApplyResult_ApplyOutcome)(0), // 1: pluggableharness.plan.v1.ApplyResult.ApplyOutcome - (*PlanItem)(nil), // 2: pluggableharness.plan.v1.PlanItem - (*Plan)(nil), // 3: pluggableharness.plan.v1.Plan - (*ApplyResult)(nil), // 4: pluggableharness.plan.v1.ApplyResult - (*ApplyResult_ApplyItem)(nil), // 5: pluggableharness.plan.v1.ApplyResult.ApplyItem - (*structpb.Struct)(nil), // 6: google.protobuf.Struct - (v1.ToolKind)(0), // 7: pluggableharness.tool.v1.ToolKind - (v1.RiskClass)(0), // 8: pluggableharness.tool.v1.RiskClass - (*v11.RenderTree)(nil), // 9: pluggableharness.render.v1.RenderTree - (v12.Category)(0), // 10: pluggableharness.common.v1.Category - (*v1.ToolResult)(nil), // 11: pluggableharness.tool.v1.ToolResult - (*v1.ToolError)(nil), // 12: pluggableharness.tool.v1.ToolError + (ClientDecision)(0), // 0: pluggableharness.plan.v1.ClientDecision + (PlanDecisionScope)(0), // 1: pluggableharness.plan.v1.PlanDecisionScope + (PlanDecision)(0), // 2: pluggableharness.plan.v1.PlanDecision + (ApplyResult_ApplyOutcome)(0), // 3: pluggableharness.plan.v1.ApplyResult.ApplyOutcome + (*PlanItem)(nil), // 4: pluggableharness.plan.v1.PlanItem + (*Plan)(nil), // 5: pluggableharness.plan.v1.Plan + (*ApplyResult)(nil), // 6: pluggableharness.plan.v1.ApplyResult + (*ApplyResult_ApplyItem)(nil), // 7: pluggableharness.plan.v1.ApplyResult.ApplyItem + (*structpb.Struct)(nil), // 8: google.protobuf.Struct + (v1.ToolKind)(0), // 9: pluggableharness.tool.v1.ToolKind + (v1.RiskClass)(0), // 10: pluggableharness.tool.v1.RiskClass + (*v11.RenderTree)(nil), // 11: pluggableharness.render.v1.RenderTree + (v12.Category)(0), // 12: pluggableharness.common.v1.Category + (*v1.ToolResult)(nil), // 13: pluggableharness.tool.v1.ToolResult + (*v1.ToolError)(nil), // 14: pluggableharness.tool.v1.ToolError } var file_pluggableharness_plan_v1_types_proto_depIdxs = []int32{ - 6, // 0: pluggableharness.plan.v1.PlanItem.input:type_name -> google.protobuf.Struct - 0, // 1: pluggableharness.plan.v1.PlanItem.decision:type_name -> pluggableharness.plan.v1.PlanDecision - 7, // 2: pluggableharness.plan.v1.PlanItem.kind:type_name -> pluggableharness.tool.v1.ToolKind - 8, // 3: pluggableharness.plan.v1.PlanItem.risk:type_name -> pluggableharness.tool.v1.RiskClass - 9, // 4: pluggableharness.plan.v1.PlanItem.preview:type_name -> pluggableharness.render.v1.RenderTree - 10, // 5: pluggableharness.plan.v1.PlanItem.producer_category:type_name -> pluggableharness.common.v1.Category - 2, // 6: pluggableharness.plan.v1.Plan.items:type_name -> pluggableharness.plan.v1.PlanItem - 5, // 7: pluggableharness.plan.v1.ApplyResult.items:type_name -> pluggableharness.plan.v1.ApplyResult.ApplyItem - 1, // 8: pluggableharness.plan.v1.ApplyResult.ApplyItem.outcome:type_name -> pluggableharness.plan.v1.ApplyResult.ApplyOutcome - 11, // 9: pluggableharness.plan.v1.ApplyResult.ApplyItem.tool_result:type_name -> pluggableharness.tool.v1.ToolResult - 12, // 10: pluggableharness.plan.v1.ApplyResult.ApplyItem.tool_error:type_name -> pluggableharness.tool.v1.ToolError + 8, // 0: pluggableharness.plan.v1.PlanItem.input:type_name -> google.protobuf.Struct + 2, // 1: pluggableharness.plan.v1.PlanItem.decision:type_name -> pluggableharness.plan.v1.PlanDecision + 9, // 2: pluggableharness.plan.v1.PlanItem.kind:type_name -> pluggableharness.tool.v1.ToolKind + 10, // 3: pluggableharness.plan.v1.PlanItem.risk:type_name -> pluggableharness.tool.v1.RiskClass + 11, // 4: pluggableharness.plan.v1.PlanItem.preview:type_name -> pluggableharness.render.v1.RenderTree + 12, // 5: pluggableharness.plan.v1.PlanItem.producer_category:type_name -> pluggableharness.common.v1.Category + 4, // 6: pluggableharness.plan.v1.Plan.items:type_name -> pluggableharness.plan.v1.PlanItem + 7, // 7: pluggableharness.plan.v1.ApplyResult.items:type_name -> pluggableharness.plan.v1.ApplyResult.ApplyItem + 3, // 8: pluggableharness.plan.v1.ApplyResult.ApplyItem.outcome:type_name -> pluggableharness.plan.v1.ApplyResult.ApplyOutcome + 13, // 9: pluggableharness.plan.v1.ApplyResult.ApplyItem.tool_result:type_name -> pluggableharness.tool.v1.ToolResult + 14, // 10: pluggableharness.plan.v1.ApplyResult.ApplyItem.tool_error:type_name -> pluggableharness.tool.v1.ToolError 11, // [11:11] is the sub-list for method output_type 11, // [11:11] is the sub-list for method input_type 11, // [11:11] is the sub-list for extension type_name @@ -709,7 +839,7 @@ func file_pluggableharness_plan_v1_types_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_plan_v1_types_proto_rawDesc), len(file_pluggableharness_plan_v1_types_proto_rawDesc)), - NumEnums: 2, + NumEnums: 4, NumMessages: 4, NumExtensions: 0, NumServices: 0, diff --git a/pkg/render/proto/v1/types.pb.go b/pkg/render/proto/v1/types.pb.go index 3ad3f52..d64c98d 100644 --- a/pkg/render/proto/v1/types.pb.go +++ b/pkg/render/proto/v1/types.pb.go @@ -4,14 +4,18 @@ // protoc (unknown) // source: pluggableharness/render/v1/types.proto -// Package pluggableharness.render.v1 defines the Emit->Render->Paint intermediate -// representation described in specifications/frontend.md §1. Every plugin -// category's optional Render() RPC (model.md §7, tool.md §7, -// context.md §9, memory.md §10) returns this; every frontend/widget Paints -// it. frontend.md §1 MUST: a frontend must render every RenderNode variant -// gracefully with a generic fallback (e.g. an unrecognized-in-practice -// future variant, or `diff` on a frontend with no diff view -> plain -// before/after text) — never error, never silently drop a node. +// Package pluggableharness.render.v1 defines the Emit->Render->Paint +// intermediate representation used by the transcript surface +// (specifications/frontend/). Every plugin category's optional Render() +// RPC (model, tool, context, memory) returns a RenderTree; frontends +// paint transcript content from it. A frontend MUST render every +// RenderNode variant gracefully with a generic fallback (e.g. an +// unrecognized future variant, or `diff` on a frontend with no diff view +// -> plain before/after text) — never error, never silently drop a node. +// +// Placement is not this package's job: Region and PlacedContent were +// retired. State, metadata, and input are typed kernel surfaces; only +// the conversation transcript still travels as RenderTree. package renderv1 @@ -31,84 +35,6 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// Region is the placement vocabulary for where a RenderTree gets shown, -// described in specifications/frontend.md §2. Every region is -// plugin-contributable; a placement is a hint the frontend MAY drop or -// re-fold under space constraints. Lives here (not in frontend/v1) because -// both the frontend provider protocol AND the widget provider protocol -// place content into these same regions (frontend.md §4.1's WidgetUpdate), -// and neither category's package should depend on the other's. -type Region int32 - -const ( - // Zero value. Never valid for a real placement; its presence on the wire - // means a caller forgot to set the field. - Region_REGION_UNSPECIFIED Region = 0 - // The main conversation transcript. The default region when a producer - // doesn't specify one. - Region_REGION_MAIN_CHAT Region = 1 - // A persistent side panel. - Region_REGION_SIDEBAR Region = 2 - // A persistent header bar. - Region_REGION_TOP_BAR Region = 3 - // The area around the user's input box. - Region_REGION_INPUT_BAR Region = 4 - // Contextual hotkey/command hints. - Region_REGION_HOTKEY_HINTS Region = 5 - // A modal or floating layer. MUST be visually distinct from the rest of - // the interface (frontend.md §2). - Region_REGION_OVERLAY Region = 6 -) - -// Enum value maps for Region. -var ( - Region_name = map[int32]string{ - 0: "REGION_UNSPECIFIED", - 1: "REGION_MAIN_CHAT", - 2: "REGION_SIDEBAR", - 3: "REGION_TOP_BAR", - 4: "REGION_INPUT_BAR", - 5: "REGION_HOTKEY_HINTS", - 6: "REGION_OVERLAY", - } - Region_value = map[string]int32{ - "REGION_UNSPECIFIED": 0, - "REGION_MAIN_CHAT": 1, - "REGION_SIDEBAR": 2, - "REGION_TOP_BAR": 3, - "REGION_INPUT_BAR": 4, - "REGION_HOTKEY_HINTS": 5, - "REGION_OVERLAY": 6, - } -) - -func (x Region) Enum() *Region { - p := new(Region) - *p = x - return p -} - -func (x Region) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (Region) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_render_v1_types_proto_enumTypes[0].Descriptor() -} - -func (Region) Type() protoreflect.EnumType { - return &file_pluggableharness_render_v1_types_proto_enumTypes[0] -} - -func (x Region) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use Region.Descriptor instead. -func (Region) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{0} -} - // TextStyle is a hint for how a TextNode's content should be presented. // Distinct from "unset": TEXT_STYLE_NORMAL is a producer explicitly // requesting plain styling (overriding any inherited formatting context), @@ -166,11 +92,11 @@ func (x TextStyle) String() string { } func (TextStyle) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_render_v1_types_proto_enumTypes[1].Descriptor() + return file_pluggableharness_render_v1_types_proto_enumTypes[0].Descriptor() } func (TextStyle) Type() protoreflect.EnumType { - return &file_pluggableharness_render_v1_types_proto_enumTypes[1] + return &file_pluggableharness_render_v1_types_proto_enumTypes[0] } func (x TextStyle) Number() protoreflect.EnumNumber { @@ -179,7 +105,7 @@ func (x TextStyle) Number() protoreflect.EnumNumber { // Deprecated: Use TextStyle.Descriptor instead. func (TextStyle) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{1} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{0} } // DiffLineOp classifies one line of a DiffHunk, mirroring unified diff's @@ -225,11 +151,11 @@ func (x DiffLineOp) String() string { } func (DiffLineOp) Descriptor() protoreflect.EnumDescriptor { - return file_pluggableharness_render_v1_types_proto_enumTypes[2].Descriptor() + return file_pluggableharness_render_v1_types_proto_enumTypes[1].Descriptor() } func (DiffLineOp) Type() protoreflect.EnumType { - return &file_pluggableharness_render_v1_types_proto_enumTypes[2] + return &file_pluggableharness_render_v1_types_proto_enumTypes[1] } func (x DiffLineOp) Number() protoreflect.EnumNumber { @@ -238,15 +164,15 @@ func (x DiffLineOp) Number() protoreflect.EnumNumber { // Deprecated: Use DiffLineOp.Descriptor instead. func (DiffLineOp) EnumDescriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{1} } // RenderTree is the return type of every category's Render() RPC. -// frontend.md §1 treats "RenderTree" and "RenderNode" as equivalent (the -// tree root is just a node) — this wraps a single root RenderNode so every -// Render() RPC across every category shares one stable, named response -// type, with room to grow (e.g. a schema_version) without changing -// RenderNode itself. +// specifications/frontend/render-tree.md treats "RenderTree" and +// "RenderNode" as equivalent (the tree root is just a node) — this wraps +// a single root RenderNode so every Render() RPC across every category +// shares one stable, named response type, with room to grow (e.g. a +// schema_version) without changing RenderNode itself. type RenderTree struct { state protoimpl.MessageState `protogen:"open.v1"` // The tree's root node. @@ -292,83 +218,6 @@ func (x *RenderTree) GetRoot() *RenderNode { return nil } -// PlacedContent pairs a RenderTree with where it should be shown and how -// it interacts with that region's prior content from the same producer. -// Used by frontend.md §3.2's ServerEvent.render variant. -type PlacedContent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Where this content should be placed. - Region Region `protobuf:"varint,1,opt,name=region,proto3,enum=pluggableharness.render.v1.Region" json:"region,omitempty"` - // The content to place. - Content *RenderTree `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` - // True: replace this producer's prior content in `region`. False: - // append (the default behavior for REGION_MAIN_CHAT). - Replace bool `protobuf:"varint,3,opt,name=replace,proto3" json:"replace,omitempty"` - // Ordering/eviction hint for space-constrained regions. Unset means - // "declaration order". - Priority *int32 `protobuf:"varint,4,opt,name=priority,proto3,oneof" json:"priority,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PlacedContent) Reset() { - *x = PlacedContent{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PlacedContent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PlacedContent) ProtoMessage() {} - -func (x *PlacedContent) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PlacedContent.ProtoReflect.Descriptor instead. -func (*PlacedContent) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{1} -} - -func (x *PlacedContent) GetRegion() Region { - if x != nil { - return x.Region - } - return Region_REGION_UNSPECIFIED -} - -func (x *PlacedContent) GetContent() *RenderTree { - if x != nil { - return x.Content - } - return nil -} - -func (x *PlacedContent) GetReplace() bool { - if x != nil { - return x.Replace - } - return false -} - -func (x *PlacedContent) GetPriority() int32 { - if x != nil && x.Priority != nil { - return *x.Priority - } - return 0 -} - // DiffLine is one line within a DiffHunk. type DiffLine struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -382,7 +231,7 @@ type DiffLine struct { func (x *DiffLine) Reset() { *x = DiffLine{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[2] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -394,7 +243,7 @@ func (x *DiffLine) String() string { func (*DiffLine) ProtoMessage() {} func (x *DiffLine) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[2] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -407,7 +256,7 @@ func (x *DiffLine) ProtoReflect() protoreflect.Message { // Deprecated: Use DiffLine.ProtoReflect.Descriptor instead. func (*DiffLine) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{2} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{1} } func (x *DiffLine) GetOp() DiffLineOp { @@ -447,7 +296,7 @@ type DiffHunk struct { func (x *DiffHunk) Reset() { *x = DiffHunk{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[3] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -459,7 +308,7 @@ func (x *DiffHunk) String() string { func (*DiffHunk) ProtoMessage() {} func (x *DiffHunk) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[3] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -472,7 +321,7 @@ func (x *DiffHunk) ProtoReflect() protoreflect.Message { // Deprecated: Use DiffHunk.ProtoReflect.Descriptor instead. func (*DiffHunk) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{3} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{2} } func (x *DiffHunk) GetOldStart() int32 { @@ -534,7 +383,7 @@ type RenderNode struct { func (x *RenderNode) Reset() { *x = RenderNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[4] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -546,7 +395,7 @@ func (x *RenderNode) String() string { func (*RenderNode) ProtoMessage() {} func (x *RenderNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[4] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -559,7 +408,7 @@ func (x *RenderNode) ProtoReflect() protoreflect.Message { // Deprecated: Use RenderNode.ProtoReflect.Descriptor instead. func (*RenderNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{4} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{3} } func (x *RenderNode) GetNode() isRenderNode_Node { @@ -736,7 +585,7 @@ type TextNode struct { func (x *TextNode) Reset() { *x = TextNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[5] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -748,7 +597,7 @@ func (x *TextNode) String() string { func (*TextNode) ProtoMessage() {} func (x *TextNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[5] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -761,7 +610,7 @@ func (x *TextNode) ProtoReflect() protoreflect.Message { // Deprecated: Use TextNode.ProtoReflect.Descriptor instead. func (*TextNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{5} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{4} } func (x *TextNode) GetContent() string { @@ -792,7 +641,7 @@ type CodeBlockNode struct { func (x *CodeBlockNode) Reset() { *x = CodeBlockNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[6] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -804,7 +653,7 @@ func (x *CodeBlockNode) String() string { func (*CodeBlockNode) ProtoMessage() {} func (x *CodeBlockNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[6] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -817,7 +666,7 @@ func (x *CodeBlockNode) ProtoReflect() protoreflect.Message { // Deprecated: Use CodeBlockNode.ProtoReflect.Descriptor instead. func (*CodeBlockNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{6} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{5} } func (x *CodeBlockNode) GetLanguage() string { @@ -847,7 +696,7 @@ type DiffNode struct { func (x *DiffNode) Reset() { *x = DiffNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[7] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -859,7 +708,7 @@ func (x *DiffNode) String() string { func (*DiffNode) ProtoMessage() {} func (x *DiffNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[7] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -872,7 +721,7 @@ func (x *DiffNode) ProtoReflect() protoreflect.Message { // Deprecated: Use DiffNode.ProtoReflect.Descriptor instead. func (*DiffNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{7} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{6} } func (x *DiffNode) GetHunks() []*DiffHunk { @@ -894,7 +743,7 @@ type TableRow struct { func (x *TableRow) Reset() { *x = TableRow{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[8] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -906,7 +755,7 @@ func (x *TableRow) String() string { func (*TableRow) ProtoMessage() {} func (x *TableRow) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[8] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -919,7 +768,7 @@ func (x *TableRow) ProtoReflect() protoreflect.Message { // Deprecated: Use TableRow.ProtoReflect.Descriptor instead. func (*TableRow) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{8} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{7} } func (x *TableRow) GetCells() []string { @@ -943,7 +792,7 @@ type TableNode struct { func (x *TableNode) Reset() { *x = TableNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[9] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -955,7 +804,7 @@ func (x *TableNode) String() string { func (*TableNode) ProtoMessage() {} func (x *TableNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[9] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -968,7 +817,7 @@ func (x *TableNode) ProtoReflect() protoreflect.Message { // Deprecated: Use TableNode.ProtoReflect.Descriptor instead. func (*TableNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{9} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{8} } func (x *TableNode) GetHeaders() []string { @@ -998,7 +847,7 @@ type LinkNode struct { func (x *LinkNode) Reset() { *x = LinkNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[10] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1010,7 +859,7 @@ func (x *LinkNode) String() string { func (*LinkNode) ProtoMessage() {} func (x *LinkNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[10] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1023,7 +872,7 @@ func (x *LinkNode) ProtoReflect() protoreflect.Message { // Deprecated: Use LinkNode.ProtoReflect.Descriptor instead. func (*LinkNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{10} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{9} } func (x *LinkNode) GetText() string { @@ -1053,7 +902,7 @@ type ListNode struct { func (x *ListNode) Reset() { *x = ListNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[11] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1065,7 +914,7 @@ func (x *ListNode) String() string { func (*ListNode) ProtoMessage() {} func (x *ListNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[11] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1078,7 +927,7 @@ func (x *ListNode) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNode.ProtoReflect.Descriptor instead. func (*ListNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{11} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{10} } func (x *ListNode) GetItems() []*RenderNode { @@ -1107,7 +956,7 @@ type GroupNode struct { func (x *GroupNode) Reset() { *x = GroupNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[12] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1119,7 +968,7 @@ func (x *GroupNode) String() string { func (*GroupNode) ProtoMessage() {} func (x *GroupNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[12] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1132,7 +981,7 @@ func (x *GroupNode) ProtoReflect() protoreflect.Message { // Deprecated: Use GroupNode.ProtoReflect.Descriptor instead. func (*GroupNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{12} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{11} } func (x *GroupNode) GetChildren() []*RenderNode { @@ -1158,7 +1007,7 @@ type CollapsibleNode struct { func (x *CollapsibleNode) Reset() { *x = CollapsibleNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[13] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1170,7 +1019,7 @@ func (x *CollapsibleNode) String() string { func (*CollapsibleNode) ProtoMessage() {} func (x *CollapsibleNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[13] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1183,7 +1032,7 @@ func (x *CollapsibleNode) ProtoReflect() protoreflect.Message { // Deprecated: Use CollapsibleNode.ProtoReflect.Descriptor instead. func (*CollapsibleNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{13} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{12} } func (x *CollapsibleNode) GetSummary() string { @@ -1222,7 +1071,7 @@ type SubSessionNode struct { func (x *SubSessionNode) Reset() { *x = SubSessionNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[14] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1234,7 +1083,7 @@ func (x *SubSessionNode) String() string { func (*SubSessionNode) ProtoMessage() {} func (x *SubSessionNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[14] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1247,7 +1096,7 @@ func (x *SubSessionNode) ProtoReflect() protoreflect.Message { // Deprecated: Use SubSessionNode.ProtoReflect.Descriptor instead. func (*SubSessionNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{14} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{13} } func (x *SubSessionNode) GetSessionId() string { @@ -1264,16 +1113,17 @@ func (x *SubSessionNode) GetSummary() string { return "" } -// ActionNode is interactive/clickable content — the widget action channel -// (frontend.md §5.1). A frontend MUST make this node interactive and, on -// activation, dispatch a ClientEvent.action_trigger carrying `tool_name` -// and `args` unchanged. The kernel handles the resulting action_trigger -// identically to a direct_invoke slash command: the normal Invoke/ -// plan-apply pipeline including policy evaluation, with no model turn. +// ActionNode is interactive/clickable content in a transcript RenderTree. +// A frontend MUST make this node interactive and, on activation, call +// KernelCallbackService.TriggerAction carrying tool_name, args, and +// provider unchanged. The kernel handles it identically to a +// direct-invoke slash command: the normal Invoke/plan-apply pipeline +// including policy evaluation, with no model turn. type ActionNode struct { state protoimpl.MessageState `protogen:"open.v1"` - // Identifies this action node within its RenderTree, echoed back in the - // resulting ClientEvent so the frontend (and kernel) can correlate. + // Identifies this action node within its RenderTree, echoed back in + // TriggerActionRequest.node_id so the frontend (and kernel) can + // correlate. Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // The clickable label shown to the user. Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` @@ -1287,8 +1137,8 @@ type ActionNode struct { // The declared name of the tool provider plugin `tool_name` belongs to. // tool_name is only unique per provider (matching plan.v1.PlanItem's own // provider/tool_name pairing), so this disambiguates which provider's - // operation to invoke on activation. Echoed unchanged onto the resulting - // ClientEvent.ActionTrigger.provider (frontend.md §"Client events"). + // operation to invoke on activation. Echoed unchanged onto + // TriggerActionRequest.provider. Provider string `protobuf:"bytes,5,opt,name=provider,proto3" json:"provider,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1296,7 +1146,7 @@ type ActionNode struct { func (x *ActionNode) Reset() { *x = ActionNode{} - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[15] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1308,7 +1158,7 @@ func (x *ActionNode) String() string { func (*ActionNode) ProtoMessage() {} func (x *ActionNode) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_render_v1_types_proto_msgTypes[15] + mi := &file_pluggableharness_render_v1_types_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1321,7 +1171,7 @@ func (x *ActionNode) ProtoReflect() protoreflect.Message { // Deprecated: Use ActionNode.ProtoReflect.Descriptor instead. func (*ActionNode) Descriptor() ([]byte, []int) { - return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{15} + return file_pluggableharness_render_v1_types_proto_rawDescGZIP(), []int{14} } func (x *ActionNode) GetId() string { @@ -1366,13 +1216,7 @@ const file_pluggableharness_render_v1_types_proto_rawDesc = "" + "&pluggableharness/render/v1/types.proto\x12\x1apluggableharness.render.v1\x1a\x1cgoogle/protobuf/struct.proto\"H\n" + "\n" + "RenderTree\x12:\n" + - "\x04root\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderNodeR\x04root\"\xd5\x01\n" + - "\rPlacedContent\x12:\n" + - "\x06region\x18\x01 \x01(\x0e2\".pluggableharness.render.v1.RegionR\x06region\x12@\n" + - "\acontent\x18\x02 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\acontent\x12\x18\n" + - "\areplace\x18\x03 \x01(\bR\areplace\x12\x1f\n" + - "\bpriority\x18\x04 \x01(\x05H\x00R\bpriority\x88\x01\x01B\v\n" + - "\t_priority\"V\n" + + "\x04root\x18\x01 \x01(\v2&.pluggableharness.render.v1.RenderNodeR\x04root\"V\n" + "\bDiffLine\x126\n" + "\x02op\x18\x01 \x01(\x0e2&.pluggableharness.render.v1.DiffLineOpR\x02op\x12\x12\n" + "\x04text\x18\x02 \x01(\tR\x04text\"\xba\x01\n" + @@ -1435,15 +1279,7 @@ const file_pluggableharness_render_v1_types_proto_rawDesc = "" + "\x05label\x18\x02 \x01(\tR\x05label\x12\x1b\n" + "\ttool_name\x18\x03 \x01(\tR\btoolName\x12+\n" + "\x04args\x18\x04 \x01(\v2\x17.google.protobuf.StructR\x04args\x12\x1a\n" + - "\bprovider\x18\x05 \x01(\tR\bprovider*\xa1\x01\n" + - "\x06Region\x12\x16\n" + - "\x12REGION_UNSPECIFIED\x10\x00\x12\x14\n" + - "\x10REGION_MAIN_CHAT\x10\x01\x12\x12\n" + - "\x0eREGION_SIDEBAR\x10\x02\x12\x12\n" + - "\x0eREGION_TOP_BAR\x10\x03\x12\x14\n" + - "\x10REGION_INPUT_BAR\x10\x04\x12\x17\n" + - "\x13REGION_HOTKEY_HINTS\x10\x05\x12\x12\n" + - "\x0eREGION_OVERLAY\x10\x06*\xd9\x01\n" + + "\bprovider\x18\x05 \x01(\tR\bprovider*\xd9\x01\n" + "\tTextStyle\x12\x1a\n" + "\x16TEXT_STYLE_UNSPECIFIED\x10\x00\x12\x15\n" + "\x11TEXT_STYLE_NORMAL\x10\x01\x12\x13\n" + @@ -1473,58 +1309,54 @@ func file_pluggableharness_render_v1_types_proto_rawDescGZIP() []byte { return file_pluggableharness_render_v1_types_proto_rawDescData } -var file_pluggableharness_render_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_pluggableharness_render_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_pluggableharness_render_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_pluggableharness_render_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_pluggableharness_render_v1_types_proto_goTypes = []any{ - (Region)(0), // 0: pluggableharness.render.v1.Region - (TextStyle)(0), // 1: pluggableharness.render.v1.TextStyle - (DiffLineOp)(0), // 2: pluggableharness.render.v1.DiffLineOp - (*RenderTree)(nil), // 3: pluggableharness.render.v1.RenderTree - (*PlacedContent)(nil), // 4: pluggableharness.render.v1.PlacedContent - (*DiffLine)(nil), // 5: pluggableharness.render.v1.DiffLine - (*DiffHunk)(nil), // 6: pluggableharness.render.v1.DiffHunk - (*RenderNode)(nil), // 7: pluggableharness.render.v1.RenderNode - (*TextNode)(nil), // 8: pluggableharness.render.v1.TextNode - (*CodeBlockNode)(nil), // 9: pluggableharness.render.v1.CodeBlockNode - (*DiffNode)(nil), // 10: pluggableharness.render.v1.DiffNode - (*TableRow)(nil), // 11: pluggableharness.render.v1.TableRow - (*TableNode)(nil), // 12: pluggableharness.render.v1.TableNode - (*LinkNode)(nil), // 13: pluggableharness.render.v1.LinkNode - (*ListNode)(nil), // 14: pluggableharness.render.v1.ListNode - (*GroupNode)(nil), // 15: pluggableharness.render.v1.GroupNode - (*CollapsibleNode)(nil), // 16: pluggableharness.render.v1.CollapsibleNode - (*SubSessionNode)(nil), // 17: pluggableharness.render.v1.SubSessionNode - (*ActionNode)(nil), // 18: pluggableharness.render.v1.ActionNode - (*structpb.Struct)(nil), // 19: google.protobuf.Struct + (TextStyle)(0), // 0: pluggableharness.render.v1.TextStyle + (DiffLineOp)(0), // 1: pluggableharness.render.v1.DiffLineOp + (*RenderTree)(nil), // 2: pluggableharness.render.v1.RenderTree + (*DiffLine)(nil), // 3: pluggableharness.render.v1.DiffLine + (*DiffHunk)(nil), // 4: pluggableharness.render.v1.DiffHunk + (*RenderNode)(nil), // 5: pluggableharness.render.v1.RenderNode + (*TextNode)(nil), // 6: pluggableharness.render.v1.TextNode + (*CodeBlockNode)(nil), // 7: pluggableharness.render.v1.CodeBlockNode + (*DiffNode)(nil), // 8: pluggableharness.render.v1.DiffNode + (*TableRow)(nil), // 9: pluggableharness.render.v1.TableRow + (*TableNode)(nil), // 10: pluggableharness.render.v1.TableNode + (*LinkNode)(nil), // 11: pluggableharness.render.v1.LinkNode + (*ListNode)(nil), // 12: pluggableharness.render.v1.ListNode + (*GroupNode)(nil), // 13: pluggableharness.render.v1.GroupNode + (*CollapsibleNode)(nil), // 14: pluggableharness.render.v1.CollapsibleNode + (*SubSessionNode)(nil), // 15: pluggableharness.render.v1.SubSessionNode + (*ActionNode)(nil), // 16: pluggableharness.render.v1.ActionNode + (*structpb.Struct)(nil), // 17: google.protobuf.Struct } var file_pluggableharness_render_v1_types_proto_depIdxs = []int32{ - 7, // 0: pluggableharness.render.v1.RenderTree.root:type_name -> pluggableharness.render.v1.RenderNode - 0, // 1: pluggableharness.render.v1.PlacedContent.region:type_name -> pluggableharness.render.v1.Region - 3, // 2: pluggableharness.render.v1.PlacedContent.content:type_name -> pluggableharness.render.v1.RenderTree - 2, // 3: pluggableharness.render.v1.DiffLine.op:type_name -> pluggableharness.render.v1.DiffLineOp - 5, // 4: pluggableharness.render.v1.DiffHunk.lines:type_name -> pluggableharness.render.v1.DiffLine - 8, // 5: pluggableharness.render.v1.RenderNode.text:type_name -> pluggableharness.render.v1.TextNode - 9, // 6: pluggableharness.render.v1.RenderNode.code_block:type_name -> pluggableharness.render.v1.CodeBlockNode - 10, // 7: pluggableharness.render.v1.RenderNode.diff:type_name -> pluggableharness.render.v1.DiffNode - 12, // 8: pluggableharness.render.v1.RenderNode.table:type_name -> pluggableharness.render.v1.TableNode - 13, // 9: pluggableharness.render.v1.RenderNode.link:type_name -> pluggableharness.render.v1.LinkNode - 14, // 10: pluggableharness.render.v1.RenderNode.list:type_name -> pluggableharness.render.v1.ListNode - 15, // 11: pluggableharness.render.v1.RenderNode.group:type_name -> pluggableharness.render.v1.GroupNode - 16, // 12: pluggableharness.render.v1.RenderNode.collapsible:type_name -> pluggableharness.render.v1.CollapsibleNode - 17, // 13: pluggableharness.render.v1.RenderNode.sub_session:type_name -> pluggableharness.render.v1.SubSessionNode - 18, // 14: pluggableharness.render.v1.RenderNode.action:type_name -> pluggableharness.render.v1.ActionNode - 1, // 15: pluggableharness.render.v1.TextNode.style:type_name -> pluggableharness.render.v1.TextStyle - 6, // 16: pluggableharness.render.v1.DiffNode.hunks:type_name -> pluggableharness.render.v1.DiffHunk - 11, // 17: pluggableharness.render.v1.TableNode.rows:type_name -> pluggableharness.render.v1.TableRow - 7, // 18: pluggableharness.render.v1.ListNode.items:type_name -> pluggableharness.render.v1.RenderNode - 7, // 19: pluggableharness.render.v1.GroupNode.children:type_name -> pluggableharness.render.v1.RenderNode - 7, // 20: pluggableharness.render.v1.CollapsibleNode.children:type_name -> pluggableharness.render.v1.RenderNode - 19, // 21: pluggableharness.render.v1.ActionNode.args:type_name -> google.protobuf.Struct - 22, // [22:22] is the sub-list for method output_type - 22, // [22:22] is the sub-list for method input_type - 22, // [22:22] is the sub-list for extension type_name - 22, // [22:22] is the sub-list for extension extendee - 0, // [0:22] is the sub-list for field type_name + 5, // 0: pluggableharness.render.v1.RenderTree.root:type_name -> pluggableharness.render.v1.RenderNode + 1, // 1: pluggableharness.render.v1.DiffLine.op:type_name -> pluggableharness.render.v1.DiffLineOp + 3, // 2: pluggableharness.render.v1.DiffHunk.lines:type_name -> pluggableharness.render.v1.DiffLine + 6, // 3: pluggableharness.render.v1.RenderNode.text:type_name -> pluggableharness.render.v1.TextNode + 7, // 4: pluggableharness.render.v1.RenderNode.code_block:type_name -> pluggableharness.render.v1.CodeBlockNode + 8, // 5: pluggableharness.render.v1.RenderNode.diff:type_name -> pluggableharness.render.v1.DiffNode + 10, // 6: pluggableharness.render.v1.RenderNode.table:type_name -> pluggableharness.render.v1.TableNode + 11, // 7: pluggableharness.render.v1.RenderNode.link:type_name -> pluggableharness.render.v1.LinkNode + 12, // 8: pluggableharness.render.v1.RenderNode.list:type_name -> pluggableharness.render.v1.ListNode + 13, // 9: pluggableharness.render.v1.RenderNode.group:type_name -> pluggableharness.render.v1.GroupNode + 14, // 10: pluggableharness.render.v1.RenderNode.collapsible:type_name -> pluggableharness.render.v1.CollapsibleNode + 15, // 11: pluggableharness.render.v1.RenderNode.sub_session:type_name -> pluggableharness.render.v1.SubSessionNode + 16, // 12: pluggableharness.render.v1.RenderNode.action:type_name -> pluggableharness.render.v1.ActionNode + 0, // 13: pluggableharness.render.v1.TextNode.style:type_name -> pluggableharness.render.v1.TextStyle + 4, // 14: pluggableharness.render.v1.DiffNode.hunks:type_name -> pluggableharness.render.v1.DiffHunk + 9, // 15: pluggableharness.render.v1.TableNode.rows:type_name -> pluggableharness.render.v1.TableRow + 5, // 16: pluggableharness.render.v1.ListNode.items:type_name -> pluggableharness.render.v1.RenderNode + 5, // 17: pluggableharness.render.v1.GroupNode.children:type_name -> pluggableharness.render.v1.RenderNode + 5, // 18: pluggableharness.render.v1.CollapsibleNode.children:type_name -> pluggableharness.render.v1.RenderNode + 17, // 19: pluggableharness.render.v1.ActionNode.args:type_name -> google.protobuf.Struct + 20, // [20:20] is the sub-list for method output_type + 20, // [20:20] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_pluggableharness_render_v1_types_proto_init() } @@ -1532,8 +1364,7 @@ func file_pluggableharness_render_v1_types_proto_init() { if File_pluggableharness_render_v1_types_proto != nil { return } - file_pluggableharness_render_v1_types_proto_msgTypes[1].OneofWrappers = []any{} - file_pluggableharness_render_v1_types_proto_msgTypes[4].OneofWrappers = []any{ + file_pluggableharness_render_v1_types_proto_msgTypes[3].OneofWrappers = []any{ (*RenderNode_Text)(nil), (*RenderNode_CodeBlock)(nil), (*RenderNode_Diff)(nil), @@ -1545,15 +1376,15 @@ func file_pluggableharness_render_v1_types_proto_init() { (*RenderNode_SubSession)(nil), (*RenderNode_Action)(nil), } + file_pluggableharness_render_v1_types_proto_msgTypes[4].OneofWrappers = []any{} file_pluggableharness_render_v1_types_proto_msgTypes[5].OneofWrappers = []any{} - file_pluggableharness_render_v1_types_proto_msgTypes[6].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_render_v1_types_proto_rawDesc), len(file_pluggableharness_render_v1_types_proto_rawDesc)), - NumEnums: 3, - NumMessages: 16, + NumEnums: 2, + NumMessages: 15, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/session/proto/v1/types.pb.go b/pkg/session/proto/v1/types.pb.go index 558721f..f71cb18 100644 --- a/pkg/session/proto/v1/types.pb.go +++ b/pkg/session/proto/v1/types.pb.go @@ -4,16 +4,19 @@ // protoc (unknown) // source: pluggableharness/session/v1/types.proto -// Package pluggableharness.session.v1 defines the session lifecycle status enum -// shared by kernel-callbacks.md §1's RunSessionResult, state-backend.md -// §4.2's session_meta.status column, and frontend.md §3.2's -// session_tree_update ServerEvent. +// Package pluggableharness.session.v1 defines the session lifecycle status +// enum shared by kernel-callbacks.md's RunSessionResult and +// state-backend.md's session_meta.status column, plus SessionInfo and the +// frontend-facing SessionState snapshot ("where am I") assembled by +// KernelCallbackService.GetSessionState. package sessionv1 import ( + v1 "github.com/pluggableharness/agent/pkg/model/proto/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" @@ -110,9 +113,9 @@ func (SessionStatus) EnumDescriptor() ([]byte, []int) { // SessionInfo is a session's shareable summary, mirroring // state-backend.md's session_meta row plus a cheap cost_ledger SUM. Used -// by frontend.md's SessionCreated/SessionAttached/SessionList ServerEvent -// variants — the frontend protocol's read-only view of session lifecycle -// state, never mutated by a frontend directly. +// by session lifecycle RPCs (CreateSession/AttachSession/ListSessions) +// and embedded inside SessionState — the frontend protocol's read-only +// view of session lifecycle state, never mutated by a frontend directly. type SessionInfo struct { state protoimpl.MessageState `protogen:"open.v1"` // The session's id. ULID, matches the session's sqlite filename stem @@ -228,11 +231,380 @@ func (x *SessionInfo) GetCostUsd() float64 { return 0 } +// VcsState is the session's version-control summary for status rendering. +// Absent fields mean "unknown / not a VCS working tree," not empty. +type VcsState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The remote tracking URL or name, when known. + Remote *string `protobuf:"bytes,1,opt,name=remote,proto3,oneof" json:"remote,omitempty"` + // The current branch or detached-HEAD ref name, when known. + Branch *string `protobuf:"bytes,2,opt,name=branch,proto3,oneof" json:"branch,omitempty"` + // True when the working tree has uncommitted changes. Absent when VCS + // state could not be determined. + Dirty *bool `protobuf:"varint,3,opt,name=dirty,proto3,oneof" json:"dirty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VcsState) Reset() { + *x = VcsState{} + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VcsState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VcsState) ProtoMessage() {} + +func (x *VcsState) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VcsState.ProtoReflect.Descriptor instead. +func (*VcsState) Descriptor() ([]byte, []int) { + return file_pluggableharness_session_v1_types_proto_rawDescGZIP(), []int{1} +} + +func (x *VcsState) GetRemote() string { + if x != nil && x.Remote != nil { + return *x.Remote + } + return "" +} + +func (x *VcsState) GetBranch() string { + if x != nil && x.Branch != nil { + return *x.Branch + } + return "" +} + +func (x *VcsState) GetDirty() bool { + if x != nil && x.Dirty != nil { + return *x.Dirty + } + return false +} + +// ModelState names the model currently driving the session. +type ModelState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The model's id within its provider (ModelSpec.id). + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // The declared name of the model provider plugin. + Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModelState) Reset() { + *x = ModelState{} + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModelState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModelState) ProtoMessage() {} + +func (x *ModelState) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModelState.ProtoReflect.Descriptor instead. +func (*ModelState) Descriptor() ([]byte, []int) { + return file_pluggableharness_session_v1_types_proto_rawDescGZIP(), []int{2} +} + +func (x *ModelState) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ModelState) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +// ContextState is the session's context-window pressure for status bars. +type ContextState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Tokens currently used against the window. + UsedTokens int64 `protobuf:"varint,1,opt,name=used_tokens,json=usedTokens,proto3" json:"used_tokens,omitempty"` + // The usable context window size in tokens (effective ceiling). + WindowTokens int64 `protobuf:"varint,2,opt,name=window_tokens,json=windowTokens,proto3" json:"window_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ContextState) Reset() { + *x = ContextState{} + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ContextState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextState) ProtoMessage() {} + +func (x *ContextState) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextState.ProtoReflect.Descriptor instead. +func (*ContextState) Descriptor() ([]byte, []int) { + return file_pluggableharness_session_v1_types_proto_rawDescGZIP(), []int{3} +} + +func (x *ContextState) GetUsedTokens() int64 { + if x != nil { + return x.UsedTokens + } + return 0 +} + +func (x *ContextState) GetWindowTokens() int64 { + if x != nil { + return x.WindowTokens + } + return 0 +} + +// SessionState is the fixed-schema "where am I" snapshot a frontend +// renders into a status bar, HTTP header, stdout line, or spoken sentence. +// Assembled by KernelCallbackService.GetSessionState and republished on +// the event bus topic kernel.state whenever a watched field changes. +// Per-session: every snapshot names exactly one session. No extension +// point — a closed schema is what makes every frontend able to render it. +type SessionState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The session's shareable lifecycle summary. MUST be set. + Info *SessionInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` + // Absolute working directory for this session. MUST be set once the + // session has one; empty only before CreateSession finishes binding it. + WorkingDirectory string `protobuf:"bytes,2,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` + // Version-control summary for working_directory. Absent when not a + // VCS tree or not yet probed. + Vcs *VcsState `protobuf:"bytes,3,opt,name=vcs,proto3,oneof" json:"vcs,omitempty"` + // The model currently selected for this session's turns. Absent only + // before the first model resolution. + Model *ModelState `protobuf:"bytes,4,opt,name=model,proto3,oneof" json:"model,omitempty"` + // The active thinking/effort level name for the current model, when + // the model exposes an effort ladder. Absent when thinking is off or + // the model has no effort control. + ThinkingEffort *string `protobuf:"bytes,5,opt,name=thinking_effort,json=thinkingEffort,proto3,oneof" json:"thinking_effort,omitempty"` + // Context-window pressure. Absent only before the first turn's usage + // is known. + Context *ContextState `protobuf:"bytes,6,opt,name=context,proto3,oneof" json:"context,omitempty"` + // Number of completed turns in this session. + TurnCount int32 `protobuf:"varint,7,opt,name=turn_count,json=turnCount,proto3" json:"turn_count,omitempty"` + // Wall-clock time since info.started_at. MUST be set for a live + // session; for a terminal session equals ended_at - started_at. + Elapsed *durationpb.Duration `protobuf:"bytes,8,opt,name=elapsed,proto3" json:"elapsed,omitempty"` + // Session-lifetime total tokens (input + output), summed from the cost + // ledger / usage rollups. Zero when no model call has completed yet. + TotalTokens int64 `protobuf:"varint,9,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + // The vendor budgets reported by the most recent completion, from that + // completion's Usage or StreamMetadata. + // + // Distinct from account.quotas below: these are per-completion + // readings taken from response headers as turns run, while + // account.quotas is the account-level snapshot GetAccount returns + // independently of any completion. A subscription product typically + // publishes both, and they refresh on different schedules. + // + // MAY be empty — a vendor that publishes no budgets has nothing here, + // and the kernel MUST NOT synthesize an entry from its own token + // counting. + Quotas []*v1.RateLimitSnapshot `protobuf:"bytes,10,rep,name=quotas,proto3" json:"quotas,omitempty"` + // The account and entitlement state behind the session's model + // provider, from GetAccount. Absent when the provider does not + // implement that RPC, which is the common case for a bare API key. + Account *v1.AccountSnapshot `protobuf:"bytes,11,opt,name=account,proto3,oneof" json:"account,omitempty"` + // What the vendor said the most recent completion cost, in its own + // denomination. + // + // Reported beside info.cost_usd, never instead of it: cost_usd remains + // the kernel's computed figure and the one every rollup and budget + // reads. This is here so a frontend can show that list price and + // actual bill disagree — and so a subscription session, where computed + // cost is structurally 0.00, has something truthful to display. + VendorCost *v1.VendorCost `protobuf:"bytes,12,opt,name=vendor_cost,json=vendorCost,proto3,oneof" json:"vendor_cost,omitempty"` + // The model that actually served the most recent completion, when the + // vendor remapped it away from the requested id. + // + // Surfaced at session level because silent model substitution is + // otherwise invisible: an operator sees only that answers got worse, + // with nothing in the UI to attribute it to. Absent means the vendor + // served what was asked for, or said nothing. + ActualModel *string `protobuf:"bytes,13,opt,name=actual_model,json=actualModel,proto3,oneof" json:"actual_model,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionState) Reset() { + *x = SessionState{} + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionState) ProtoMessage() {} + +func (x *SessionState) ProtoReflect() protoreflect.Message { + mi := &file_pluggableharness_session_v1_types_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionState.ProtoReflect.Descriptor instead. +func (*SessionState) Descriptor() ([]byte, []int) { + return file_pluggableharness_session_v1_types_proto_rawDescGZIP(), []int{4} +} + +func (x *SessionState) GetInfo() *SessionInfo { + if x != nil { + return x.Info + } + return nil +} + +func (x *SessionState) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + +func (x *SessionState) GetVcs() *VcsState { + if x != nil { + return x.Vcs + } + return nil +} + +func (x *SessionState) GetModel() *ModelState { + if x != nil { + return x.Model + } + return nil +} + +func (x *SessionState) GetThinkingEffort() string { + if x != nil && x.ThinkingEffort != nil { + return *x.ThinkingEffort + } + return "" +} + +func (x *SessionState) GetContext() *ContextState { + if x != nil { + return x.Context + } + return nil +} + +func (x *SessionState) GetTurnCount() int32 { + if x != nil { + return x.TurnCount + } + return 0 +} + +func (x *SessionState) GetElapsed() *durationpb.Duration { + if x != nil { + return x.Elapsed + } + return nil +} + +func (x *SessionState) GetTotalTokens() int64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *SessionState) GetQuotas() []*v1.RateLimitSnapshot { + if x != nil { + return x.Quotas + } + return nil +} + +func (x *SessionState) GetAccount() *v1.AccountSnapshot { + if x != nil { + return x.Account + } + return nil +} + +func (x *SessionState) GetVendorCost() *v1.VendorCost { + if x != nil { + return x.VendorCost + } + return nil +} + +func (x *SessionState) GetActualModel() string { + if x != nil && x.ActualModel != nil { + return *x.ActualModel + } + return "" +} + var File_pluggableharness_session_v1_types_proto protoreflect.FileDescriptor const file_pluggableharness_session_v1_types_proto_rawDesc = "" + "\n" + - "'pluggableharness/session/v1/types.proto\x12\x1bpluggableharness.session.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x98\x03\n" + + "'pluggableharness/session/v1/types.proto\x12\x1bpluggableharness.session.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a%pluggableharness/model/v1/types.proto\"\x98\x03\n" + "\vSessionInfo\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12/\n" + @@ -246,7 +618,48 @@ const file_pluggableharness_session_v1_types_proto_rawDesc = "" + "\bcost_usd\x18\b \x01(\x01H\x02R\acostUsd\x88\x01\x01B\x14\n" + "\x12_parent_session_idB\v\n" + "\t_ended_atB\v\n" + - "\t_cost_usd*\x98\x02\n" + + "\t_cost_usd\"\x7f\n" + + "\bVcsState\x12\x1b\n" + + "\x06remote\x18\x01 \x01(\tH\x00R\x06remote\x88\x01\x01\x12\x1b\n" + + "\x06branch\x18\x02 \x01(\tH\x01R\x06branch\x88\x01\x01\x12\x19\n" + + "\x05dirty\x18\x03 \x01(\bH\x02R\x05dirty\x88\x01\x01B\t\n" + + "\a_remoteB\t\n" + + "\a_branchB\b\n" + + "\x06_dirty\"8\n" + + "\n" + + "ModelState\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + + "\bprovider\x18\x02 \x01(\tR\bprovider\"T\n" + + "\fContextState\x12\x1f\n" + + "\vused_tokens\x18\x01 \x01(\x03R\n" + + "usedTokens\x12#\n" + + "\rwindow_tokens\x18\x02 \x01(\x03R\fwindowTokens\"\xcf\x06\n" + + "\fSessionState\x12<\n" + + "\x04info\x18\x01 \x01(\v2(.pluggableharness.session.v1.SessionInfoR\x04info\x12+\n" + + "\x11working_directory\x18\x02 \x01(\tR\x10workingDirectory\x12<\n" + + "\x03vcs\x18\x03 \x01(\v2%.pluggableharness.session.v1.VcsStateH\x00R\x03vcs\x88\x01\x01\x12B\n" + + "\x05model\x18\x04 \x01(\v2'.pluggableharness.session.v1.ModelStateH\x01R\x05model\x88\x01\x01\x12,\n" + + "\x0fthinking_effort\x18\x05 \x01(\tH\x02R\x0ethinkingEffort\x88\x01\x01\x12H\n" + + "\acontext\x18\x06 \x01(\v2).pluggableharness.session.v1.ContextStateH\x03R\acontext\x88\x01\x01\x12\x1d\n" + + "\n" + + "turn_count\x18\a \x01(\x05R\tturnCount\x123\n" + + "\aelapsed\x18\b \x01(\v2\x19.google.protobuf.DurationR\aelapsed\x12!\n" + + "\ftotal_tokens\x18\t \x01(\x03R\vtotalTokens\x12D\n" + + "\x06quotas\x18\n" + + " \x03(\v2,.pluggableharness.model.v1.RateLimitSnapshotR\x06quotas\x12I\n" + + "\aaccount\x18\v \x01(\v2*.pluggableharness.model.v1.AccountSnapshotH\x04R\aaccount\x88\x01\x01\x12K\n" + + "\vvendor_cost\x18\f \x01(\v2%.pluggableharness.model.v1.VendorCostH\x05R\n" + + "vendorCost\x88\x01\x01\x12&\n" + + "\factual_model\x18\r \x01(\tH\x06R\vactualModel\x88\x01\x01B\x06\n" + + "\x04_vcsB\b\n" + + "\x06_modelB\x12\n" + + "\x10_thinking_effortB\n" + + "\n" + + "\b_contextB\n" + + "\n" + + "\b_accountB\x0e\n" + + "\f_vendor_costB\x0f\n" + + "\r_actual_model*\x98\x02\n" + "\rSessionStatus\x12\x1e\n" + "\x1aSESSION_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n" + "\x16SESSION_STATUS_RUNNING\x10\x01\x12\x1c\n" + @@ -270,21 +683,37 @@ func file_pluggableharness_session_v1_types_proto_rawDescGZIP() []byte { } var file_pluggableharness_session_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pluggableharness_session_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pluggableharness_session_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_pluggableharness_session_v1_types_proto_goTypes = []any{ (SessionStatus)(0), // 0: pluggableharness.session.v1.SessionStatus (*SessionInfo)(nil), // 1: pluggableharness.session.v1.SessionInfo - (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp + (*VcsState)(nil), // 2: pluggableharness.session.v1.VcsState + (*ModelState)(nil), // 3: pluggableharness.session.v1.ModelState + (*ContextState)(nil), // 4: pluggableharness.session.v1.ContextState + (*SessionState)(nil), // 5: pluggableharness.session.v1.SessionState + (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 7: google.protobuf.Duration + (*v1.RateLimitSnapshot)(nil), // 8: pluggableharness.model.v1.RateLimitSnapshot + (*v1.AccountSnapshot)(nil), // 9: pluggableharness.model.v1.AccountSnapshot + (*v1.VendorCost)(nil), // 10: pluggableharness.model.v1.VendorCost } var file_pluggableharness_session_v1_types_proto_depIdxs = []int32{ - 0, // 0: pluggableharness.session.v1.SessionInfo.status:type_name -> pluggableharness.session.v1.SessionStatus - 2, // 1: pluggableharness.session.v1.SessionInfo.started_at:type_name -> google.protobuf.Timestamp - 2, // 2: pluggableharness.session.v1.SessionInfo.ended_at:type_name -> google.protobuf.Timestamp - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 0, // 0: pluggableharness.session.v1.SessionInfo.status:type_name -> pluggableharness.session.v1.SessionStatus + 6, // 1: pluggableharness.session.v1.SessionInfo.started_at:type_name -> google.protobuf.Timestamp + 6, // 2: pluggableharness.session.v1.SessionInfo.ended_at:type_name -> google.protobuf.Timestamp + 1, // 3: pluggableharness.session.v1.SessionState.info:type_name -> pluggableharness.session.v1.SessionInfo + 2, // 4: pluggableharness.session.v1.SessionState.vcs:type_name -> pluggableharness.session.v1.VcsState + 3, // 5: pluggableharness.session.v1.SessionState.model:type_name -> pluggableharness.session.v1.ModelState + 4, // 6: pluggableharness.session.v1.SessionState.context:type_name -> pluggableharness.session.v1.ContextState + 7, // 7: pluggableharness.session.v1.SessionState.elapsed:type_name -> google.protobuf.Duration + 8, // 8: pluggableharness.session.v1.SessionState.quotas:type_name -> pluggableharness.model.v1.RateLimitSnapshot + 9, // 9: pluggableharness.session.v1.SessionState.account:type_name -> pluggableharness.model.v1.AccountSnapshot + 10, // 10: pluggableharness.session.v1.SessionState.vendor_cost:type_name -> pluggableharness.model.v1.VendorCost + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_pluggableharness_session_v1_types_proto_init() } @@ -293,13 +722,15 @@ func file_pluggableharness_session_v1_types_proto_init() { return } file_pluggableharness_session_v1_types_proto_msgTypes[0].OneofWrappers = []any{} + file_pluggableharness_session_v1_types_proto_msgTypes[1].OneofWrappers = []any{} + file_pluggableharness_session_v1_types_proto_msgTypes[4].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_session_v1_types_proto_rawDesc), len(file_pluggableharness_session_v1_types_proto_rawDesc)), NumEnums: 1, - NumMessages: 1, + NumMessages: 5, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/tool/capabilities.go b/pkg/tool/capabilities.go index 99ea29e..b8a77dc 100644 --- a/pkg/tool/capabilities.go +++ b/pkg/tool/capabilities.go @@ -1,33 +1,56 @@ package tool import ( - "context" "fmt" toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" ) // BuildGetSchemaResponse assembles the full GetSchemaResponse for p: every -// operation's Schema (via p.Schema, converted and validated with -// toProtoSchema), plus this provider's config schema, slash commands, -// and supported hook points when p additionally implements -// ConfigSchemaProvider, SlashCommandProvider, or HookPointProvider — see -// tool.go. This package does not re-validate a ConfigSchemaProvider's -// output; it trusts pkg/config's own Attribute/Schema validation, which a -// well-behaved ConfigSchemaProvider implementation is expected to have run -// already. -func BuildGetSchemaResponse(ctx context.Context, p Provider) (*toolv1.GetSchemaResponse, error) { - schemas, err := p.Schema(ctx) - if err != nil { - return nil, fmt.Errorf("tool: get schema: %w", err) - } +// Tool's Schema (converted and validated with toProtoSchema), plus this +// provider's config schema, slash commands, and supported hook points when +// p additionally implements ConfigSchemaProvider, SlashCommandProvider, or +// HookPointProvider — see tool.go. This package does not re-validate a +// ConfigSchemaProvider's output; it trusts pkg/config's own +// Attribute/Schema validation, which a well-behaved ConfigSchemaProvider +// implementation is expected to have run already. +// +// Takes no context because every input to the advertisement is static — +// docs/specifications/tool/protocol.md#getschema's "MUST be cheaply +// re-queryable and MUST NOT require a network call", expressed in the +// signature rather than left to provider discipline. +func BuildGetSchemaResponse(p Provider) (*toolv1.GetSchemaResponse, error) { + _, resp, err := resolveTools(p) + return resp, err +} - tools := make([]*toolv1.ToolSchema, 0, len(schemas)) - for _, s := range schemas { +// resolveTools walks p's Tools exactly once, returning both the name-keyed +// dispatch table Service routes an incoming Call with and the wire +// advertisement GetSchema serves. The two are built from that single pass +// deliberately: querying each Tool's Schema twice would let a provider whose +// declarations are not stable produce a dispatch table that disagrees with +// what it told the kernel it exposes. +func resolveTools(p Provider) (map[string]Tool, *toolv1.GetSchemaResponse, error) { + implTools := p.Tools() + + byName := make(map[string]Tool, len(implTools)) + tools := make([]*toolv1.ToolSchema, 0, len(implTools)) + for i, t := range implTools { + if t == nil { + return nil, nil, fmt.Errorf("tool: get schema: tool at index %d: %w", i, ErrNilTool) + } + s, err := t.Schema() + if err != nil { + return nil, nil, fmt.Errorf("tool: get schema: tool at index %d: %w", i, err) + } ps, err := toProtoSchema(s) if err != nil { - return nil, fmt.Errorf("tool: get schema: %w", err) + return nil, nil, fmt.Errorf("tool: get schema: %w", err) + } + if _, dup := byName[s.Name]; dup { + return nil, nil, fmt.Errorf("tool: get schema: %q: %w", s.Name, ErrDuplicateToolName) } + byName[s.Name] = t tools = append(tools, ps) } @@ -36,7 +59,7 @@ func BuildGetSchemaResponse(ctx context.Context, p Provider) (*toolv1.GetSchemaR if cs, ok := p.(ConfigSchemaProvider); ok { schema, err := cs.ConfigSchema() if err != nil { - return nil, fmt.Errorf("tool: get schema: config schema: %w", err) + return nil, nil, fmt.Errorf("tool: get schema: config schema: %w", err) } resp.ConfigSchema = schema } @@ -47,5 +70,5 @@ func BuildGetSchemaResponse(ctx context.Context, p Provider) (*toolv1.GetSchemaR resp.SupportedHookPoints = hp.SupportedHookPoints() } - return resp, nil + return byName, resp, nil } diff --git a/pkg/tool/capabilities_test.go b/pkg/tool/capabilities_test.go index 666eae9..5d8290e 100644 --- a/pkg/tool/capabilities_test.go +++ b/pkg/tool/capabilities_test.go @@ -1,7 +1,6 @@ package tool_test import ( - "context" "errors" "testing" @@ -13,13 +12,9 @@ import ( func TestBuildGetSchemaResponseBasic(t *testing.T) { t.Parallel() - p := &fakeProvider{ - schemaFunc: func(context.Context) ([]*tool.Schema, error) { - return []*tool.Schema{validSchema("read_file"), validSchema("glob")}, nil - }, - } + p := newFakeProvider(newFakeTool("read_file"), newFakeTool("glob")) - resp, err := tool.BuildGetSchemaResponse(t.Context(), p) + resp, err := tool.BuildGetSchemaResponse(p) if err != nil { t.Fatalf("BuildGetSchemaResponse: %v", err) } @@ -41,26 +36,31 @@ func TestBuildGetSchemaResponseSchemaError(t *testing.T) { t.Parallel() wantErr := errors.New("boom") - p := &fakeProvider{ - schemaFunc: func(context.Context) ([]*tool.Schema, error) { return nil, wantErr }, - } + p := newFakeProvider(&fakeTool{schemaErr: wantErr}) - _, err := tool.BuildGetSchemaResponse(t.Context(), p) + _, err := tool.BuildGetSchemaResponse(p) if !errors.Is(err, wantErr) { t.Fatalf("BuildGetSchemaResponse() error = %v, want wrapping %v", err, wantErr) } } -func TestBuildGetSchemaResponseInvalidSchema(t *testing.T) { +func TestBuildGetSchemaResponseNilTool(t *testing.T) { t.Parallel() - p := &fakeProvider{ - schemaFunc: func(context.Context) ([]*tool.Schema, error) { - return []*tool.Schema{{Name: ""}}, nil // missing everything - }, + p := newFakeProvider(newFakeTool("read_file"), nil) + + _, err := tool.BuildGetSchemaResponse(p) + if !errors.Is(err, tool.ErrNilTool) { + t.Fatalf("BuildGetSchemaResponse() error = %v, want wrapping %v", err, tool.ErrNilTool) } +} + +func TestBuildGetSchemaResponseInvalidSchema(t *testing.T) { + t.Parallel() + + p := newFakeProvider(&fakeTool{schema: &tool.Schema{Name: ""}}) // missing everything - _, err := tool.BuildGetSchemaResponse(t.Context(), p) + _, err := tool.BuildGetSchemaResponse(p) if err == nil { t.Fatal("BuildGetSchemaResponse() with an invalid Schema: want error, got nil") } @@ -69,11 +69,7 @@ func TestBuildGetSchemaResponseInvalidSchema(t *testing.T) { func TestBuildGetSchemaResponseOptionalCapabilities(t *testing.T) { t.Parallel() - base := &fakeProvider{ - schemaFunc: func(context.Context) ([]*tool.Schema, error) { - return []*tool.Schema{validSchema("op")}, nil - }, - } + base := newFakeProvider(newFakeTool("op")) wantSchema := &configv1.ConfigSchema{} wantSlash := []*commonv1.PromptExpansionSpec{{Name: "foo", Template: "do foo"}} wantHooks := []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_PRE_TOOL_CALL} @@ -85,7 +81,7 @@ func TestBuildGetSchemaResponseOptionalCapabilities(t *testing.T) { hookPoints: wantHooks, } - resp, err := tool.BuildGetSchemaResponse(t.Context(), p) + resp, err := tool.BuildGetSchemaResponse(p) if err != nil { t.Fatalf("BuildGetSchemaResponse: %v", err) } @@ -104,15 +100,12 @@ func TestBuildGetSchemaResponseConfigSchemaError(t *testing.T) { t.Parallel() wantErr := errors.New("bad config schema") - base := &fakeProvider{ - schemaFunc: func(context.Context) ([]*tool.Schema, error) { return nil, nil }, - } p := &fakeFullProvider{ - fakeProvider: base, + fakeProvider: newFakeProvider(), configSchemaFunc: func() (*configv1.ConfigSchema, error) { return nil, wantErr }, } - _, err := tool.BuildGetSchemaResponse(t.Context(), p) + _, err := tool.BuildGetSchemaResponse(p) if !errors.Is(err, wantErr) { t.Fatalf("BuildGetSchemaResponse() error = %v, want wrapping %v", err, wantErr) } diff --git a/pkg/tool/convert.go b/pkg/tool/convert.go index 01d378c..0cc70ba 100644 --- a/pkg/tool/convert.go +++ b/pkg/tool/convert.go @@ -29,6 +29,18 @@ var ( // ErrEmptyName is returned when a Schema's Name is empty. ErrEmptyName = errors.New("tool: name must not be empty") + // ErrNilTool is returned by NewService and BuildGetSchemaResponse + // when Provider.Tools yields a nil Tool. + ErrNilTool = errors.New("tool: tool must not be nil") + // ErrDuplicateToolName is returned by NewService when two Tools from + // the same Provider declare the same Schema.Name. ToolSchema.name + // MUST be unique within a provider's namespace + // (docs/specifications/tool/protocol.md#getschema), and a duplicate + // would make Invoke dispatch ambiguous. + ErrDuplicateToolName = errors.New("tool: tool names must be unique within a provider") + // ErrUnknownTool is returned by Invoke and Preview when a Call names + // a tool this provider does not expose. + ErrUnknownTool = errors.New("tool: no such tool in this provider") // ErrUnspecifiedKind is returned when a Schema's Kind is // KindUnspecified. ErrUnspecifiedKind = errors.New("tool: kind must not be unspecified") diff --git a/pkg/tool/doc.go b/pkg/tool/doc.go index eef14ea..f68d16b 100644 --- a/pkg/tool/doc.go +++ b/pkg/tool/doc.go @@ -3,9 +3,36 @@ // access, task tracking, and sub-agent spawning // (docs/specifications/tool/README.md). It sits directly on top of the // generated pkg/tool/proto/v1 stubs (toolv1) and the shared foundation -// packages (pkg/plugin, pkg/config, pkg/schema, pkg/render): a plugin -// author implements Provider, builds a *Service with NewService, and -// passes it to plugin.Config.Services before calling plugin.Serve. +// packages (pkg/plugin, pkg/config, pkg/schema, pkg/render). +// +// # The unit of implementation is one Tool +// +// One plugin process serves as many operations as it likes — this is what +// the wire contract already describes, with GetSchemaResponse.tools +// repeated and every ToolCall naming which of them to run. This package +// mirrors that directly: a plugin author writes one Tool per operation, +// each owning its own Schema and Invoke, and a Provider that returns them: +// +// type FileRead struct{ root string } +// +// func (t *FileRead) Schema() (*tool.Schema, error) { ... } +// func (t *FileRead) Invoke(ctx context.Context, c *tool.Call, s *tool.Stream) error { ... } +// +// func (p *fsProvider) Tools() []tool.Tool { +// return []tool.Tool{&FileRead{p.root}, &FileWrite{p.root}, &FileDelete{p.root}} +// } +// +// The author then builds a *Service with NewService and passes it to +// plugin.Config.Services before calling plugin.Serve. Service owns the +// name-keyed dispatch from an incoming Call to the Tool that serves it, so +// no implementation in this package's care ever switches on Call.ToolName, +// and a tool's declared schema cannot drift from the code behind it. +// +// Provider keeps only what is genuinely plugin-wide: the tool set, +// Configure, and the optional ConfigSchemaProvider, SlashCommandProvider, +// HookPointProvider, and Renderer. Previewer, by contrast, is per Tool — +// see its documentation in tool.go, and Renderer's, for why the two +// optional render-side interfaces sit at different levels. // // See docs/specifications/tool/protocol.md for the six RPCs this package // wires up (GetSchema, Configure, Invoke, Render, Preview, Describe — the diff --git a/pkg/tool/helpers_test.go b/pkg/tool/helpers_test.go index 75f0c83..4483f2e 100644 --- a/pkg/tool/helpers_test.go +++ b/pkg/tool/helpers_test.go @@ -18,20 +18,79 @@ import ( toolv1 "github.com/pluggableharness/agent/pkg/tool/proto/v1" ) -// fakeProvider is a hand-written tool.Provider fake (go-testing.md: fakes, -// not mocking frameworks). Each method's behavior is controlled by a -// caller-set func field; a nil field falls through to a harmless default. +// fakeTool is a hand-written tool.Tool fake (go-testing.md: fakes, not +// mocking frameworks). Schema is served from the schema/schemaErr fields; +// Invoke's behavior is controlled by a caller-set func field, and a nil one +// falls through to sending a bare terminal result. +type fakeTool struct { + schema *tool.Schema + schemaErr error + invokeFunc func(ctx context.Context, call *tool.Call, stream *tool.Stream) error +} + +func (f *fakeTool) Schema() (*tool.Schema, error) { + if f.schemaErr != nil { + return nil, f.schemaErr + } + return f.schema, nil +} + +func (f *fakeTool) Invoke(ctx context.Context, call *tool.Call, stream *tool.Stream) error { + if f.invokeFunc != nil { + return f.invokeFunc(ctx, call, stream) + } + return stream.Send(tool.NewResultEvent(map[string]any{})) +} + +var _ tool.Tool = (*fakeTool)(nil) + +// newFakeTool returns a *fakeTool exposing a minimally valid schema under +// name and the default Invoke — enough for any test that just needs a tool +// to exist under a given name. +func newFakeTool(name string) *fakeTool { + return &fakeTool{schema: validSchema(name)} +} + +// invokingTool returns a *fakeTool named name whose Invoke is fn. +func invokingTool(name string, fn func(ctx context.Context, call *tool.Call, stream *tool.Stream) error) *fakeTool { + return &fakeTool{schema: validSchema(name), invokeFunc: fn} +} + +// fakePreviewTool embeds *fakeTool and additionally implements the optional +// per-tool tool.Previewer. Kept a distinct type from fakeTool so tests can +// also exercise the "this tool does not preview" fallback against a plain +// *fakeTool — including both within one provider, which is the shape +// protocol.md#preview's per-operation MAY actually permits. +type fakePreviewTool struct { + *fakeTool + + previewFunc func(ctx context.Context, call *tool.Call) (*renderv1.RenderTree, error) +} + +func (f *fakePreviewTool) Preview(ctx context.Context, call *tool.Call) (*renderv1.RenderTree, error) { + return f.previewFunc(ctx, call) +} + +var ( + _ tool.Tool = (*fakePreviewTool)(nil) + _ tool.Previewer = (*fakePreviewTool)(nil) +) + +// previewingTool returns a *fakePreviewTool named name whose Preview is fn. +func previewingTool(name string, fn func(ctx context.Context, call *tool.Call) (*renderv1.RenderTree, error)) *fakePreviewTool { + return &fakePreviewTool{fakeTool: newFakeTool(name), previewFunc: fn} +} + +// fakeProvider is a hand-written tool.Provider fake serving a fixed set of +// Tools. Configure's behavior is controlled by a caller-set func field; a +// nil field falls through to a harmless default. type fakeProvider struct { - schemaFunc func(ctx context.Context) ([]*tool.Schema, error) + tools []tool.Tool configureFunc func(ctx context.Context, config map[string]any) error - invokeFunc func(ctx context.Context, call *tool.Call, stream *tool.Stream) error } -func (f *fakeProvider) Schema(ctx context.Context) ([]*tool.Schema, error) { - if f.schemaFunc != nil { - return f.schemaFunc(ctx) - } - return nil, nil +func (f *fakeProvider) Tools() []tool.Tool { + return f.tools } func (f *fakeProvider) Configure(ctx context.Context, config map[string]any) error { @@ -41,27 +100,25 @@ func (f *fakeProvider) Configure(ctx context.Context, config map[string]any) err return nil } -func (f *fakeProvider) Invoke(ctx context.Context, call *tool.Call, stream *tool.Stream) error { - if f.invokeFunc != nil { - return f.invokeFunc(ctx, call, stream) - } - return stream.Send(tool.NewResultEvent(map[string]any{})) -} - var _ tool.Provider = (*fakeProvider)(nil) +// newFakeProvider returns a *fakeProvider exposing tools. +func newFakeProvider(tools ...tool.Tool) *fakeProvider { + return &fakeProvider{tools: tools} +} + // fakeFullProvider embeds fakeProvider and additionally implements every -// optional interface this package defines (Renderer, Previewer, +// optional plugin-level interface this package defines (Renderer, // ConfigSchemaProvider, SlashCommandProvider, HookPointProvider), each // controlled by its own func field — used by tests exercising the // optional-capability paths. Kept as a distinct type from fakeProvider so // tests can also exercise the "provider does not implement this optional -// interface" fallback paths against a plain *fakeProvider. +// interface" fallback paths against a plain *fakeProvider. Previewer is +// absent by design: it is per-Tool, so it lives on fakePreviewTool. type fakeFullProvider struct { *fakeProvider renderFunc func(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) - previewFunc func(ctx context.Context, call *tool.Call) (*renderv1.RenderTree, error) configSchemaFunc func() (*configv1.ConfigSchema, error) slashCommands []*commonv1.PromptExpansionSpec hookPoints []commonv1.HookPoint @@ -71,11 +128,13 @@ func (f *fakeFullProvider) Render(ctx context.Context, payload []byte, schemaVer return f.renderFunc(ctx, payload, schemaVersion) } -func (f *fakeFullProvider) Preview(ctx context.Context, call *tool.Call) (*renderv1.RenderTree, error) { - return f.previewFunc(ctx, call) -} - +// ConfigSchema tolerates an unset configSchemaFunc because NewService calls +// it during construction — every test building a *fakeFullProvider would +// otherwise have to set a field it does not care about. func (f *fakeFullProvider) ConfigSchema() (*configv1.ConfigSchema, error) { + if f.configSchemaFunc == nil { + return &configv1.ConfigSchema{}, nil + } return f.configSchemaFunc() } @@ -90,7 +149,6 @@ func (f *fakeFullProvider) SupportedHookPoints() []commonv1.HookPoint { var ( _ tool.Provider = (*fakeFullProvider)(nil) _ tool.Renderer = (*fakeFullProvider)(nil) - _ tool.Previewer = (*fakeFullProvider)(nil) _ tool.ConfigSchemaProvider = (*fakeFullProvider)(nil) _ tool.SlashCommandProvider = (*fakeFullProvider)(nil) _ tool.HookPointProvider = (*fakeFullProvider)(nil) @@ -118,7 +176,10 @@ func validSchema(name string) *tool.Schema { func newTestClient(t *testing.T, p tool.Provider) toolv1.ToolServiceClient { t.Helper() - svc := tool.NewService(p, plugin.Identity{Name: "fake-tool", Version: "0.0.1", Source: "local/fake"}, plugin.NewCallback()) + svc, err := tool.NewService(p, plugin.Identity{Name: "fake-tool", Version: "0.0.1", Source: "local/fake"}, plugin.NewCallback()) + if err != nil { + t.Fatalf("tool.NewService: %v", err) + } const bufSize = 1 << 20 lis := bufconn.Listen(bufSize) diff --git a/pkg/tool/server.go b/pkg/tool/server.go index 7e8d689..40d0517 100644 --- a/pkg/tool/server.go +++ b/pkg/tool/server.go @@ -39,12 +39,22 @@ func CallbackFromContext(ctx context.Context) (*plugin.Callback, bool) { // Service adapts a Provider onto the generated toolv1.ToolServiceServer, // implementing plugin.Service so it can be passed to plugin.Config.Services. +// It owns the name-keyed dispatch from an incoming Call to the Tool that +// serves it, so no Provider or Tool implementation switches on +// Call.ToolName itself. type Service struct { toolv1.UnimplementedToolServiceServer identity plugin.Identity callback *plugin.Callback impl Provider + // tools maps Schema.Name to the Tool serving it, built once by + // NewService. + tools map[string]Tool + // schema is the capability advertisement, built once by NewService + // and returned verbatim by every GetSchema. Safe to cache because + // Provider.Tools is static by contract — see its documentation. + schema *toolv1.GetSchemaResponse } var _ plugin.Service = (*Service)(nil) @@ -53,9 +63,34 @@ var _ toolv1.ToolServiceServer = (*Service)(nil) // NewService builds a *Service adapting p onto ToolServiceServer. identity // is this plugin build's own self-reported identity, returned verbatim by // Describe; callback is the lazily-dialed kernel-callback handle attached -// to every context Service passes into p (see ContextWithCallback). -func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) *Service { - return &Service{identity: identity, callback: callback, impl: p} +// to every context Service passes into p and its Tools (see +// ContextWithCallback). +// +// Every one of p's Tools is resolved here, at construction: a nil Tool, an +// unnamed or duplicate Schema.Name, or a Schema that fails validation is an +// error now rather than a malformed advertisement the kernel discovers on +// its first GetSchema, or an ambiguous dispatch it discovers mid-turn. +func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) (*Service, error) { + tools, schema, err := resolveTools(p) + if err != nil { + return nil, fmt.Errorf("tool: new service: %w", err) + } + return &Service{identity: identity, callback: callback, impl: p, tools: tools, schema: schema}, nil +} + +// tool resolves the Tool serving name, or an *Error the caller returns +// straight to the kernel. A call naming an operation this provider does +// not expose never reaches a Tool. +func (s *Service) tool(name string) (Tool, error) { + t, ok := s.tools[name] + if !ok { + return nil, ToStatusError(&Error{ + Category: ErrorCategoryInvalidArguments, + Message: fmt.Sprintf("tool: %q: %v", name, ErrUnknownTool), + Retryable: false, + }) + } + return t, nil } // Register registers ToolService on s, satisfying plugin.Service. @@ -69,13 +104,12 @@ func (s *Service) ctx(base context.Context) context.Context { return ContextWithCallback(base, s.callback) } -// GetSchema implements toolv1.ToolServiceServer. -func (s *Service) GetSchema(ctx context.Context, _ *toolv1.GetSchemaRequest) (*toolv1.GetSchemaResponse, error) { - resp, err := BuildGetSchemaResponse(s.ctx(ctx), s.impl) - if err != nil { - return nil, ToStatusError(&Error{Category: ErrorCategoryUnknown, Message: err.Error(), Retryable: false}) - } - return resp, nil +// GetSchema implements toolv1.ToolServiceServer, returning the +// advertisement NewService built. Cheaply re-queryable and free of any +// network call, per docs/specifications/tool/protocol.md#getschema, because +// there is no work left to do at request time. +func (s *Service) GetSchema(context.Context, *toolv1.GetSchemaRequest) (*toolv1.GetSchemaResponse, error) { + return s.schema, nil } // Configure implements toolv1.ToolServiceServer. @@ -92,22 +126,28 @@ func (s *Service) Configure(ctx context.Context, req *toolv1.ConfigureRequest) ( } // Invoke implements toolv1.ToolServiceServer. Server-streaming: it decodes -// the request's Call, hands it and a *Stream to the wrapped Provider, -// and treats a cancelled context as normal control flow rather than a -// failed RPC, per docs/specifications/tool/README.md#transport--lifecycle. +// the request's Call, resolves the Tool its ToolName names, hands the call +// and a *Stream to that Tool, and treats a cancelled context as normal +// control flow rather than a failed RPC, per +// docs/specifications/tool/README.md#transport--lifecycle. func (s *Service) Invoke(req *toolv1.InvokeRequest, grpcStream toolv1.ToolService_InvokeServer) error { call, err := fromProtoCall(req.GetCall()) if err != nil { return ToStatusError(&Error{Category: ErrorCategoryInvalidArguments, Message: fmt.Sprintf("tool: invoke: %v", err), Retryable: false}) } + t, err := s.tool(call.ToolName) + if err != nil { + return err + } + st := newStream(grpcStream) - invokeErr := s.impl.Invoke(s.ctx(grpcStream.Context()), call, st) + invokeErr := t.Invoke(s.ctx(grpcStream.Context()), call, st) switch { case invokeErr == nil: if !st.closedTerminal() { - return fmt.Errorf("tool: invoke: %s: provider returned without sending a terminal result or error event", call.ToolName) + return fmt.Errorf("tool: invoke: %s: tool returned without sending a terminal result or error event", call.ToolName) } return nil case errors.Is(invokeErr, context.Canceled), status.Code(invokeErr) == codes.Canceled: @@ -134,18 +174,27 @@ func (s *Service) Render(ctx context.Context, req *toolv1.RenderRequest) (*toolv return &toolv1.RenderResponse{Tree: tree}, nil } -// Preview implements toolv1.ToolServiceServer. Returns codes.Unimplemented -// if the wrapped Provider does not additionally implement Previewer, per -// docs/specifications/tool/protocol.md#preview's "MAY be implemented". +// Preview implements toolv1.ToolServiceServer, dispatching on the call's +// ToolName exactly as Invoke does. Returns codes.Unimplemented if the +// addressed Tool does not implement Previewer, per +// docs/specifications/tool/protocol.md#preview's "MAY be implemented" — +// which is per operation, so one Tool previewing and its sibling not is a +// supported, expected shape. func (s *Service) Preview(ctx context.Context, req *toolv1.PreviewRequest) (*toolv1.PreviewResponse, error) { - p, ok := s.impl.(Previewer) - if !ok { - return nil, status.Error(codes.Unimplemented, "tool: preview not implemented by this provider") - } call, err := fromProtoCall(req.GetCall()) if err != nil { return nil, ToStatusError(&Error{Category: ErrorCategoryInvalidArguments, Message: fmt.Sprintf("tool: preview: %v", err), Retryable: false}) } + + t, err := s.tool(call.ToolName) + if err != nil { + return nil, err + } + p, ok := t.(Previewer) + if !ok { + return nil, status.Error(codes.Unimplemented, fmt.Sprintf("tool: preview not implemented by tool %q", call.ToolName)) + } + tree, err := p.Preview(s.ctx(ctx), call) if err != nil { return nil, ToStatusError(&Error{Category: ErrorCategoryUnknown, Message: err.Error(), Retryable: false}) diff --git a/pkg/tool/server_test.go b/pkg/tool/server_test.go index 0637fef..e394fb8 100644 --- a/pkg/tool/server_test.go +++ b/pkg/tool/server_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "slices" "testing" "google.golang.org/grpc/codes" @@ -20,12 +21,7 @@ import ( func TestServiceGetSchema(t *testing.T) { t.Parallel() - p := &fakeProvider{ - schemaFunc: func(context.Context) ([]*tool.Schema, error) { - return []*tool.Schema{validSchema("read_file")}, nil - }, - } - client := newTestClient(t, p) + client := newTestClient(t, newFakeProvider(newFakeTool("read_file"))) resp, err := client.GetSchema(t.Context(), &toolv1.GetSchemaRequest{}) if err != nil { @@ -36,21 +32,87 @@ func TestServiceGetSchema(t *testing.T) { } } -func TestServiceGetSchemaError(t *testing.T) { +func TestServiceGetSchemaListsEveryTool(t *testing.T) { t.Parallel() - p := &fakeProvider{ - schemaFunc: func(context.Context) ([]*tool.Schema, error) { return nil, errors.New("boom") }, - } + p := newFakeProvider(newFakeTool("file_read"), newFakeTool("file_write"), newFakeTool("file_delete")) client := newTestClient(t, p) - _, err := client.GetSchema(t.Context(), &toolv1.GetSchemaRequest{}) - st, ok := status.FromError(err) - if !ok { - t.Fatalf("GetSchema error is not a *status.Status: %v", err) + resp, err := client.GetSchema(t.Context(), &toolv1.GetSchemaRequest{}) + if err != nil { + t.Fatalf("GetSchema: %v", err) } - if st.Code() != codes.Internal { - t.Errorf("code = %v, want %v", st.Code(), codes.Internal) + + got := make([]string, 0, len(resp.GetTools())) + for _, s := range resp.GetTools() { + got = append(got, s.GetName()) + } + want := []string{"file_read", "file_write", "file_delete"} + if !slices.Equal(got, want) { + t.Errorf("tool names = %v, want %v (declaration order preserved)", got, want) + } +} + +// TestNewServiceRejectsMalformedToolSets covers the construction-time +// validation NewService performs: every one of these would otherwise become +// a malformed advertisement the kernel only discovers on its first +// GetSchema, or an ambiguous dispatch it discovers mid-turn. +func TestNewServiceRejectsMalformedToolSets(t *testing.T) { + t.Parallel() + + schemaErr := errors.New("boom") + + tests := []struct { + name string + tools []tool.Tool + wantErr error + }{ + { + name: "nil tool", + tools: []tool.Tool{nil}, + wantErr: tool.ErrNilTool, + }, + { + name: "schema error", + tools: []tool.Tool{&fakeTool{schemaErr: schemaErr}}, + wantErr: schemaErr, + }, + { + name: "nil schema", + tools: []tool.Tool{&fakeTool{}}, + wantErr: tool.ErrNilSchema, + }, + { + name: "empty name", + tools: []tool.Tool{newFakeTool("")}, + wantErr: tool.ErrEmptyName, + }, + { + name: "duplicate name", + tools: []tool.Tool{newFakeTool("file_read"), newFakeTool("file_read")}, + wantErr: tool.ErrDuplicateToolName, + }, + { + name: "invalid schema", + tools: []tool.Tool{&fakeTool{schema: &tool.Schema{ + Name: "file_read", + Kind: tool.KindDataSource, + Risk: tool.RiskClassReadOnly, + Description: "missing both I/O schemas", + }}}, + wantErr: tool.ErrNilInputSchema, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := tool.NewService(newFakeProvider(tt.tools...), plugin.Identity{Name: "fake-tool"}, plugin.NewCallback()) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("NewService() error = %v, want wrapping %v", err, tt.wantErr) + } + }) } } @@ -122,17 +184,15 @@ func TestServiceConfigureGenericErrorDefaultsInvalidArgument(t *testing.T) { func TestServiceInvokeStreamsEvents(t *testing.T) { t.Parallel() - p := &fakeProvider{ - invokeFunc: func(_ context.Context, call *tool.Call, stream *tool.Stream) error { - if call.ToolName != "read_file" { - t.Errorf("call.ToolName = %q, want %q", call.ToolName, "read_file") - } - if err := stream.Send(tool.NewOutputChunkEvent(tool.OutputStreamStdout, []byte("hello"))); err != nil { - return err - } - return stream.Send(tool.NewResultEvent(map[string]any{"ok": true})) - }, - } + p := newFakeProvider(invokingTool("read_file", func(_ context.Context, call *tool.Call, stream *tool.Stream) error { + if call.ToolName != "read_file" { + t.Errorf("call.ToolName = %q, want %q", call.ToolName, "read_file") + } + if err := stream.Send(tool.NewOutputChunkEvent(tool.OutputStreamStdout, []byte("hello"))); err != nil { + return err + } + return stream.Send(tool.NewResultEvent(map[string]any{"ok": true})) + })) client := newTestClient(t, p) args, err := structpb.NewStruct(map[string]any{"path": "a.go"}) @@ -175,16 +235,13 @@ func TestServiceInvokeStreamsEvents(t *testing.T) { func TestServiceInvokeCancellationIsNotSurfacedAsError(t *testing.T) { t.Parallel() - p := &fakeProvider{ - invokeFunc: func(context.Context, *tool.Call, *tool.Stream) error { - // Simulate a Provider that detects cancellation itself and - // returns context.Canceled rather than sending a terminal - // event — README.md#transport--lifecycle: cancellation is - // normal control flow, never surfaced as an application - // error. - return context.Canceled - }, - } + p := newFakeProvider(invokingTool("op", func(context.Context, *tool.Call, *tool.Stream) error { + // Simulate a Tool that detects cancellation itself and returns + // context.Canceled rather than sending a terminal event — + // README.md#transport--lifecycle: cancellation is normal control + // flow, never surfaced as an application error. + return context.Canceled + })) client := newTestClient(t, p) stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) @@ -200,11 +257,9 @@ func TestServiceInvokeCancellationIsNotSurfacedAsError(t *testing.T) { func TestServiceInvokeGenericErrorMapsToUnknown(t *testing.T) { t.Parallel() - p := &fakeProvider{ - invokeFunc: func(context.Context, *tool.Call, *tool.Stream) error { - return errors.New("provider panic recovered") - }, - } + p := newFakeProvider(invokingTool("op", func(context.Context, *tool.Call, *tool.Stream) error { + return errors.New("tool panic recovered") + })) client := newTestClient(t, p) stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) @@ -224,7 +279,7 @@ func TestServiceInvokeGenericErrorMapsToUnknown(t *testing.T) { func TestServiceInvokeInvalidCallRejected(t *testing.T) { t.Parallel() - client := newTestClient(t, &fakeProvider{}) + client := newTestClient(t, newFakeProvider(newFakeTool("op"))) stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: nil}) if err != nil { @@ -239,9 +294,7 @@ func TestServiceInvokeInvalidCallRejected(t *testing.T) { func TestServiceInvokeWithoutTerminalEventFails(t *testing.T) { t.Parallel() - p := &fakeProvider{ - invokeFunc: func(context.Context, *tool.Call, *tool.Stream) error { return nil }, - } + p := newFakeProvider(invokingTool("op", func(context.Context, *tool.Call, *tool.Stream) error { return nil })) client := newTestClient(t, p) stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) @@ -257,15 +310,13 @@ func TestServiceInvokeWithoutTerminalEventFails(t *testing.T) { func TestServiceInvokeErrorEventTerminates(t *testing.T) { t.Parallel() - p := &fakeProvider{ - invokeFunc: func(_ context.Context, _ *tool.Call, stream *tool.Stream) error { - te, err := tool.NewError(tool.ErrorCategoryNotFound, "no such file", false, nil) - if err != nil { - return err - } - return stream.Send(tool.NewErrorEvent(te)) - }, - } + p := newFakeProvider(invokingTool("op", func(_ context.Context, _ *tool.Call, stream *tool.Stream) error { + te, err := tool.NewError(tool.ErrorCategoryNotFound, "no such file", false, nil) + if err != nil { + return err + } + return stream.Send(tool.NewErrorEvent(te)) + })) client := newTestClient(t, p) stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) @@ -287,7 +338,7 @@ func TestServiceInvokeErrorEventTerminates(t *testing.T) { func TestServiceDescribe(t *testing.T) { t.Parallel() - client := newTestClient(t, &fakeProvider{}) + client := newTestClient(t, newFakeProvider()) resp, err := client.Describe(t.Context(), &toolv1.DescribeRequest{}) if err != nil { @@ -305,7 +356,7 @@ func TestServiceDescribe(t *testing.T) { func TestServiceRenderUnimplementedWithoutRenderer(t *testing.T) { t.Parallel() - client := newTestClient(t, &fakeProvider{}) + client := newTestClient(t, newFakeProvider()) _, err := client.Render(t.Context(), &toolv1.RenderRequest{}) if status.Code(err) != codes.Unimplemented { @@ -313,43 +364,114 @@ func TestServiceRenderUnimplementedWithoutRenderer(t *testing.T) { } } -func TestServicePreviewUnimplementedWithoutPreviewer(t *testing.T) { +// TestServicePreviewIsPerTool exercises protocol.md#preview's MAY at the +// granularity it is actually written: one operation previewing while its +// sibling in the same plugin does not. +func TestServicePreviewIsPerTool(t *testing.T) { t.Parallel() - client := newTestClient(t, &fakeProvider{}) + previewed := previewingTool("edit_file", func(_ context.Context, call *tool.Call) (*renderv1.RenderTree, error) { + if call.ToolName != "edit_file" { + t.Errorf("Preview call.ToolName = %q, want %q", call.ToolName, "edit_file") + } + return &renderv1.RenderTree{Root: &renderv1.RenderNode{}}, nil + }) + client := newTestClient(t, newFakeProvider(previewed, newFakeTool("read_file"))) + + if _, err := client.Preview(t.Context(), &toolv1.PreviewRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "edit_file"}}); err != nil { + t.Fatalf("Preview(edit_file): %v", err) + } - _, err := client.Preview(t.Context(), &toolv1.PreviewRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) + _, err := client.Preview(t.Context(), &toolv1.PreviewRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "read_file"}}) if status.Code(err) != codes.Unimplemented { - t.Fatalf("Preview() code = %v, want %v", status.Code(err), codes.Unimplemented) + t.Fatalf("Preview(read_file) code = %v, want %v (that tool does not implement Previewer)", status.Code(err), codes.Unimplemented) } } -func TestServiceRenderAndPreview(t *testing.T) { +func TestServiceRender(t *testing.T) { t.Parallel() - base := &fakeProvider{} p := &fakeFullProvider{ - fakeProvider: base, + fakeProvider: newFakeProvider(newFakeTool("edit_file")), renderFunc: func(_ context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) { if string(payload) != "raw" || schemaVersion != "v1" { t.Errorf("Render(%q, %q)", payload, schemaVersion) } return &renderv1.RenderTree{Root: &renderv1.RenderNode{}}, nil }, - previewFunc: func(_ context.Context, call *tool.Call) (*renderv1.RenderTree, error) { - if call.ToolName != "edit_file" { - t.Errorf("Preview call.ToolName = %q, want %q", call.ToolName, "edit_file") - } - return &renderv1.RenderTree{Root: &renderv1.RenderNode{}}, nil - }, } client := newTestClient(t, p) if _, err := client.Render(t.Context(), &toolv1.RenderRequest{Payload: []byte("raw"), SchemaVersion: "v1"}); err != nil { t.Fatalf("Render: %v", err) } - if _, err := client.Preview(t.Context(), &toolv1.PreviewRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "edit_file"}}); err != nil { - t.Fatalf("Preview: %v", err) +} + +// TestServiceDispatchesToNamedTool is the whole point of the Tool-shaped +// SDK: three tools in one provider, each reached by name, with none of them +// switching on Call.ToolName themselves. +func TestServiceDispatchesToNamedTool(t *testing.T) { + t.Parallel() + + invoked := make(chan string, 3) + record := func(name string) *fakeTool { + return invokingTool(name, func(_ context.Context, _ *tool.Call, stream *tool.Stream) error { + invoked <- name + return stream.Send(tool.NewResultEvent(map[string]any{"tool": name})) + }) + } + client := newTestClient(t, newFakeProvider(record("file_read"), record("file_write"), record("file_delete"))) + + for _, want := range []string{"file_delete", "file_read", "file_write"} { + t.Run(want, func(t *testing.T) { + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: want}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + resp, err := stream.Recv() + if err != nil { + t.Fatalf("stream.Recv: %v", err) + } + if got := resp.GetEvent().GetResult().GetPayload().AsMap()["tool"]; got != want { + t.Errorf("result payload tool = %v, want %q", got, want) + } + if got := <-invoked; got != want { + t.Errorf("Invoke reached tool %q, want %q", got, want) + } + }) + } +} + +func TestServiceInvokeUnknownToolRejected(t *testing.T) { + t.Parallel() + + reached := false + p := newFakeProvider(invokingTool("file_read", func(_ context.Context, _ *tool.Call, stream *tool.Stream) error { + reached = true + return stream.Send(tool.NewResultEvent(nil)) + })) + client := newTestClient(t, p) + + stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "file_teleport"}}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + if _, err = stream.Recv(); status.Code(err) != codes.InvalidArgument { + t.Fatalf("stream.Recv() code = %v, want %v", status.Code(err), codes.InvalidArgument) + } + if reached { + t.Error("a call naming an unknown tool reached a Tool implementation") + } +} + +func TestServicePreviewUnknownToolRejected(t *testing.T) { + t.Parallel() + + client := newTestClient(t, newFakeProvider(newFakeTool("file_read"))) + + _, err := client.Preview(t.Context(), &toolv1.PreviewRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "file_teleport"}}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("Preview() code = %v, want %v", status.Code(err), codes.InvalidArgument) } } @@ -373,12 +495,10 @@ func TestServiceInvokeSeesCallback(t *testing.T) { t.Parallel() var sawCallback bool - p := &fakeProvider{ - invokeFunc: func(ctx context.Context, _ *tool.Call, stream *tool.Stream) error { - _, sawCallback = tool.CallbackFromContext(ctx) - return stream.Send(tool.NewResultEvent(nil)) - }, - } + p := newFakeProvider(invokingTool("op", func(ctx context.Context, _ *tool.Call, stream *tool.Stream) error { + _, sawCallback = tool.CallbackFromContext(ctx) + return stream.Send(tool.NewResultEvent(nil)) + })) client := newTestClient(t, p) stream, err := client.Invoke(t.Context(), &toolv1.InvokeRequest{Call: &toolv1.ToolCall{Id: "c", ToolName: "op"}}) diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index b2232fb..c504ef1 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -310,13 +310,59 @@ func NewErrorEvent(err *Error) *Event { return &Event{Error: err} } -// Provider is the interface a tool plugin author implements; NewService -// adapts it onto the generated toolv1.ToolServiceServer. +// Tool is one operation a plugin exposes — the unit a plugin author +// implements. A single Provider serves as many Tools as it likes (a +// filesystem plugin's read, write, and delete are three Tools in one +// process), which is exactly what the wire contract describes: +// GetSchemaResponse.tools is repeated, and every ToolCall names which of +// them to run. Service builds a name-keyed dispatch table from +// Provider.Tools and routes each Invoke to the right one, so no +// implementation ever switches on Call.ToolName itself. +type Tool interface { + // Schema declares this operation, per + // docs/specifications/tool/protocol.md#getschema: its name, kind, + // risk, description, and input/output schemas. Deliberately takes no + // context — the spec's "MUST NOT require a network call" is a + // structural property of a declaration, not a promise to keep by + // hand. The error return is for the fallible pkg/schema builders + // alone, so a Tool need not have a fallible constructor. + Schema() (*Schema, error) + // Invoke executes call, sending zero or more non-terminal events and + // exactly one terminal event (built with NewResultEvent or + // NewErrorEvent) via stream before returning. Returning a nil error + // without having sent a terminal event is a Tool bug the adapter + // surfaces as a failed RPC. See stream.go for the full contract. + Invoke(ctx context.Context, call *Call, stream *Stream) error +} + +// Previewer is an optional interface a Tool MAY additionally implement to +// describe, without executing, what Invoke(call) would do, per +// docs/specifications/tool/protocol.md#preview. Producing a preview MUST +// NOT mutate anything and MUST be side-effect-free; a Tool unable to +// satisfy that MUST NOT implement Previewer. Implemented per Tool rather +// than per Provider because PreviewRequest carries a full ToolCall, so +// Service can dispatch on Call.ToolName exactly as it does for Invoke. If +// the addressed Tool does not implement Previewer, a kernel falls back to +// showing the call's raw arguments in the plan/apply gate's permission UI. +type Previewer interface { + Preview(ctx context.Context, call *Call) (*renderv1.RenderTree, error) +} + +// Provider is the plugin-level interface a tool plugin author implements: +// the set of Tools this process serves, plus the configuration they share. +// NewService adapts it onto the generated toolv1.ToolServiceServer. type Provider interface { - // Schema returns the Schema for every operation this plugin - // exposes, per docs/specifications/tool/protocol.md#getschema. MUST - // be cheaply re-queryable and MUST NOT make a network call. - Schema(ctx context.Context) ([]*Schema, error) + // Tools returns every operation this plugin exposes, per + // docs/specifications/tool/protocol.md#getschema. Deliberately + // static — no context, no error — because the tool set is fixed + // before Configure ever runs: internal/pluginhost brings a plugin up + // as Describe, GetSchema, decode config, then Configure, so the + // kernel has already learned and cached this set by the time a + // provider knows its own configuration. Config-dependent *behavior* + // belongs inside a Tool's Invoke, reading provider state Configure + // set; a config-dependent tool *set* is not expressible and would + // misreport this plugin's capabilities to the kernel. + Tools() []Tool // Configure decodes and validates this provider's agent.hcl block, // already decoded from JSON into config. MUST reject with an error // on a missing required field rather than deferring failure to the @@ -324,12 +370,6 @@ type Provider interface { // category/message; any other error defaults to // ErrorCategoryInvalidArguments. Configure(ctx context.Context, config map[string]any) error - // Invoke executes call, sending zero or more non-terminal events and - // exactly one terminal event (built with NewResultEvent or - // NewErrorEvent) via stream before returning. Returning a nil error - // without having sent a terminal event is a Provider bug the adapter - // surfaces as a failed RPC. See stream.go for the full contract. - Invoke(ctx context.Context, call *Call, stream *Stream) error } // Renderer is an optional interface a Provider MAY additionally implement @@ -337,21 +377,17 @@ type Provider interface { // docs/specifications/tool/protocol.md#render. If a Provider does not // implement Renderer, the kernel falls back to its generic default // (pretty-printed JSON payload). +// +// Deliberately plugin-level, unlike Previewer: RenderRequest carries only +// the opaque payload and its schema version, with no tool name for Service +// to dispatch on, so a per-Tool Render is not implementable without a wire +// change. A multi-Tool plugin discriminates on its own payload — legitimate, +// since that payload's format is the plugin's alone (grpc.md's +// Emit->Render->Paint carve-out) and SchemaVersion already versions it. type Renderer interface { Render(ctx context.Context, payload []byte, schemaVersion string) (*renderv1.RenderTree, error) } -// Previewer is an optional interface a Provider MAY additionally implement -// to describe, without executing, what Invoke(call) would do, per -// docs/specifications/tool/protocol.md#preview. Producing a preview MUST -// NOT mutate anything and MUST be side-effect-free; a Provider unable to -// satisfy that for a given operation MUST NOT implement Previewer for it. -// If a Provider does not implement Previewer, a kernel falls back to -// showing the call's raw arguments in the plan/apply gate's permission UI. -type Previewer interface { - Preview(ctx context.Context, call *Call) (*renderv1.RenderTree, error) -} - // ConfigSchemaProvider is an optional interface a Provider MAY implement // to advertise the ConfigSchema (built with pkg/config) the kernel decodes // its agent.hcl provider block against before ever calling Configure. A diff --git a/pkg/widget/capabilities.go b/pkg/widget/capabilities.go index ca729f6..0bdae1b 100644 --- a/pkg/widget/capabilities.go +++ b/pkg/widget/capabilities.go @@ -3,20 +3,34 @@ package widget import ( commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" ) -// NewCapabilities builds a Capabilities from a config schema built with -// pkg/config (Schema and Attribute), the regions this widget intends to -// contribute to, and the hook points it can subscribe to in observe mode. -// It is a plain constructor, not a validating one — pkg/config.Schema -// already validates configSchema's own invariants before a caller has one -// to pass here, and Regions/SupportedHookPoints are enum lists with no -// further invariant of their own to check. -func NewCapabilities(configSchema *configv1.ConfigSchema, regions []renderv1.Region, hookPoints ...commonv1.HookPoint) Capabilities { - return Capabilities{ - Regions: regions, - ConfigSchema: configSchema, - SupportedHookPoints: hookPoints, +// CapabilitiesOption configures one optional field of a Capabilities +// built by NewCapabilities. +type CapabilitiesOption func(*Capabilities) + +// WithSupportedHookPoints sets the hook points this widget can +// subscribe to. +func WithSupportedHookPoints(points ...commonv1.HookPoint) CapabilitiesOption { + return func(c *Capabilities) { c.SupportedHookPoints = points } +} + +// NewCapabilities builds a Capabilities from schema plus any options. +// schema MAY be nil for a provider with no configuration surface. +func NewCapabilities(schema *configv1.ConfigSchema, opts ...CapabilitiesOption) Capabilities { + c := Capabilities{ConfigSchema: schema} + for _, opt := range opts { + opt(&c) + } + return c +} + +// toProtoCapabilities converts c into the wire type GetCapabilities +// returns. +func toProtoCapabilities(c Capabilities) *widgetv1.WidgetCapabilities { + return &widgetv1.WidgetCapabilities{ + ConfigSchema: c.ConfigSchema, + SupportedHookPoints: c.SupportedHookPoints, } } diff --git a/pkg/widget/capabilities_test.go b/pkg/widget/capabilities_test.go index fa2e06f..47aa547 100644 --- a/pkg/widget/capabilities_test.go +++ b/pkg/widget/capabilities_test.go @@ -6,7 +6,6 @@ import ( commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" "github.com/pluggableharness/agent/pkg/config" configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" "github.com/pluggableharness/agent/pkg/widget" ) @@ -21,16 +20,15 @@ func TestNewCapabilities(t *testing.T) { if err != nil { t.Fatalf("config.Schema: %v", err) } - regions := []renderv1.Region{renderv1.Region_REGION_SIDEBAR, renderv1.Region_REGION_TOP_BAR} - got := widget.NewCapabilities(schema, regions, commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL, commonv1.HookPoint_HOOK_POINT_SESSION_START) + got := widget.NewCapabilities(schema, widget.WithSupportedHookPoints( + commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL, + commonv1.HookPoint_HOOK_POINT_SESSION_START, + )) if got.ConfigSchema != schema { t.Errorf("NewCapabilities().ConfigSchema = %p, want %p", got.ConfigSchema, schema) } - if len(got.Regions) != 2 || got.Regions[0] != renderv1.Region_REGION_SIDEBAR || got.Regions[1] != renderv1.Region_REGION_TOP_BAR { - t.Errorf("NewCapabilities().Regions = %v, want %v", got.Regions, regions) - } wantHooks := []commonv1.HookPoint{commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL, commonv1.HookPoint_HOOK_POINT_SESSION_START} if len(got.SupportedHookPoints) != 2 || got.SupportedHookPoints[0] != wantHooks[0] || got.SupportedHookPoints[1] != wantHooks[1] { t.Errorf("NewCapabilities().SupportedHookPoints = %v, want %v", got.SupportedHookPoints, wantHooks) @@ -40,7 +38,7 @@ func TestNewCapabilities(t *testing.T) { func TestNewCapabilities_noHookPoints(t *testing.T) { t.Parallel() - got := widget.NewCapabilities(nil, []renderv1.Region{renderv1.Region_REGION_OVERLAY}) + got := widget.NewCapabilities(nil) if got.SupportedHookPoints != nil { t.Errorf("NewCapabilities().SupportedHookPoints = %v, want nil", got.SupportedHookPoints) diff --git a/pkg/widget/convert.go b/pkg/widget/convert.go deleted file mode 100644 index cbf9fd0..0000000 --- a/pkg/widget/convert.go +++ /dev/null @@ -1,32 +0,0 @@ -package widget - -import ( - widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" -) - -// toProtoCapabilities converts a domain Capabilities to its wire -// representation, for Service.GetCapabilities' response. -func toProtoCapabilities(caps Capabilities) *widgetv1.WidgetCapabilities { - return &widgetv1.WidgetCapabilities{ - Regions: caps.Regions, - ConfigSchema: caps.ConfigSchema, - SupportedHookPoints: caps.SupportedHookPoints, - } -} - -// fromProtoAttachRequest converts a wire AttachRequest to its domain -// representation, for handing to Provider.Attach. -func fromProtoAttachRequest(req *widgetv1.AttachRequest) AttachRequest { - return AttachRequest{SessionID: req.GetSessionId()} -} - -// toProtoUpdate converts a domain Update to its wire representation, -// translating Mode to the wire's bare Replace bool (UpdateReplace -> true, -// UpdateAppend -> false) for UpdateSender.Send. -func toProtoUpdate(u Update) *widgetv1.WidgetUpdate { - return &widgetv1.WidgetUpdate{ - Region: u.Region, - Content: u.Content, - Replace: u.Mode == UpdateReplace, - } -} diff --git a/pkg/widget/doc.go b/pkg/widget/doc.go index f0d3224..6505eb7 100644 --- a/pkg/widget/doc.go +++ b/pkg/widget/doc.go @@ -1,87 +1,14 @@ -// Package widget is the plugin-author-facing SDK for the widget provider -// category described in docs/specifications/frontend/widget-protocol.md — -// a plugin that contributes content into whichever frontend is attached, -// without owning the terminal/window/voice channel itself (a git-status -// panel, a context-budget indicator — content that isn't naturally "a -// tool" or "a context provider," it just wants to put something on -// screen). Despite living under docs/specifications/frontend/ alongside -// the frontend provider protocol, the widget protocol is generated as its -// own pkg/widget/proto/v1 package, distinct from pkg/frontend. -// -// # The Attach name collision — read this before touching Attach -// -// This package's Attach RPC shares its name with pkg/frontend's Attach -// but has a genuinely different shape, and conflating the two is the -// single easiest mistake to make when building on this SDK -// (docs/specifications/frontend/widget-protocol.md#transport): -// -// - frontend Attach is BIDIRECTIONAL and CONNECTION-scoped: one stream -// multiplexes every session a frontend has subscribed to, correlated -// by session_id on each message it carries. -// - widget Attach — this package's Attach — is SERVER-STREAMING ONLY -// and SESSION-scoped: one call per session, identified once by -// AttachRequest.SessionID, never multiplexed across sessions on a -// single connection. A widget instance serving three attached -// sessions gets three separate Attach calls, not one call carrying -// three sessions' worth of updates. -// -// Do not port a connection-multiplexing design onto this Attach — it is -// structurally simpler than the frontend protocol's Attach, not a variant -// of it. Service.Attach (server.go) and UpdateSender (stream.go) are -// built around exactly one call, one session, one Provider.Attach -// invocation. -// -// # Package shape -// -// Provider (widget.go) is the interface a widget plugin author -// implements: GetCapabilities, Configure, and Attach. NewService -// (server.go) adapts a Provider to widgetv1.WidgetServiceServer for -// registration via plugin.Config.Services (pkg/plugin); Describe is -// implemented directly from the plugin.Identity passed to NewService, -// per widget-protocol.md#transport's dev_overrides-identity discussion, -// rather than delegated to the Provider — every category protocol gains -// this identical RPC specifically so a dev_overrides-resolved binary, -// which has no provider {} lock-file entry, can still report its own -// identity. -// -// # Passive in v1 — no action-triggering mechanism here -// -// Widgets are display-only in this protocol revision -// (docs/specifications/frontend/widget-protocol.md#interactive-widgets). -// A WidgetUpdate's RenderTree MAY include an ActionNode the same way any -// other producer's render tree can, but a widget wanting to trigger -// something does so by also implementing SlashCommandService in the same -// plugin process (pkg/slashcommand, a sibling package — not built here). -// This package deliberately has no action-dispatch or action-receiving -// machinery of its own; don't invent one. -// -// # Deriving display state -// -// A widget gets no special session-state API. It derives whatever it -// wants to display by subscribing to hook points in observe mode (rides -// HookSubscriberService, pkg/hook — a sibling package) and pushes the -// result out through Attach's WidgetUpdate stream -// (docs/specifications/frontend/widget-protocol.md#deriving-display-state--no-new-data-feed). -// Attach's stream is purely one-directional: it never receives anything -// back — there is no frontend-protocol-style ClientEvent equivalent here, -// and this package has no receive-side code to match one. -// -// # Errors -// -// Widget Attach has no in-band error channel at all — unlike pkg/frontend's -// Attach, which can report a recoverable error in-band via -// ServerEvent.error, this Attach is server-streaming only with no return -// channel besides the stream itself. Error (errors.go) is therefore -// always carried in the structured detail of a gRPC status returned from -// Configure or Attach, never as an in-band WidgetUpdate field — see -// docs/specifications/frontend/widget-protocol.md#error-taxonomy and -// docs/specifications/frontend/conformance.md#error-taxonomy. +// Package widget implements the hand-written, plugin-author-facing Go SDK +// for the widget provider category — plugins that contribute typed +// metadata (or other side work) without owning the frontend +// (docs/specifications/frontend/widget-protocol.md). +// +// WidgetService exposes GetCapabilities, Configure, and Describe only. +// There is no Attach stream. Screen presence is KernelCallbackService. +// PublishMetadata on the callback channel, the same path any tool +// provider uses for a status block. package widget -// ProtocolVersion is the version of the widget category's own protocol this -// SDK implements — the "v1" in pluggableharness.widget.v1. -// -// Deliberately NOT pkg/common.ProtocolVersion, which versions the -// go-plugin runtime contract shared by every category. The two move -// independently: see that constant's documentation for why. +// ProtocolVersion is the version of the widget category's own protocol +// this SDK implements — the "v1" in pluggableharness.widget.v1. const ProtocolVersion uint32 = 1 diff --git a/pkg/widget/errors.go b/pkg/widget/errors.go index 1db20b8..af96265 100644 --- a/pkg/widget/errors.go +++ b/pkg/widget/errors.go @@ -12,37 +12,22 @@ import ( ) // errorDomain is the google.rpc.ErrorInfo domain every Error-backed gRPC -// status carries, per plugin.StatusError's own "domain should be the -// calling category's own error-taxonomy name" convention. +// status carries, per plugin.StatusError's convention. const errorDomain = "widget.pluggableharness.dev" -// Reason strings match -// docs/specifications/frontend/widget-protocol.md#error-taxonomy's -// category names exactly, so a caller comparing against the spec's own -// vocabulary doesn't have to translate an enum name. const ( - reasonRenderFailed = "render_failed" - reasonRegionUnsupported = "region_unsupported" - reasonUnknown = "unknown" + reasonRenderFailed = "render_failed" + reasonUnknown = "unknown" ) // metadataCategoryKey names the google.rpc.ErrorInfo metadata entry -// carrying WidgetErrorCategory's exact wire enum name (e.g. -// "WIDGET_ERROR_CATEGORY_RENDER_FAILED"), alongside the coarser reason -// string, so FromStatus can recover the precise category instead of -// re-deriving it from reason. +// carrying WidgetErrorCategory's exact wire enum name. const metadataCategoryKey = "category" // Error is the widget category's structured error type — the domain-side -// representation of the wire WidgetError message -// (docs/specifications/frontend/widget-protocol.md#error-taxonomy). -// Unlike the frontend category's FrontendError, Error has no in-band wire -// representation on this package's server-streaming-only Attach — a -// Service always carries it in the structured detail of a gRPC status -// returned from Configure or Attach (see toStatus), never as an Update -// field. Construct one with RenderFailed, RegionUnsupported, or Unknown, -// or return any other error from a Provider method and let Service map it -// to WIDGET_ERROR_CATEGORY_UNKNOWN automatically. +// representation of the wire WidgetError message. A Service always carries +// it in the structured detail of a gRPC status returned from Configure, +// never as an in-band stream message. type Error struct { // Category classifies this error. Category widgetv1.WidgetErrorCategory @@ -55,82 +40,46 @@ func (e *Error) Error() string { return fmt.Sprintf("widget: %s: %s", e.Category, e.Message) } -// RenderFailed builds an Error reporting that a RenderTree or Update -// could not be produced. Maps to codes.Internal — a render-time failure -// isn't the caller's fault, and there is no more specific code for "this -// widget's rendering logic broke." +// RenderFailed builds an Error reporting that a render or metadata +// contribution could not be produced. Maps to codes.Internal. func RenderFailed(message string) *Error { return &Error{Category: widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED, Message: message} } -// RegionUnsupported builds an Error reporting that this widget was asked -// to produce a render it structurally can't — including a -// partial-failure update that renders for one Region but not another -// (docs/specifications/frontend/widget-protocol.md#error-taxonomy). Maps -// to codes.InvalidArgument, per widget-protocol.md#error-taxonomy's -// explicit "codes.InvalidArgument for ... a render this widget can't -// produce." -func RegionUnsupported(message string) *Error { - return &Error{Category: widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED, Message: message} -} - // Unknown builds an Error for a failure that fits none of the other -// categories. Maps to codes.Internal, never codes.Unknown — the same -// "most specific code" discipline .claude/rules/grpc.md requires -// everywhere else in this protocol series. Service.toGRPCStatus also -// falls back to this constructor automatically for any Provider error -// that isn't already an *Error. +// categories. Maps to codes.Internal, never codes.Unknown. func Unknown(message string) *Error { return &Error{Category: widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNKNOWN, Message: message} } -// grpcCode returns the codes.Code e maps to, per -// docs/specifications/frontend/widget-protocol.md#error-taxonomy. -func (e *Error) grpcCode() codes.Code { - switch e.Category { - case widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED: - return codes.Internal - case widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED: - return codes.InvalidArgument - default: - // WIDGET_ERROR_CATEGORY_UNKNOWN, and WIDGET_ERROR_CATEGORY_UNSPECIFIED - // (a hand-built Error that skipped RenderFailed/ - // RegionUnsupported/Unknown), both fall through to codes.Internal. - return codes.Internal - } -} +// widgetGRPCCode is the codes.Code every currently-defined widget error +// category maps to. Both RENDER_FAILED and UNKNOWN are Internal per +// conformance.md's canonical table — never codes.Unknown. This is a +// constant rather than a switch precisely because a switch whose arms all +// return the same value reads as a mapping that exists when it does not; if +// a future category maps elsewhere (InvalidArgument, Canceled), reintroduce +// the switch then, with arms that actually differ. +const widgetGRPCCode = codes.Internal -// reason returns e.Category's spec-vocabulary reason string. +// reason returns e.Category's reason string. func (e *Error) reason() string { switch e.Category { case widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED: return reasonRenderFailed - case widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED: - return reasonRegionUnsupported default: return reasonUnknown } } -// toStatus builds the gRPC status a Service returns for e, per -// docs/specifications/frontend/widget-protocol.md#error-taxonomy: e is -// always carried in the structured detail of a gRPC status returned from -// Configure or Attach, never as an in-band Update field. +// toStatus builds the gRPC status a Service returns for e. func (e *Error) toStatus() error { - return plugin.StatusError(e.grpcCode(), errorDomain, e.reason(), e.Message, map[string]string{ + return plugin.StatusError(widgetGRPCCode, errorDomain, e.reason(), e.Message, map[string]string{ metadataCategoryKey: e.Category.String(), }) } // FromStatus recovers an *Error from err if err is a gRPC status carrying -// this package's ErrorInfo detail (i.e. one built by toStatus, reached by -// a Provider's Configure or Attach method returning an error) — the -// Error-aware counterpart to errors.As, for a caller that received this -// error across the plugin boundary rather than constructed it locally. ok -// is false for any other error, including a plain codes.Canceled -// cancellation status, which -// docs/specifications/frontend/widget-protocol.md#error-taxonomy treats -// as never an application error in the first place. +// this package's ErrorInfo detail. func FromStatus(err error) (*Error, bool) { st, ok := status.FromError(err) if !ok { diff --git a/pkg/widget/errors_test.go b/pkg/widget/errors_test.go index 472cc59..16b21f9 100644 --- a/pkg/widget/errors_test.go +++ b/pkg/widget/errors_test.go @@ -16,7 +16,6 @@ func TestError_Error(t *testing.T) { want string }{ {name: "render failed", err: widget.RenderFailed("bad node"), want: "widget: WIDGET_ERROR_CATEGORY_RENDER_FAILED: bad node"}, - {name: "region unsupported", err: widget.RegionUnsupported("no sidebar"), want: "widget: WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED: no sidebar"}, {name: "unknown", err: widget.Unknown("boom"), want: "widget: WIDGET_ERROR_CATEGORY_UNKNOWN: boom"}, } @@ -43,9 +42,6 @@ func TestFromStatus_notAStatusError(t *testing.T) { func TestFromStatus_nilError(t *testing.T) { t.Parallel() - // status.FromError(nil) reports ok=true with a nil/OK status carrying - // no ErrorInfo detail, so FromStatus must still report ok=false here — - // there is no WidgetError to recover from success. _, ok := widget.FromStatus(nil) if ok { t.Error("FromStatus(nil) ok = true, want false") diff --git a/pkg/widget/fake_test.go b/pkg/widget/fake_test.go index fb52a11..d09e5e4 100644 --- a/pkg/widget/fake_test.go +++ b/pkg/widget/fake_test.go @@ -8,14 +8,10 @@ import ( "github.com/pluggableharness/agent/pkg/widget" ) -// fakeProvider is a hand-written widget.Provider fake (go-testing.md: -// fakes, not mocking frameworks). Each method's behavior is controlled by -// a caller-set func field; a nil field returns a zero value and a nil -// error, which is enough for tests that only exercise one method. +// fakeProvider is a hand-written widget.Provider fake. type fakeProvider struct { getCapabilitiesFunc func(ctx context.Context) (widget.Capabilities, error) configureFunc func(ctx context.Context, config *structpb.Struct) error - attachFunc func(ctx context.Context, req widget.AttachRequest, sender *widget.UpdateSender) error } func (f *fakeProvider) GetCapabilities(ctx context.Context) (widget.Capabilities, error) { @@ -32,11 +28,4 @@ func (f *fakeProvider) Configure(ctx context.Context, config *structpb.Struct) e return nil } -func (f *fakeProvider) Attach(ctx context.Context, req widget.AttachRequest, sender *widget.UpdateSender) error { - if f.attachFunc != nil { - return f.attachFunc(ctx, req, sender) - } - return nil -} - var _ widget.Provider = (*fakeProvider)(nil) diff --git a/pkg/widget/proto/v1/errors.pb.go b/pkg/widget/proto/v1/errors.pb.go index 95ea7ac..d5df9b4 100644 --- a/pkg/widget/proto/v1/errors.pb.go +++ b/pkg/widget/proto/v1/errors.pb.go @@ -21,20 +21,15 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// WidgetErrorCategory classifies a WidgetError, mirroring -// FrontendErrorCategory's shape (frontend/v1/errors.proto) for the widget category — -// resolves frontend/conformance.md's prior open question of whether -// widgets need a structured error type of their own. +// WidgetErrorCategory classifies a WidgetError. type WidgetErrorCategory int32 const ( // Zero value. Never valid for a real error; its presence on the wire // means a caller forgot to set the field. WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNSPECIFIED WidgetErrorCategory = 0 - // A RenderTree or WidgetUpdate could not be displayed. + // A render or metadata contribution could not be produced. WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED WidgetErrorCategory = 1 - // A WidgetUpdate named a Region this widget's frontend cannot honor. - WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED WidgetErrorCategory = 2 // An error that does not fit any other category. WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNKNOWN WidgetErrorCategory = 3 ) @@ -44,14 +39,12 @@ var ( WidgetErrorCategory_name = map[int32]string{ 0: "WIDGET_ERROR_CATEGORY_UNSPECIFIED", 1: "WIDGET_ERROR_CATEGORY_RENDER_FAILED", - 2: "WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED", 3: "WIDGET_ERROR_CATEGORY_UNKNOWN", } WidgetErrorCategory_value = map[string]int32{ - "WIDGET_ERROR_CATEGORY_UNSPECIFIED": 0, - "WIDGET_ERROR_CATEGORY_RENDER_FAILED": 1, - "WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED": 2, - "WIDGET_ERROR_CATEGORY_UNKNOWN": 3, + "WIDGET_ERROR_CATEGORY_UNSPECIFIED": 0, + "WIDGET_ERROR_CATEGORY_RENDER_FAILED": 1, + "WIDGET_ERROR_CATEGORY_UNKNOWN": 3, } ) @@ -82,13 +75,9 @@ func (WidgetErrorCategory) EnumDescriptor() ([]byte, []int) { return file_pluggableharness_widget_v1_errors_proto_rawDescGZIP(), []int{0} } -// WidgetError is the structured error type for the widget category, -// mirroring FrontendError (frontend/v1/errors.proto). Unlike the frontend category -// (whose Attach errors surface in-band via ServerEvent.Error), widget -// Attach has no return channel other than the stream itself — WidgetError -// is carried in the structured detail of a gRPC status on Configure or -// Attach, per .claude/rules/grpc.md's error-taxonomy discipline, not as an -// in-band stream message. +// WidgetError is the structured error type for the widget category. +// Carried in the structured detail of a gRPC status on Configure (or +// any future unary), per .claude/rules/grpc.md. type WidgetError struct { state protoimpl.MessageState `protogen:"open.v1"` // The error's category. @@ -150,12 +139,11 @@ const file_pluggableharness_widget_v1_errors_proto_rawDesc = "" + "'pluggableharness/widget/v1/errors.proto\x12\x1apluggableharness.widget.v1\"t\n" + "\vWidgetError\x12K\n" + "\bcategory\x18\x01 \x01(\x0e2/.pluggableharness.widget.v1.WidgetErrorCategoryR\bcategory\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage*\xb6\x01\n" + + "\amessage\x18\x02 \x01(\tR\amessage*\xb8\x01\n" + "\x13WidgetErrorCategory\x12%\n" + "!WIDGET_ERROR_CATEGORY_UNSPECIFIED\x10\x00\x12'\n" + - "#WIDGET_ERROR_CATEGORY_RENDER_FAILED\x10\x01\x12,\n" + - "(WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED\x10\x02\x12!\n" + - "\x1dWIDGET_ERROR_CATEGORY_UNKNOWN\x10\x03B@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + "#WIDGET_ERROR_CATEGORY_RENDER_FAILED\x10\x01\x12!\n" + + "\x1dWIDGET_ERROR_CATEGORY_UNKNOWN\x10\x03\"\x04\b\x02\x10\x02*(WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTEDB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" var ( file_pluggableharness_widget_v1_errors_proto_rawDescOnce sync.Once diff --git a/pkg/widget/proto/v1/events.pb.go b/pkg/widget/proto/v1/events.pb.go deleted file mode 100644 index cd4552c..0000000 --- a/pkg/widget/proto/v1/events.pb.go +++ /dev/null @@ -1,150 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: pluggableharness/widget/v1/events.proto - -package widgetv1 - -import ( - v1 "github.com/pluggableharness/agent/pkg/render/proto/v1" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// WidgetUpdate is one pushed update to this widget's rendered content, per -// frontend.md §4.1. -type WidgetUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Which region this update places content into. - Region v1.Region `protobuf:"varint,1,opt,name=region,proto3,enum=pluggableharness.render.v1.Region" json:"region,omitempty"` - // The content to place. - Content *v1.RenderTree `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` - // True: replace this widget's prior content in `region`. False: append. - Replace bool `protobuf:"varint,3,opt,name=replace,proto3" json:"replace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *WidgetUpdate) Reset() { - *x = WidgetUpdate{} - mi := &file_pluggableharness_widget_v1_events_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *WidgetUpdate) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WidgetUpdate) ProtoMessage() {} - -func (x *WidgetUpdate) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_events_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WidgetUpdate.ProtoReflect.Descriptor instead. -func (*WidgetUpdate) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_events_proto_rawDescGZIP(), []int{0} -} - -func (x *WidgetUpdate) GetRegion() v1.Region { - if x != nil { - return x.Region - } - return v1.Region(0) -} - -func (x *WidgetUpdate) GetContent() *v1.RenderTree { - if x != nil { - return x.Content - } - return nil -} - -func (x *WidgetUpdate) GetReplace() bool { - if x != nil { - return x.Replace - } - return false -} - -var File_pluggableharness_widget_v1_events_proto protoreflect.FileDescriptor - -const file_pluggableharness_widget_v1_events_proto_rawDesc = "" + - "\n" + - "'pluggableharness/widget/v1/events.proto\x12\x1apluggableharness.widget.v1\x1a&pluggableharness/render/v1/types.proto\"\xa6\x01\n" + - "\fWidgetUpdate\x12:\n" + - "\x06region\x18\x01 \x01(\x0e2\".pluggableharness.render.v1.RegionR\x06region\x12@\n" + - "\acontent\x18\x02 \x01(\v2&.pluggableharness.render.v1.RenderTreeR\acontent\x12\x18\n" + - "\areplace\x18\x03 \x01(\bR\areplaceB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" - -var ( - file_pluggableharness_widget_v1_events_proto_rawDescOnce sync.Once - file_pluggableharness_widget_v1_events_proto_rawDescData []byte -) - -func file_pluggableharness_widget_v1_events_proto_rawDescGZIP() []byte { - file_pluggableharness_widget_v1_events_proto_rawDescOnce.Do(func() { - file_pluggableharness_widget_v1_events_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_events_proto_rawDesc), len(file_pluggableharness_widget_v1_events_proto_rawDesc))) - }) - return file_pluggableharness_widget_v1_events_proto_rawDescData -} - -var file_pluggableharness_widget_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_pluggableharness_widget_v1_events_proto_goTypes = []any{ - (*WidgetUpdate)(nil), // 0: pluggableharness.widget.v1.WidgetUpdate - (v1.Region)(0), // 1: pluggableharness.render.v1.Region - (*v1.RenderTree)(nil), // 2: pluggableharness.render.v1.RenderTree -} -var file_pluggableharness_widget_v1_events_proto_depIdxs = []int32{ - 1, // 0: pluggableharness.widget.v1.WidgetUpdate.region:type_name -> pluggableharness.render.v1.Region - 2, // 1: pluggableharness.widget.v1.WidgetUpdate.content:type_name -> pluggableharness.render.v1.RenderTree - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name -} - -func init() { file_pluggableharness_widget_v1_events_proto_init() } -func file_pluggableharness_widget_v1_events_proto_init() { - if File_pluggableharness_widget_v1_events_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_events_proto_rawDesc), len(file_pluggableharness_widget_v1_events_proto_rawDesc)), - NumEnums: 0, - NumMessages: 1, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_pluggableharness_widget_v1_events_proto_goTypes, - DependencyIndexes: file_pluggableharness_widget_v1_events_proto_depIdxs, - MessageInfos: file_pluggableharness_widget_v1_events_proto_msgTypes, - }.Build() - File_pluggableharness_widget_v1_events_proto = out.File - file_pluggableharness_widget_v1_events_proto_goTypes = nil - file_pluggableharness_widget_v1_events_proto_depIdxs = nil -} diff --git a/pkg/widget/proto/v1/rpc_request.pb.go b/pkg/widget/proto/v1/rpc_request.pb.go index 915efce..e018f9c 100644 --- a/pkg/widget/proto/v1/rpc_request.pb.go +++ b/pkg/widget/proto/v1/rpc_request.pb.go @@ -100,7 +100,7 @@ func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { // ConfigureRequest carries this provider's already-decoded agent.hcl block. type ConfigureRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // The provider's already-decoded config, per frontend.md §4.1. + // The provider's already-decoded config. Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -143,52 +143,6 @@ func (x *ConfigureRequest) GetConfig() *structpb.Struct { return nil } -// AttachRequest identifies which session's widget instance to attach to. -type AttachRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The session this widget instance is attaching to. - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AttachRequest) Reset() { - *x = AttachRequest{} - mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AttachRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AttachRequest) ProtoMessage() {} - -func (x *AttachRequest) ProtoReflect() protoreflect.Message { - mi := &file_pluggableharness_widget_v1_rpc_request_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AttachRequest.ProtoReflect.Descriptor instead. -func (*AttachRequest) Descriptor() ([]byte, []int) { - return file_pluggableharness_widget_v1_rpc_request_proto_rawDescGZIP(), []int{3} -} - -func (x *AttachRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - var File_pluggableharness_widget_v1_rpc_request_proto protoreflect.FileDescriptor const file_pluggableharness_widget_v1_rpc_request_proto_rawDesc = "" + @@ -197,10 +151,7 @@ const file_pluggableharness_widget_v1_rpc_request_proto_rawDesc = "" + "\x0fDescribeRequest\"\x18\n" + "\x16GetCapabilitiesRequest\"C\n" + "\x10ConfigureRequest\x12/\n" + - "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\".\n" + - "\rAttachRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionIdB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06configB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" var ( file_pluggableharness_widget_v1_rpc_request_proto_rawDescOnce sync.Once @@ -214,16 +165,15 @@ func file_pluggableharness_widget_v1_rpc_request_proto_rawDescGZIP() []byte { return file_pluggableharness_widget_v1_rpc_request_proto_rawDescData } -var file_pluggableharness_widget_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_pluggableharness_widget_v1_rpc_request_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_pluggableharness_widget_v1_rpc_request_proto_goTypes = []any{ (*DescribeRequest)(nil), // 0: pluggableharness.widget.v1.DescribeRequest (*GetCapabilitiesRequest)(nil), // 1: pluggableharness.widget.v1.GetCapabilitiesRequest (*ConfigureRequest)(nil), // 2: pluggableharness.widget.v1.ConfigureRequest - (*AttachRequest)(nil), // 3: pluggableharness.widget.v1.AttachRequest - (*structpb.Struct)(nil), // 4: google.protobuf.Struct + (*structpb.Struct)(nil), // 3: google.protobuf.Struct } var file_pluggableharness_widget_v1_rpc_request_proto_depIdxs = []int32{ - 4, // 0: pluggableharness.widget.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct + 3, // 0: pluggableharness.widget.v1.ConfigureRequest.config:type_name -> google.protobuf.Struct 1, // [1:1] is the sub-list for method output_type 1, // [1:1] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name @@ -242,7 +192,7 @@ func file_pluggableharness_widget_v1_rpc_request_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pluggableharness_widget_v1_rpc_request_proto_rawDesc), len(file_pluggableharness_widget_v1_rpc_request_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/widget/proto/v1/service.pb.go b/pkg/widget/proto/v1/service.pb.go index b90c602..8e8209e 100644 --- a/pkg/widget/proto/v1/service.pb.go +++ b/pkg/widget/proto/v1/service.pb.go @@ -4,11 +4,13 @@ // protoc (unknown) // source: pluggableharness/widget/v1/service.proto -// Package pluggableharness.widget.v1 defines the widget provider plugin protocol -// described in specifications/frontend.md §4 (Attach, action dispatch, ...). -// Messages and RPCs are added incrementally as the protocol is finalized; -// this file currently scaffolds the buf toolchain wiring — see -// .claude/rules/proto.md. +// Package pluggableharness.widget.v1 defines the widget provider plugin +// protocol described in specifications/frontend/widget-protocol.md. A +// widget contributes typed metadata (or other plugin-side work) without +// owning the frontend. There is no Attach stream: a widget that wants +// screen presence calls KernelCallbackService.PublishMetadata on the +// callback channel, the same path a tool provider uses for a status +// block. package widgetv1 @@ -30,34 +32,29 @@ var File_pluggableharness_widget_v1_service_proto protoreflect.FileDescriptor const file_pluggableharness_widget_v1_service_proto_rawDesc = "" + "\n" + - "(pluggableharness/widget/v1/service.proto\x12\x1apluggableharness.widget.v1\x1a'pluggableharness/widget/v1/events.proto\x1a,pluggableharness/widget/v1/rpc_request.proto\x1a-pluggableharness/widget/v1/rpc_response.proto2\xbd\x03\n" + + "(pluggableharness/widget/v1/service.proto\x12\x1apluggableharness.widget.v1\x1a,pluggableharness/widget/v1/rpc_request.proto\x1a-pluggableharness/widget/v1/rpc_response.proto2\xdc\x02\n" + "\rWidgetService\x12z\n" + "\x0fGetCapabilities\x122.pluggableharness.widget.v1.GetCapabilitiesRequest\x1a3.pluggableharness.widget.v1.GetCapabilitiesResponse\x12h\n" + - "\tConfigure\x12,.pluggableharness.widget.v1.ConfigureRequest\x1a-.pluggableharness.widget.v1.ConfigureResponse\x12_\n" + - "\x06Attach\x12).pluggableharness.widget.v1.AttachRequest\x1a(.pluggableharness.widget.v1.WidgetUpdate0\x01\x12e\n" + + "\tConfigure\x12,.pluggableharness.widget.v1.ConfigureRequest\x1a-.pluggableharness.widget.v1.ConfigureResponse\x12e\n" + "\bDescribe\x12+.pluggableharness.widget.v1.DescribeRequest\x1a,.pluggableharness.widget.v1.DescribeResponseB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" var file_pluggableharness_widget_v1_service_proto_goTypes = []any{ (*GetCapabilitiesRequest)(nil), // 0: pluggableharness.widget.v1.GetCapabilitiesRequest (*ConfigureRequest)(nil), // 1: pluggableharness.widget.v1.ConfigureRequest - (*AttachRequest)(nil), // 2: pluggableharness.widget.v1.AttachRequest - (*DescribeRequest)(nil), // 3: pluggableharness.widget.v1.DescribeRequest - (*GetCapabilitiesResponse)(nil), // 4: pluggableharness.widget.v1.GetCapabilitiesResponse - (*ConfigureResponse)(nil), // 5: pluggableharness.widget.v1.ConfigureResponse - (*WidgetUpdate)(nil), // 6: pluggableharness.widget.v1.WidgetUpdate - (*DescribeResponse)(nil), // 7: pluggableharness.widget.v1.DescribeResponse + (*DescribeRequest)(nil), // 2: pluggableharness.widget.v1.DescribeRequest + (*GetCapabilitiesResponse)(nil), // 3: pluggableharness.widget.v1.GetCapabilitiesResponse + (*ConfigureResponse)(nil), // 4: pluggableharness.widget.v1.ConfigureResponse + (*DescribeResponse)(nil), // 5: pluggableharness.widget.v1.DescribeResponse } var file_pluggableharness_widget_v1_service_proto_depIdxs = []int32{ 0, // 0: pluggableharness.widget.v1.WidgetService.GetCapabilities:input_type -> pluggableharness.widget.v1.GetCapabilitiesRequest 1, // 1: pluggableharness.widget.v1.WidgetService.Configure:input_type -> pluggableharness.widget.v1.ConfigureRequest - 2, // 2: pluggableharness.widget.v1.WidgetService.Attach:input_type -> pluggableharness.widget.v1.AttachRequest - 3, // 3: pluggableharness.widget.v1.WidgetService.Describe:input_type -> pluggableharness.widget.v1.DescribeRequest - 4, // 4: pluggableharness.widget.v1.WidgetService.GetCapabilities:output_type -> pluggableharness.widget.v1.GetCapabilitiesResponse - 5, // 5: pluggableharness.widget.v1.WidgetService.Configure:output_type -> pluggableharness.widget.v1.ConfigureResponse - 6, // 6: pluggableharness.widget.v1.WidgetService.Attach:output_type -> pluggableharness.widget.v1.WidgetUpdate - 7, // 7: pluggableharness.widget.v1.WidgetService.Describe:output_type -> pluggableharness.widget.v1.DescribeResponse - 4, // [4:8] is the sub-list for method output_type - 0, // [0:4] is the sub-list for method input_type + 2, // 2: pluggableharness.widget.v1.WidgetService.Describe:input_type -> pluggableharness.widget.v1.DescribeRequest + 3, // 3: pluggableharness.widget.v1.WidgetService.GetCapabilities:output_type -> pluggableharness.widget.v1.GetCapabilitiesResponse + 4, // 4: pluggableharness.widget.v1.WidgetService.Configure:output_type -> pluggableharness.widget.v1.ConfigureResponse + 5, // 5: pluggableharness.widget.v1.WidgetService.Describe:output_type -> pluggableharness.widget.v1.DescribeResponse + 3, // [3:6] is the sub-list for method output_type + 0, // [0:3] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -68,7 +65,6 @@ func file_pluggableharness_widget_v1_service_proto_init() { if File_pluggableharness_widget_v1_service_proto != nil { return } - file_pluggableharness_widget_v1_events_proto_init() file_pluggableharness_widget_v1_rpc_request_proto_init() file_pluggableharness_widget_v1_rpc_response_proto_init() type x struct{} diff --git a/pkg/widget/proto/v1/service_grpc.pb.go b/pkg/widget/proto/v1/service_grpc.pb.go index d44292c..8d35d59 100644 --- a/pkg/widget/proto/v1/service_grpc.pb.go +++ b/pkg/widget/proto/v1/service_grpc.pb.go @@ -4,11 +4,13 @@ // - protoc (unknown) // source: pluggableharness/widget/v1/service.proto -// Package pluggableharness.widget.v1 defines the widget provider plugin protocol -// described in specifications/frontend.md §4 (Attach, action dispatch, ...). -// Messages and RPCs are added incrementally as the protocol is finalized; -// this file currently scaffolds the buf toolchain wiring — see -// .claude/rules/proto.md. +// Package pluggableharness.widget.v1 defines the widget provider plugin +// protocol described in specifications/frontend/widget-protocol.md. A +// widget contributes typed metadata (or other plugin-side work) without +// owning the frontend. There is no Attach stream: a widget that wants +// screen presence calls KernelCallbackService.PublishMetadata on the +// callback channel, the same path a tool provider uses for a status +// block. package widgetv1 @@ -27,7 +29,6 @@ const _ = grpc.SupportPackageIsVersion9 const ( WidgetService_GetCapabilities_FullMethodName = "/pluggableharness.widget.v1.WidgetService/GetCapabilities" WidgetService_Configure_FullMethodName = "/pluggableharness.widget.v1.WidgetService/Configure" - WidgetService_Attach_FullMethodName = "/pluggableharness.widget.v1.WidgetService/Attach" WidgetService_Describe_FullMethodName = "/pluggableharness.widget.v1.WidgetService/Describe" ) @@ -35,46 +36,18 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // -// WidgetService is the widget provider plugin protocol described in -// specifications/frontend.md §4.1. A widget provider plugin exposes -// GetCapabilities, Configure, and Attach. Unlike the frontend provider's -// bidirectional Attach (frontend.md §3), this Attach is server-streaming -// only — widgets are passive/display-only in v1; a widget wanting to -// trigger an action does so by also being a tool provider with a slash -// command (frontend.md §5), not through this channel. +// WidgetService is the widget provider plugin protocol. Same three RPCs +// every category exposes; no Attach. type WidgetServiceClient interface { - // GetCapabilities returns this widget's regions and config schema, per - // frontend.md §4.1. MUST be cheaply re-queryable and MUST NOT require a + // GetCapabilities returns this widget's config schema and supported + // hook points. MUST be cheaply re-queryable and MUST NOT require a // network call. GetCapabilities(ctx context.Context, in *GetCapabilitiesRequest, opts ...grpc.CallOption) (*GetCapabilitiesResponse, error) - // Configure decodes this provider's agent.hcl block, per frontend.md - // §4.1. The request is already-decoded JSON (the schema-to-cty bridge is - // kernel-internal and never crosses the wire). Errors surface as a gRPC - // status per grpc.md — not an in-band field on ConfigureResponse. + // Configure decodes this provider's agent.hcl block. Errors surface as + // a gRPC status per grpc.md — not an in-band field on ConfigureResponse. Configure(ctx context.Context, in *ConfigureRequest, opts ...grpc.CallOption) (*ConfigureResponse, error) - // Attach opens a server-streaming feed of this widget's rendered updates - // for one session, per frontend.md §4.1 — confirmed NOT bidi; widgets are - // passive/display-only in v1. A widget derives its displayed state via - // observe-mode hook subscription (agent-loop.md §4), not a separate - // session-state feed (frontend.md §4.2); this stream is purely how it - // pushes the resulting rendered updates out, it never receives anything - // back on this channel. Cancellation is the kernel closing the gRPC - // stream; the plugin MUST treat this as normal control flow, never as an - // error. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is the bare "WidgetUpdate" per frontend.md §4.1's - // literal spec, naming the domain concept rather than the RPC. Not a - // uniqueness violation: WidgetUpdate is used by exactly this one RPC. - Attach(ctx context.Context, in *AttachRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[WidgetUpdate], error) - // Describe reports this plugin build's own identity — {name, version, - // source, category, protocol_version} — directly from the running - // process, rather than the kernel inferring it from a lock-file row. - // Every one of the seven category protocols gains this identical RPC in - // this protocol revision; it exists specifically for a - // `dev_overrides`-resolved binary (configuration/lock-file.md's - // "dev_overrides and identity without a lock entry"), which has no - // provider {} lock-file entry to read identity from at all. + // Describe reports this plugin build's own identity from the running + // process rather than a lock-file row. Describe(ctx context.Context, in *DescribeRequest, opts ...grpc.CallOption) (*DescribeResponse, error) } @@ -106,25 +79,6 @@ func (c *widgetServiceClient) Configure(ctx context.Context, in *ConfigureReques return out, nil } -func (c *widgetServiceClient) Attach(ctx context.Context, in *AttachRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[WidgetUpdate], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &WidgetService_ServiceDesc.Streams[0], WidgetService_Attach_FullMethodName, cOpts...) - if err != nil { - return nil, err - } - x := &grpc.GenericClientStream[AttachRequest, WidgetUpdate]{ClientStream: stream} - if err := x.ClientStream.SendMsg(in); err != nil { - return nil, err - } - if err := x.ClientStream.CloseSend(); err != nil { - return nil, err - } - return x, nil -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type WidgetService_AttachClient = grpc.ServerStreamingClient[WidgetUpdate] - func (c *widgetServiceClient) Describe(ctx context.Context, in *DescribeRequest, opts ...grpc.CallOption) (*DescribeResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DescribeResponse) @@ -139,46 +93,18 @@ func (c *widgetServiceClient) Describe(ctx context.Context, in *DescribeRequest, // All implementations must embed UnimplementedWidgetServiceServer // for forward compatibility. // -// WidgetService is the widget provider plugin protocol described in -// specifications/frontend.md §4.1. A widget provider plugin exposes -// GetCapabilities, Configure, and Attach. Unlike the frontend provider's -// bidirectional Attach (frontend.md §3), this Attach is server-streaming -// only — widgets are passive/display-only in v1; a widget wanting to -// trigger an action does so by also being a tool provider with a slash -// command (frontend.md §5), not through this channel. +// WidgetService is the widget provider plugin protocol. Same three RPCs +// every category exposes; no Attach. type WidgetServiceServer interface { - // GetCapabilities returns this widget's regions and config schema, per - // frontend.md §4.1. MUST be cheaply re-queryable and MUST NOT require a + // GetCapabilities returns this widget's config schema and supported + // hook points. MUST be cheaply re-queryable and MUST NOT require a // network call. GetCapabilities(context.Context, *GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) - // Configure decodes this provider's agent.hcl block, per frontend.md - // §4.1. The request is already-decoded JSON (the schema-to-cty bridge is - // kernel-internal and never crosses the wire). Errors surface as a gRPC - // status per grpc.md — not an in-band field on ConfigureResponse. + // Configure decodes this provider's agent.hcl block. Errors surface as + // a gRPC status per grpc.md — not an in-band field on ConfigureResponse. Configure(context.Context, *ConfigureRequest) (*ConfigureResponse, error) - // Attach opens a server-streaming feed of this widget's rendered updates - // for one session, per frontend.md §4.1 — confirmed NOT bidi; widgets are - // passive/display-only in v1. A widget derives its displayed state via - // observe-mode hook subscription (agent-loop.md §4), not a separate - // session-state feed (frontend.md §4.2); this stream is purely how it - // pushes the resulting rendered updates out, it never receives anything - // back on this channel. Cancellation is the kernel closing the gRPC - // stream; the plugin MUST treat this as normal control flow, never as an - // error. - // - // buf:lint:ignore RPC_RESPONSE_STANDARD_NAME - // Stream element type is the bare "WidgetUpdate" per frontend.md §4.1's - // literal spec, naming the domain concept rather than the RPC. Not a - // uniqueness violation: WidgetUpdate is used by exactly this one RPC. - Attach(*AttachRequest, grpc.ServerStreamingServer[WidgetUpdate]) error - // Describe reports this plugin build's own identity — {name, version, - // source, category, protocol_version} — directly from the running - // process, rather than the kernel inferring it from a lock-file row. - // Every one of the seven category protocols gains this identical RPC in - // this protocol revision; it exists specifically for a - // `dev_overrides`-resolved binary (configuration/lock-file.md's - // "dev_overrides and identity without a lock entry"), which has no - // provider {} lock-file entry to read identity from at all. + // Describe reports this plugin build's own identity from the running + // process rather than a lock-file row. Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) mustEmbedUnimplementedWidgetServiceServer() } @@ -196,9 +122,6 @@ func (UnimplementedWidgetServiceServer) GetCapabilities(context.Context, *GetCap func (UnimplementedWidgetServiceServer) Configure(context.Context, *ConfigureRequest) (*ConfigureResponse, error) { return nil, status.Error(codes.Unimplemented, "method Configure not implemented") } -func (UnimplementedWidgetServiceServer) Attach(*AttachRequest, grpc.ServerStreamingServer[WidgetUpdate]) error { - return status.Error(codes.Unimplemented, "method Attach not implemented") -} func (UnimplementedWidgetServiceServer) Describe(context.Context, *DescribeRequest) (*DescribeResponse, error) { return nil, status.Error(codes.Unimplemented, "method Describe not implemented") } @@ -259,17 +182,6 @@ func _WidgetService_Configure_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } -func _WidgetService_Attach_Handler(srv interface{}, stream grpc.ServerStream) error { - m := new(AttachRequest) - if err := stream.RecvMsg(m); err != nil { - return err - } - return srv.(WidgetServiceServer).Attach(m, &grpc.GenericServerStream[AttachRequest, WidgetUpdate]{ServerStream: stream}) -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type WidgetService_AttachServer = grpc.ServerStreamingServer[WidgetUpdate] - func _WidgetService_Describe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DescribeRequest) if err := dec(in); err != nil { @@ -308,12 +220,6 @@ var WidgetService_ServiceDesc = grpc.ServiceDesc{ Handler: _WidgetService_Describe_Handler, }, }, - Streams: []grpc.StreamDesc{ - { - StreamName: "Attach", - Handler: _WidgetService_Attach_Handler, - ServerStreams: true, - }, - }, + Streams: []grpc.StreamDesc{}, Metadata: "pluggableharness/widget/v1/service.proto", } diff --git a/pkg/widget/proto/v1/types.pb.go b/pkg/widget/proto/v1/types.pb.go index 46aace3..8fe167e 100644 --- a/pkg/widget/proto/v1/types.pb.go +++ b/pkg/widget/proto/v1/types.pb.go @@ -7,9 +7,8 @@ package widgetv1 import ( - v12 "github.com/pluggableharness/agent/pkg/common/proto/v1" - v11 "github.com/pluggableharness/agent/pkg/config/proto/v1" - v1 "github.com/pluggableharness/agent/pkg/render/proto/v1" + v11 "github.com/pluggableharness/agent/pkg/common/proto/v1" + v1 "github.com/pluggableharness/agent/pkg/config/proto/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -25,19 +24,17 @@ const ( ) // WidgetCapabilities is this widget provider's complete capability -// advertisement, per frontend.md §4.1. +// advertisement. type WidgetCapabilities struct { state protoimpl.MessageState `protogen:"open.v1"` - // MUST — the regions this widget intends to contribute to. - Regions []v1.Region `protobuf:"varint,1,rep,packed,name=regions,proto3,enum=pluggableharness.render.v1.Region" json:"regions,omitempty"` - // This provider's agent.hcl config schema, per configuration.md §4 — - // what fields Configure's request may be decoded from. - ConfigSchema *v11.ConfigSchema `protobuf:"bytes,2,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` + // This provider's agent.hcl config schema — what fields Configure's + // request may be decoded from. + ConfigSchema *v1.ConfigSchema `protobuf:"bytes,2,opt,name=config_schema,json=configSchema,proto3" json:"config_schema,omitempty"` // Hook points this widget can subscribe to in observe mode // (agent-loop/hook-dispatch.md), so a mis-declared agent.hcl hook{} // block naming an unsupported point can be rejected at config-load // time rather than failing at first dispatch. - SupportedHookPoints []v12.HookPoint `protobuf:"varint,3,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` + SupportedHookPoints []v11.HookPoint `protobuf:"varint,3,rep,packed,name=supported_hook_points,json=supportedHookPoints,proto3,enum=pluggableharness.common.v1.HookPoint" json:"supported_hook_points,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -72,21 +69,14 @@ func (*WidgetCapabilities) Descriptor() ([]byte, []int) { return file_pluggableharness_widget_v1_types_proto_rawDescGZIP(), []int{0} } -func (x *WidgetCapabilities) GetRegions() []v1.Region { - if x != nil { - return x.Regions - } - return nil -} - -func (x *WidgetCapabilities) GetConfigSchema() *v11.ConfigSchema { +func (x *WidgetCapabilities) GetConfigSchema() *v1.ConfigSchema { if x != nil { return x.ConfigSchema } return nil } -func (x *WidgetCapabilities) GetSupportedHookPoints() []v12.HookPoint { +func (x *WidgetCapabilities) GetSupportedHookPoints() []v11.HookPoint { if x != nil { return x.SupportedHookPoints } @@ -97,11 +87,10 @@ var File_pluggableharness_widget_v1_types_proto protoreflect.FileDescriptor const file_pluggableharness_widget_v1_types_proto_rawDesc = "" + "\n" + - "&pluggableharness/widget/v1/types.proto\x12\x1apluggableharness.widget.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\x1a&pluggableharness/render/v1/types.proto\"\xfc\x01\n" + - "\x12WidgetCapabilities\x12<\n" + - "\aregions\x18\x01 \x03(\x0e2\".pluggableharness.render.v1.RegionR\aregions\x12M\n" + + "&pluggableharness/widget/v1/types.proto\x12\x1apluggableharness.widget.v1\x1a&pluggableharness/common/v1/types.proto\x1a&pluggableharness/config/v1/types.proto\"\xcd\x01\n" + + "\x12WidgetCapabilities\x12M\n" + "\rconfig_schema\x18\x02 \x01(\v2(.pluggableharness.config.v1.ConfigSchemaR\fconfigSchema\x12Y\n" + - "\x15supported_hook_points\x18\x03 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPointsB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" + "\x15supported_hook_points\x18\x03 \x03(\x0e2%.pluggableharness.common.v1.HookPointR\x13supportedHookPointsJ\x04\b\x01\x10\x02R\aregionsB@Z>github.com/pluggableharness/agent/pkg/widget/proto/v1;widgetv1b\x06proto3" var ( file_pluggableharness_widget_v1_types_proto_rawDescOnce sync.Once @@ -118,19 +107,17 @@ func file_pluggableharness_widget_v1_types_proto_rawDescGZIP() []byte { var file_pluggableharness_widget_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_pluggableharness_widget_v1_types_proto_goTypes = []any{ (*WidgetCapabilities)(nil), // 0: pluggableharness.widget.v1.WidgetCapabilities - (v1.Region)(0), // 1: pluggableharness.render.v1.Region - (*v11.ConfigSchema)(nil), // 2: pluggableharness.config.v1.ConfigSchema - (v12.HookPoint)(0), // 3: pluggableharness.common.v1.HookPoint + (*v1.ConfigSchema)(nil), // 1: pluggableharness.config.v1.ConfigSchema + (v11.HookPoint)(0), // 2: pluggableharness.common.v1.HookPoint } var file_pluggableharness_widget_v1_types_proto_depIdxs = []int32{ - 1, // 0: pluggableharness.widget.v1.WidgetCapabilities.regions:type_name -> pluggableharness.render.v1.Region - 2, // 1: pluggableharness.widget.v1.WidgetCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema - 3, // 2: pluggableharness.widget.v1.WidgetCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 1, // 0: pluggableharness.widget.v1.WidgetCapabilities.config_schema:type_name -> pluggableharness.config.v1.ConfigSchema + 2, // 1: pluggableharness.widget.v1.WidgetCapabilities.supported_hook_points:type_name -> pluggableharness.common.v1.HookPoint + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_pluggableharness_widget_v1_types_proto_init() } diff --git a/pkg/widget/server.go b/pkg/widget/server.go index d687659..56df9de 100644 --- a/pkg/widget/server.go +++ b/pkg/widget/server.go @@ -31,12 +31,8 @@ var ( // NewService builds a Service adapting p to widgetv1.WidgetServiceServer. // identity is this plugin build's own self-reported identity, used -// directly by Describe (via plugin.Identity.ProducerRef) rather than a -// lock-file row. callback is accepted for parity with this plugin -// process's other muxed services and exposed via Callback, so a Provider -// implementation can reach the kernel callback channel (structured -// logging, tracing, the event bus) from within its own methods — -// WidgetService's own RPCs never call back into the kernel themselves. +// directly by Describe. callback is the kernel callback handle a Provider +// uses for PublishMetadata and other kernel RPCs. func NewService(p Provider, identity plugin.Identity, callback *plugin.Callback) *Service { return &Service{provider: p, identity: identity, callback: callback} } @@ -52,9 +48,7 @@ func (s *Service) Register(gs *grpc.Server) { widgetv1.RegisterWidgetServiceServer(gs, s) } -// Describe reports this plugin build's own identity, obtained directly -// from the Identity passed to NewService rather than a lock-file row -// (docs/specifications/frontend/widget-protocol.md#transport). +// Describe reports this plugin build's own identity. func (s *Service) Describe(context.Context, *widgetv1.DescribeRequest) (*widgetv1.DescribeResponse, error) { return &widgetv1.DescribeResponse{ Producer: s.identity.ProducerRef(commonv1.Category_CATEGORY_WIDGET, ProtocolVersion), @@ -62,10 +56,7 @@ func (s *Service) Describe(context.Context, *widgetv1.DescribeRequest) (*widgetv } // GetCapabilities delegates to the Provider and converts its result to -// the wire representation. MUST be cheap and MUST NOT require a network -// call (docs/specifications/frontend/widget-protocol.md#transport) — that -// guarantee is the Provider implementation's responsibility, not this -// adapter's. +// the wire representation. func (s *Service) GetCapabilities(ctx context.Context, _ *widgetv1.GetCapabilitiesRequest) (*widgetv1.GetCapabilitiesResponse, error) { caps, err := s.provider.GetCapabilities(ctx) if err != nil { @@ -74,11 +65,7 @@ func (s *Service) GetCapabilities(ctx context.Context, _ *widgetv1.GetCapabiliti return &widgetv1.GetCapabilitiesResponse{Capabilities: toProtoCapabilities(caps)}, nil } -// Configure delegates to the Provider. A rejection surfaces as a gRPC -// status carrying an Error in its structured detail, per -// docs/specifications/frontend/widget-protocol.md#error-taxonomy — never -// an in-band field on ConfigureResponse, and never echoing a received -// secret back out. +// Configure delegates to the Provider. func (s *Service) Configure(ctx context.Context, req *widgetv1.ConfigureRequest) (*widgetv1.ConfigureResponse, error) { if err := s.provider.Configure(ctx, req.GetConfig()); err != nil { return nil, toGRPCStatus(err) @@ -86,31 +73,8 @@ func (s *Service) Configure(ctx context.Context, req *widgetv1.ConfigureRequest) return &widgetv1.ConfigureResponse{}, nil } -// Attach serves one session's update feed by delegating to the Provider, -// which pushes updates through an UpdateSender built from stream. Per -// docs/specifications/frontend/widget-protocol.md#transport, this Attach -// is server-streaming only and session-scoped — one call per session, -// never multiplexed across sessions on one connection — a genuinely -// different shape from the frontend protocol's bidirectional, -// connection-scoped Attach despite sharing the RPC name (see doc.go). -// Cancellation (the kernel closing the stream) surfaces as -// codes.Canceled, never as an application error. -func (s *Service) Attach(req *widgetv1.AttachRequest, stream widgetv1.WidgetService_AttachServer) error { - ctx := stream.Context() - sender := newUpdateSender(ctx, stream) - - if err := s.provider.Attach(ctx, fromProtoAttachRequest(req), sender); err != nil { - return toGRPCStatus(err) - } - return nil -} - -// toGRPCStatus maps err to a gRPC status. Cancellation always maps to -// codes.Canceled, never to an application error. An *Error maps per its -// own category (errors.go); any other error is treated as -// WIDGET_ERROR_CATEGORY_UNKNOWN, mapping to codes.Internal — never -// codes.Unknown — per -// docs/specifications/frontend/widget-protocol.md#error-taxonomy. +// toGRPCStatus maps err to a gRPC status. An *Error maps per its own +// category; any other error is treated as WIDGET_ERROR_CATEGORY_UNKNOWN. func toGRPCStatus(err error) error { if errors.Is(err, context.Canceled) { return status.Error(codes.Canceled, err.Error()) diff --git a/pkg/widget/server_test.go b/pkg/widget/server_test.go index ccfae2e..53af9f9 100644 --- a/pkg/widget/server_test.go +++ b/pkg/widget/server_test.go @@ -3,9 +3,7 @@ package widget_test import ( "context" "errors" - "io" "testing" - "time" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -14,8 +12,6 @@ import ( commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" "github.com/pluggableharness/agent/pkg/plugin" - "github.com/pluggableharness/agent/pkg/render" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" "github.com/pluggableharness/agent/pkg/widget" widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" ) @@ -37,8 +33,8 @@ func TestService_Describe(t *testing.T) { t.Fatalf("Describe: %v", err) } producer := resp.GetProducer() - if producer.GetName() != "git-status" || producer.GetVersion() != "1.0.0" || producer.GetSource() != "github.com/agentco/git-status-widget" { - t.Errorf("Describe() producer = %+v, want name/version/source from testIdentity", producer) + if producer.GetName() != "git-status" || producer.GetVersion() != "1.0.0" { + t.Errorf("Describe() producer = %+v, want name/version from testIdentity", producer) } if producer.GetCategory() != commonv1.Category_CATEGORY_WIDGET { t.Errorf("Describe() producer.Category = %v, want CATEGORY_WIDGET", producer.GetCategory()) @@ -72,7 +68,7 @@ func TestService_Register(t *testing.T) { func TestService_GetCapabilities(t *testing.T) { t.Parallel() - want := widget.NewCapabilities(nil, []renderv1.Region{renderv1.Region_REGION_SIDEBAR}, commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL) + want := widget.NewCapabilities(nil, widget.WithSupportedHookPoints(commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL)) svc := widget.NewService(&fakeProvider{ getCapabilitiesFunc: func(context.Context) (widget.Capabilities, error) { return want, nil }, }, testIdentity, plugin.NewCallback()) @@ -83,214 +79,63 @@ func TestService_GetCapabilities(t *testing.T) { t.Fatalf("GetCapabilities: %v", err) } caps := resp.GetCapabilities() - if len(caps.GetRegions()) != 1 || caps.GetRegions()[0] != renderv1.Region_REGION_SIDEBAR { - t.Errorf("GetCapabilities().Regions = %v, want [REGION_SIDEBAR]", caps.GetRegions()) - } if len(caps.GetSupportedHookPoints()) != 1 || caps.GetSupportedHookPoints()[0] != commonv1.HookPoint_HOOK_POINT_POST_TOOL_CALL { - t.Errorf("GetCapabilities().SupportedHookPoints = %v, want [HOOK_POINT_POST_TOOL_CALL]", caps.GetSupportedHookPoints()) + t.Errorf("GetCapabilities().SupportedHookPoints = %v, want [POST_TOOL_CALL]", caps.GetSupportedHookPoints()) } } -func TestService_GetCapabilities_error(t *testing.T) { +func TestService_Configure(t *testing.T) { t.Parallel() + var got *structpb.Struct svc := widget.NewService(&fakeProvider{ - getCapabilitiesFunc: func(context.Context) (widget.Capabilities, error) { - return widget.Capabilities{}, widget.RenderFailed("cannot build schema") + configureFunc: func(_ context.Context, config *structpb.Struct) error { + got = config + return nil }, }, testIdentity, plugin.NewCallback()) client := newTestClient(t, svc) - _, err := client.GetCapabilities(t.Context(), &widgetv1.GetCapabilitiesRequest{}) - assertWidgetStatus(t, err, codes.Internal, widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_RENDER_FAILED, "cannot build schema") -} - -func TestService_Configure_success(t *testing.T) { - t.Parallel() - - var gotConfig bool - svc := widget.NewService(&fakeProvider{ - configureFunc: func(context.Context, *structpb.Struct) error { gotConfig = true; return nil }, - }, testIdentity, plugin.NewCallback()) - client := newTestClient(t, svc) - - if _, err := client.Configure(t.Context(), &widgetv1.ConfigureRequest{}); err != nil { + cfg, err := structpb.NewStruct(map[string]any{"enabled": true}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + if _, err := client.Configure(t.Context(), &widgetv1.ConfigureRequest{Config: cfg}); err != nil { t.Fatalf("Configure: %v", err) } - if !gotConfig { - t.Error("Configure: Provider.Configure was not called") + if !got.GetFields()["enabled"].GetBoolValue() { + t.Errorf("Configure received %v, want enabled=true", got) } } -func TestService_Configure_regionUnsupportedError(t *testing.T) { +func TestService_Configure_error(t *testing.T) { t.Parallel() svc := widget.NewService(&fakeProvider{ configureFunc: func(context.Context, *structpb.Struct) error { - return widget.RegionUnsupported("this widget has no sidebar support") + return widget.RenderFailed("cannot configure") }, }, testIdentity, plugin.NewCallback()) client := newTestClient(t, svc) _, err := client.Configure(t.Context(), &widgetv1.ConfigureRequest{}) - assertWidgetStatus(t, err, codes.InvalidArgument, widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED, "this widget has no sidebar support") -} - -func TestService_Configure_plainErrorMapsToUnknown(t *testing.T) { - t.Parallel() - - svc := widget.NewService(&fakeProvider{ - configureFunc: func(context.Context, *structpb.Struct) error { return errors.New("malformed config") }, - }, testIdentity, plugin.NewCallback()) - client := newTestClient(t, svc) - - _, err := client.Configure(t.Context(), &widgetv1.ConfigureRequest{}) - assertWidgetStatus(t, err, codes.Internal, widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_UNKNOWN, "malformed config") -} - -func TestService_Attach_pushesUpdatesWithSessionID(t *testing.T) { - t.Parallel() - - var gotSessionID string - svc := widget.NewService(&fakeProvider{ - attachFunc: func(_ context.Context, req widget.AttachRequest, sender *widget.UpdateSender) error { - gotSessionID = req.SessionID - if err := sender.Send(widget.Update{ - Region: renderv1.Region_REGION_SIDEBAR, - Content: render.Tree(render.Text("first")), - Mode: widget.UpdateAppend, - }); err != nil { - return err - } - return sender.Send(widget.Update{ - Region: renderv1.Region_REGION_TOP_BAR, - Content: render.Tree(render.Text("second")), - Mode: widget.UpdateReplace, - }) - }, - }, testIdentity, plugin.NewCallback()) - client := newTestClient(t, svc) - - stream, err := client.Attach(t.Context(), &widgetv1.AttachRequest{SessionId: "session-01"}) - if err != nil { - t.Fatalf("Attach: %v", err) + if status.Code(err) != codes.Internal { + t.Errorf("Configure code = %v, want Internal", status.Code(err)) } - - first, err := stream.Recv() - if err != nil { - t.Fatalf("Recv (first): %v", err) - } - if first.GetRegion() != renderv1.Region_REGION_SIDEBAR || first.GetReplace() { - t.Errorf("first update = region=%v replace=%v, want region=REGION_SIDEBAR replace=false (append)", first.GetRegion(), first.GetReplace()) - } - - second, err := stream.Recv() - if err != nil { - t.Fatalf("Recv (second): %v", err) - } - if second.GetRegion() != renderv1.Region_REGION_TOP_BAR || !second.GetReplace() { - t.Errorf("second update = region=%v replace=%v, want region=REGION_TOP_BAR replace=true", second.GetRegion(), second.GetReplace()) - } - - if _, err := stream.Recv(); !errors.Is(err, io.EOF) { - t.Errorf("Recv (third): err = %v, want io.EOF", err) - } - if gotSessionID != "session-01" { - t.Errorf("Provider.Attach saw SessionID = %q, want session-01", gotSessionID) - } -} - -func TestService_Attach_error(t *testing.T) { - t.Parallel() - - svc := widget.NewService(&fakeProvider{ - attachFunc: func(context.Context, widget.AttachRequest, *widget.UpdateSender) error { - return widget.RegionUnsupported("no overlay support") - }, - }, testIdentity, plugin.NewCallback()) - client := newTestClient(t, svc) - - stream, err := client.Attach(t.Context(), &widgetv1.AttachRequest{SessionId: "session-01"}) - if err != nil { - t.Fatalf("Attach: %v", err) - } - - _, err = stream.Recv() - assertWidgetStatus(t, err, codes.InvalidArgument, widgetv1.WidgetErrorCategory_WIDGET_ERROR_CATEGORY_REGION_UNSUPPORTED, "no overlay support") } -// TestService_Attach_clientCancelIsCleanShutdown exercises -// widget-protocol.md#transport's cancellation discipline: the kernel -// (here, the test client) closing the Attach stream mid-flight MUST be -// treated as normal control flow by both UpdateSender.Send and the -// Provider, never as an application error to report. -func TestService_Attach_clientCancelIsCleanShutdown(t *testing.T) { +func TestService_Configure_plainError(t *testing.T) { t.Parallel() - providerDone := make(chan struct{}) - var sendAfterCancelErr error - svc := widget.NewService(&fakeProvider{ - attachFunc: func(ctx context.Context, _ widget.AttachRequest, sender *widget.UpdateSender) error { - defer close(providerDone) - if err := sender.Send(widget.Update{ - Region: renderv1.Region_REGION_SIDEBAR, - Content: render.Tree(render.Text("hello")), - }); err != nil { - return err - } - <-ctx.Done() - sendAfterCancelErr = sender.Send(widget.Update{ - Region: renderv1.Region_REGION_SIDEBAR, - Content: render.Tree(render.Text("after cancel")), - }) - return ctx.Err() + configureFunc: func(context.Context, *structpb.Struct) error { + return errors.New("plain boom") }, }, testIdentity, plugin.NewCallback()) client := newTestClient(t, svc) - ctx, cancel := context.WithCancel(t.Context()) - stream, err := client.Attach(ctx, &widgetv1.AttachRequest{SessionId: "session-01"}) - if err != nil { - t.Fatalf("Attach: %v", err) - } - if _, err := stream.Recv(); err != nil { - t.Fatalf("Recv: %v", err) - } - - cancel() - - select { - case <-providerDone: - case <-time.After(5 * time.Second): - t.Fatal("Provider.Attach did not observe cancellation within 5s") - } - - if !errors.Is(sendAfterCancelErr, context.Canceled) { - t.Errorf("Send after cancel = %v, want context.Canceled", sendAfterCancelErr) - } -} - -// assertWidgetStatus asserts err is a gRPC status with code wantCode -// carrying a *widget.Error with category wantCategory and message -// wantMessage, recovered via widget.FromStatus. -func assertWidgetStatus(t *testing.T, err error, wantCode codes.Code, wantCategory widgetv1.WidgetErrorCategory, wantMessage string) { - t.Helper() - - if err == nil { - t.Fatal("want error, got nil") - } - if got := status.Code(err); got != wantCode { - t.Errorf("status.Code(err) = %v, want %v", got, wantCode) - } - werr, ok := widget.FromStatus(err) - if !ok { - t.Fatalf("FromStatus(%v) ok = false, want true", err) - } - if werr.Category != wantCategory { - t.Errorf("FromStatus(err).Category = %v, want %v", werr.Category, wantCategory) - } - if werr.Message != wantMessage { - t.Errorf("FromStatus(err).Message = %q, want %q", werr.Message, wantMessage) + _, err := client.Configure(t.Context(), &widgetv1.ConfigureRequest{}) + if status.Code(err) != codes.Internal { + t.Errorf("Configure code = %v, want Internal for plain error", status.Code(err)) } } diff --git a/pkg/widget/stream.go b/pkg/widget/stream.go deleted file mode 100644 index 3b21987..0000000 --- a/pkg/widget/stream.go +++ /dev/null @@ -1,44 +0,0 @@ -package widget - -import ( - "context" - "fmt" - - widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" -) - -// UpdateSender is the cancellation-safe handle a Provider's Attach method -// uses to push Update values for one session's Attach call. The zero -// value is not usable; Service.Attach constructs one per call and hands -// it to the Provider — a Provider never constructs one itself. -type UpdateSender struct { - ctx context.Context - stream widgetv1.WidgetService_AttachServer -} - -// newUpdateSender builds an UpdateSender bound to one Attach call's -// stream and context. -func newUpdateSender(ctx context.Context, stream widgetv1.WidgetService_AttachServer) *UpdateSender { - return &UpdateSender{ctx: ctx, stream: stream} -} - -// Send converts update to its wire representation and writes it to the -// underlying stream. If the session's context has already been -// canceled — the kernel closing this Attach call, ordinary control flow -// per docs/specifications/frontend/widget-protocol.md#transport, never an -// application error — Send returns ctx.Err() directly without attempting -// the write, so a Provider's Attach loop can check errors.Is(err, -// context.Canceled) uniformly regardless of whether cancellation landed -// before or during the write. -func (s *UpdateSender) Send(update Update) error { - if err := s.ctx.Err(); err != nil { - return err - } - if err := s.stream.Send(toProtoUpdate(update)); err != nil { - if ctxErr := s.ctx.Err(); ctxErr != nil { - return ctxErr - } - return fmt.Errorf("widget: send update: %w", err) - } - return nil -} diff --git a/pkg/widget/stream_internal_test.go b/pkg/widget/stream_internal_test.go deleted file mode 100644 index 9084722..0000000 --- a/pkg/widget/stream_internal_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package widget - -import ( - "context" - "errors" - "testing" - - "google.golang.org/grpc/metadata" - - widgetv1 "github.com/pluggableharness/agent/pkg/widget/proto/v1" -) - -// fakeAttachStream is a hand-written widgetv1.WidgetService_AttachServer -// fake (go-testing.md: fakes, not mocking frameworks), used to exercise -// UpdateSender.Send's branches that a real bufconn round trip can't -// deterministically reach — in particular a stream-level send failure -// that is not itself caused by context cancellation. server_test.go -// covers Send's ordinary and cancellation-driven paths through a real -// gRPC round trip; this file is the one place in this package that needs -// direct access to the unexported newUpdateSender constructor. -type fakeAttachStream struct { - ctx context.Context - sendErr error -} - -func (f *fakeAttachStream) Send(*widgetv1.WidgetUpdate) error { return f.sendErr } -func (f *fakeAttachStream) SetHeader(metadata.MD) error { return nil } -func (f *fakeAttachStream) SendHeader(metadata.MD) error { return nil } -func (f *fakeAttachStream) SetTrailer(metadata.MD) {} -func (f *fakeAttachStream) Context() context.Context { return f.ctx } -func (f *fakeAttachStream) SendMsg(any) error { return nil } -func (f *fakeAttachStream) RecvMsg(any) error { return nil } - -var _ widgetv1.WidgetService_AttachServer = (*fakeAttachStream)(nil) - -func TestUpdateSender_Send_success(t *testing.T) { - t.Parallel() - - stream := &fakeAttachStream{ctx: t.Context()} - sender := newUpdateSender(stream.ctx, stream) - - if err := sender.Send(Update{}); err != nil { - t.Errorf("Send() = %v, want nil", err) - } -} - -func TestUpdateSender_Send_ctxAlreadyCanceled(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithCancel(t.Context()) - cancel() - stream := &fakeAttachStream{ctx: ctx} - sender := newUpdateSender(ctx, stream) - - if err := sender.Send(Update{}); !errors.Is(err, context.Canceled) { - t.Errorf("Send() = %v, want context.Canceled", err) - } -} - -// TestUpdateSender_Send_nonCancellationError covers a stream-level Send -// failure unrelated to cancellation (a broken pipe, a marshal failure) — -// UpdateSender.Send must wrap and return it distinguishably from -// context.Canceled, not silently swallow it as "probably cancellation." -func TestUpdateSender_Send_nonCancellationError(t *testing.T) { - t.Parallel() - - wantErr := errors.New("connection reset") - stream := &fakeAttachStream{ctx: t.Context(), sendErr: wantErr} - sender := newUpdateSender(stream.ctx, stream) - - err := sender.Send(Update{}) - if !errors.Is(err, wantErr) { - t.Errorf("Send() = %v, want wrapping %v", err, wantErr) - } - if errors.Is(err, context.Canceled) { - t.Errorf("Send() = %v, want not context.Canceled", err) - } -} diff --git a/pkg/widget/widget.go b/pkg/widget/widget.go index 23a6027..a919313 100644 --- a/pkg/widget/widget.go +++ b/pkg/widget/widget.go @@ -2,24 +2,17 @@ package widget import ( "context" - "fmt" structpb "google.golang.org/protobuf/types/known/structpb" commonv1 "github.com/pluggableharness/agent/pkg/common/proto/v1" configv1 "github.com/pluggableharness/agent/pkg/config/proto/v1" - renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" ) // Capabilities is this widget provider's complete capability // advertisement, returned from GetCapabilities. It MUST be cheap to -// compute and MUST NOT require a network call -// (docs/specifications/frontend/widget-protocol.md#transport) — a -// Provider's GetCapabilities implementation should build this from -// static, already-in-process data, never a fresh RPC or I/O call. +// compute and MUST NOT require a network call. type Capabilities struct { - // Regions this widget intends to contribute to. MUST be set. - Regions []renderv1.Region // ConfigSchema is this provider's agent.hcl config schema — build it // with pkg/config.Schema and pkg/config.Attribute, or with this // package's NewCapabilities. @@ -30,81 +23,19 @@ type Capabilities struct { SupportedHookPoints []commonv1.HookPoint } -// AttachRequest identifies which session's widget instance a Provider's -// Attach method is being asked to serve. Per -// docs/specifications/frontend/widget-protocol.md#transport, one -// AttachRequest maps to exactly one Provider.Attach call and one -// session — never a set of sessions multiplexed on one call. -type AttachRequest struct { - // SessionID is the session this widget instance is attaching to. - SessionID string -} - -// UpdateMode says whether an Update replaces or appends to this widget's -// prior content in its target Region. It exists specifically so the -// replace/append distinction can't be silently inverted the way a bare -// bool parameter invites — UpdateAppend is the zero value, matching the -// wire default (an unset WidgetUpdate.replace means false, i.e. append, -// per widget-protocol.md#transport). -type UpdateMode int - -const ( - // UpdateAppend adds this update's Content alongside whatever this - // widget previously pushed to the same Region, rather than replacing - // it. This is UpdateMode's zero value. - UpdateAppend UpdateMode = iota - // UpdateReplace replaces this widget's prior content in the same - // Region entirely. - UpdateReplace -) - -// String returns "append" or "replace" for the two defined UpdateMode -// values, or "unknown(N)" for any other value. -func (m UpdateMode) String() string { - switch m { - case UpdateAppend: - return "append" - case UpdateReplace: - return "replace" - default: - return fmt.Sprintf("unknown(%d)", int(m)) - } -} - -// Update is one pushed update to this widget's rendered content for one -// session, per docs/specifications/frontend/widget-protocol.md#transport -// (the wire message is WidgetUpdate; this is its domain-side -// representation). Mode governs whether Content replaces or appends to -// this widget's prior content in Region — see UpdateMode's doc comment -// for why that distinction is a named type rather than a bare bool. -type Update struct { - // Region this update places Content into. - Region renderv1.Region - // Content to place — build it with pkg/render. - Content *renderv1.RenderTree - // Mode says whether this update replaces or appends to this widget's - // prior content in Region. - Mode UpdateMode -} - // Provider is the author-facing interface a widget plugin implements. A // concrete Provider is handed to NewService, which adapts it to // widgetv1.WidgetServiceServer. +// +// There is no Attach stream. A widget that wants screen presence calls +// KernelCallbackService.PublishMetadata on the callback channel — the +// same path a tool provider uses for a status block. type Provider interface { - // GetCapabilities returns this widget's regions, config schema, and - // supported hook points. MUST be cheap and MUST NOT make a network - // call (docs/specifications/frontend/widget-protocol.md#transport). + // GetCapabilities returns this widget's config schema and supported + // hook points. MUST be cheap and MUST NOT make a network call. GetCapabilities(ctx context.Context) (Capabilities, error) - // Configure decodes and validates this provider's agent.hcl block, - // already-decoded to config. Return an *Error (or any error) to - // reject it; Service surfaces it as a gRPC status, never echoing a - // received secret back out. + // Configure decodes and validates this provider's agent.hcl block. + // Return an *Error (or any error) to reject it; Service surfaces it + // as a gRPC status, never echoing a received secret back out. Configure(ctx context.Context, config *structpb.Struct) error - // Attach serves one session's update feed for as long as the kernel - // keeps the stream open, pushing every update through sender.Send. - // ctx is canceled when the kernel closes the stream — ordinary - // control flow, not a failure. Attach SHOULD return promptly once ctx - // is done (returning ctx.Err() is the idiomatic choice) rather than - // treating cancellation as an error condition worth reporting. - Attach(ctx context.Context, req AttachRequest, sender *UpdateSender) error } diff --git a/pkg/widget/widget_test.go b/pkg/widget/widget_test.go deleted file mode 100644 index 3a8cacf..0000000 --- a/pkg/widget/widget_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package widget_test - -import ( - "testing" - - "github.com/pluggableharness/agent/pkg/widget" -) - -func TestUpdateMode_String(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mode widget.UpdateMode - want string - }{ - {name: "append is the zero value", mode: widget.UpdateAppend, want: "append"}, - {name: "replace", mode: widget.UpdateReplace, want: "replace"}, - {name: "unrecognized value", mode: widget.UpdateMode(99), want: "unknown(99)"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := tt.mode.String(); got != tt.want { - t.Errorf("UpdateMode(%d).String() = %q, want %q", int(tt.mode), got, tt.want) - } - }) - } -} - -func TestUpdateMode_zeroValueIsAppend(t *testing.T) { - t.Parallel() - - var m widget.UpdateMode - if m != widget.UpdateAppend { - t.Errorf("zero value UpdateMode = %v, want UpdateAppend", m) - } -}