Skip to content

feat(desktop): harness-agnostic config bridge#887

Draft
wpfleger96 wants to merge 10 commits into
mainfrom
wpfleger/phase4-config-bridge
Draft

feat(desktop): harness-agnostic config bridge#887
wpfleger96 wants to merge 10 commits into
mainfrom
wpfleger/phase4-config-bridge

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Adds a harness-agnostic config bridge that reads agent configuration from all available sources and exposes it to the frontend with full provenance — where each value came from and how it can be written back.

Agent config was fragmented across four uncoordinated mechanisms with no single surface to see what a running agent would actually use. The silent failure mode was concrete: Wes's goose personas failed because active_provider in ~/.config/goose/config.yaml was invisible to Sprout and overrode our injected model. The bridge surfaces that ambient config, establishes a clear precedence order, and routes writes to the cheapest live mechanism.

  • New config_bridge module with per-runtime config file readers: YAML for goose (~/.config/goose/config.yaml), JSON for Claude Code (~/.claude/settings.json + ~/.claude.json), TOML for Codex (~/.codex/config.toml). 25 unit tests covering all formats, malformed files, and missing files.
  • Four-tier precedence: Sprout-explicit > ACP native (reserved for goose post goose#9197) > ACP configOptions from session/new > env vars > config file on disk. Pre-spawn surfaces tier 2 only; post-spawn adds ACP tiers.
  • session_config_captured observer event emitted after session/new; put_agent_session_config Tauri command populates SessionConfigCache in AppState. parse_models handles both the object shape ({currentModelId, availableModels}) and legacy array shape from session/new.
  • get_agent_config_surface and write_agent_config_field Tauri commands. Write mechanism is chosen per runtime and field: session/set_config_option (live, no restart) before env-var respawn. Single lock scope on the write path closes a TOCTOU race.
  • AgentConfigPanel component with origin badges (Sprout/ACP/ConfigFile/Env), collapsible advanced section, and sources footer showing all four tier statuses. Override/strikethrough rendering shows superseded config values inline — the overridden value appears crossed out next to the effective value, with both origin badges visible for at-a-glance provenance. Read-only fields display an info icon with tooltip rather than a lock icon.
  • ModelPicker shows provenance label when config surface data is available post-spawn (best-effort, non-blocking).
  • Playwright E2E screenshot spec covering 7 scenarios (goose, claude-code, codex, pre-spawn, overrides, advanced section, sources footer) for visual review.
  • Fixes codex.provider_locked to false — Codex supports [model_providers.<id>] custom provider tables. Fixes serde enum tagging on ConfigWriteMechanism, ConfigFieldType, and WriteConfigTarget to use #[serde(tag = "type")] matching the TypeScript discriminated union shapes (default external tagging made all write operations and TS type guards silently broken).

Stack: #794 → this PR

@wpfleger96 wpfleger96 force-pushed the wpfleger/phase3b-normalized-config branch 2 times, most recently from 66de98f to 60f95a2 Compare June 6, 2026 01:02
@wpfleger96 wpfleger96 changed the title feat(desktop): phase 4 — harness-agnostic config bridge feat(desktop): harness-agnostic config bridge Jun 6, 2026
@wpfleger96 wpfleger96 force-pushed the wpfleger/phase3b-normalized-config branch 6 times, most recently from de81ed1 to 0c44d4e Compare June 9, 2026 17:57
@wpfleger96 wpfleger96 force-pushed the wpfleger/phase4-config-bridge branch from 44cc07f to 678b871 Compare June 9, 2026 18:02
@wpfleger96 wpfleger96 force-pushed the wpfleger/phase3b-normalized-config branch from 0c44d4e to ba28ea0 Compare June 9, 2026 20:31
Base automatically changed from wpfleger/phase3b-normalized-config to main June 9, 2026 20:42
@wpfleger96 wpfleger96 force-pushed the wpfleger/phase4-config-bridge branch 2 times, most recently from c392c34 to 9939a10 Compare June 11, 2026 19:24
wpfleger96 pushed a commit that referenced this pull request Jun 11, 2026
wpfleger96 pushed a commit that referenced this pull request Jun 11, 2026
@wpfleger96 wpfleger96 force-pushed the wpfleger/phase4-config-bridge branch from 3228e7a to 139ada2 Compare June 11, 2026 20:16
wpfleger96 pushed a commit that referenced this pull request Jun 11, 2026
wpfleger96 pushed a commit that referenced this pull request Jun 11, 2026
wpfleger96 pushed a commit that referenced this pull request Jun 11, 2026
@wpfleger96 wpfleger96 force-pushed the wpfleger/phase4-config-bridge branch 5 times, most recently from 69e6bc8 to fba912f Compare June 16, 2026 21:33
@wpfleger96 wpfleger96 force-pushed the wpfleger/phase4-config-bridge branch 2 times, most recently from 2357920 to ac3a45e Compare June 17, 2026 00:46
Four-tier config bridge that reads agent configuration from config
files (goose YAML, claude JSON, codex TOML), ACP session data, env
vars, and Sprout-explicit overrides — surfacing a unified normalized
config surface to the desktop UI regardless of runtime.

Key changes:
- Config bridge module with per-runtime file readers
- ACP session config caching for post-spawn config visibility
- AgentConfigPanel component with origin badges and tier provenance
- Serde internally-tagged enums matching TypeScript discriminated unions
- TOCTOU-safe write path with single lock scope
- E2E mock handler for get_agent_config_surface with per-runtime
  fixtures and a Playwright screenshot spec covering 7 scenarios
- Info icon + tooltip on read-only fields; override/strikethrough
  rendering for superseded config values

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…place badges with provenance sentences

Persona-linked agents had their inherited model/prompt/provider invisible
in the config panel, and the source indicators (checkmark/hourglass icons,
colored badges, footer) were undecipherable.

Phase 1: Resolve all three persona fields (prompt, model, provider) in a
single resolve_config_surface helper called by both get_agent_config_surface
(read) and write_agent_config_field (write). The helper injects resolved
values into the record where absent, calls the reader, then re-tags the
injected fields from BuzzExplicit to PersonaDefault. The re-tag is triple-
gated so a value the user set explicitly in Buzz is never re-tagged. Sharing
the helper keeps the read and write surfaces identical, so plan_config_write
never returns "field not available" for a persona-sourced field. Reader stays
untouched (pure tier-merge function).

Phase 2: Add AgentConfigPanel to ProfileSummaryView in the profile pop-out,
gated on isBot && isOwner && managedAgent defined.

Phase 3: Remove SourcesFooter and colored OriginBadge pills. Replace with
gray inline provenance sentences below each value ("Set in Buzz", "Inherited
from persona", "From environment variable", etc). No action clauses.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 force-pushed the wpfleger/phase4-config-bridge branch from ac3a45e to 69d2c4e Compare June 17, 2026 04:19
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 8 commits June 17, 2026 14:20
Picking a model on a persona-linked running agent now applies to the live
instance instead of forcing a restart. The switch rides the existing
`desired_model` lever plus the Interrupt requeue/invalidate machinery: a busy
turn cancel-switch-requeues onto a fresh session under the new model; an idle
session invalidates and re-applies on its next turn. The override is
runtime-only — never persisted to `record.model`, gone on restart/respawn, so
spawn resolution stays persona-wins.

`ControlSignal` drops `Copy` to carry the owned model id; the post-match
re-read is replaced by a `requeues()` predicate. A model absent from the
agent's catalog surfaces an `unsupported_model` control_result (idle path
guards pre-cancel; busy path validates on the re-created session post-cancel)
so the picker rejects rather than silently no-opping. The desktop keys the
override-active display off the ACP `current_model` diverging from the persona
model (the harness-only `desired_model` is unreadable by the reader), shows the
persona as a non-struck secondary, and folds the standalone Configuration block
into the metadata card.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… flag

The runtime-override display keyed off `acp_model != persona_model`, which
cannot distinguish a live ModelPicker pick from a session left stale after a
mid-life persona edit. Editing a running persona-linked agent's model A->B
false-positived as a live override — re-introducing the display-versus-reality
bug this surface exists to kill. The harness now stamps a `model_overridden`
flag into session_config_captured (true only when a SwitchModel control signal
set desired_model, reset on spawn); the reader gates the override on that flag.

Also fix multi-channel sendLiveSwitch: it resolved on the first control_result
of any status, so a fast `sent` from one channel masked a later
`unsupported_model` from another. Now any single rejection fails the pick
immediately (fail-fast); success requires every channel to acknowledge.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The config-bridge provenance rows and ModelPicker origin/restart labels
used hardcoded text-[11px]/text-[10px] literals. The px-text guard (added
to main via the rem-token migration) forbids arbitrary font-size literals
so text scales with Cmd +/- zoom. Swap all four to the text-2xs meta-text
token (0.6875rem), the documented sibling for these decorative sub-labels.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The any-unsupported-fails-fast counting for a live model switch was locked
inside the sendLiveSwitch useCallback in ModelPicker, verified by read but
not unit-pinned. Extract the counting (remaining decrement, immediate
unsupported reject, resolve-once, unsubscribe-once, timeout fallback) into a
pure awaitLiveSwitchOutcome helper with the relay subscription, per-channel
sends, and timeout scheduler injected. The component wires the real
subscribeControlResults / window timer / dispatch; behavior is unchanged.

The helper is node:test-drivable with synthetic frames and a manual clock.
The masking-guard test fails against a first-ack-resolves variant and passes
against the shipped fail-fast logic.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The interim settled===false checks used a single await Promise.resolve()
drain, which the outcome.then callback outruns by one microtask tick, so
the guard passed even against a first-ack-resolves variant. Drain five
ticks before each interim check so it deterministically regresses an early
resolve.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A genuine-explicit agent (own record model, no persona) that live-switched
mid-session rendered nowhere: resolve_config_surface passed no override
baseline for an explicit record, so apply_runtime_override early-returned
and the panel showed the stale record model as primary with the live model
struck through — display contradicting reality.

Carry the override baseline as a typed (value, origin) pair end-to-end so
the secondary is tagged by its true source (BuzzExplicit for the record
case, PersonaDefault for personas) instead of a hardcoded PersonaDefault.
Build the record-model baseline only when model_overridden is true, so a
persona edited mid-life still does not false-positive. On a live pick equal
to the baseline, yield a clean single-value field rather than passing the
pre-polluted base through (build_model_field independently sets an
AcpConfigOption secondary for the record-plus-live-session case).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The live-acp-vs-record override is now exclusively apply_runtime_override's
job, gated on model_overridden. build_model_field's acp-derived secondary
predated that gate: with record_model=Some(X) and acp_model=Some(Y) it
populated overridden_value=Some(Y) unconditionally, and that row passed
straight through apply_runtime_override's !model_overridden early-return —
surfacing a live override before any switch was applied.

Collapse the secondary to express only the static record-vs-file precedence
(a Buzz-explicit model shadowing a config-file model); drop all acp_model
references from it.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The v4 screenshots captured the old origin-badge UI. The shipped surface
now renders inline provenance sentences ("Set in Buzz", "Inherited from
persona", "Live override (this session only)", etc.), a folded config panel,
and a non-struck persona baseline for runtime overrides.

Re-grounds the screenshot scenarios to the sentence-based render and adds a
multiOriginSurface fixture (one distinct origin per row) so the provenance-
sentences shot witnesses multiple distinct sentences in one frame instead of
duplicating the folded-panel capture.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96

Copy link
Copy Markdown
Collaborator Author

Configuration UI — fresh screenshots (supersedes prior v4)

These shots are captured against the actually-shipped sentence-based config UI: origin badges and the Sources Footer are gone, provenance now reads as a plain sentence under each field, and the Configuration panel is folded into the owner profile card.

Folded config panel

The Configuration block is folded into the owner profile card — no separate section, no duplicate Model row. Each field shows its value with a one-line provenance sentence beneath it.
01-folded-config-panel

Live runtime override

A runtime model override applied to the currently-running instance — wins over the persona default for this instance only, with the override surfaced inline (no needs-restart prompt, no persisted record.model write).
02-live-runtime-override

Provenance sentences (multiple distinct origins)

A multi-origin agent where every row carries a different provenance sentence in one frame: Model gpt-4o "Set in Buzz", Provider openai "Inherited from persona", Mode auto "From environment variable (GOOSE_MODE)", Thinking/Effort medium "From config file (~/.config/goose/config.yaml)".
03-provenance-sentences

Pre-spawn state

The config panel before an instance is spawned — provenance reflects the resolved-but-not-yet-running configuration.
04-pre-spawn-state

Advanced section expanded

The Advanced section expanded, showing the additional config fields with their provenance sentences.
05-advanced-expanded

wpfleger96 pushed a commit that referenced this pull request Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant