From a02968685238035b83505987bd9276ee15121a45 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:40:59 -0700 Subject: [PATCH 01/81] feat(flags): developer-flags backend foundation (#1506, ADR 0068 slice 1) (#1533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flags): developer-flags backend foundation (#1506, ADR 0068 slice 1) Slice 1 of the developer-flags system: the registry + resolution. - runtime/flags.py — a frozen `Flag` dataclass, the `FLAGS` registry (single source of truth, empty for now), and `flag_enabled(id)` / `resolved_flags()`. Tiers off env override > tier-vs-channel > off (fail-closed on an unregistered id). - channel derivation: explicit PROTOAGENT_CHANNEL > the dev sandbox instance (PROTOAGENT_INSTANCE=dev, ADR 0065) > the new `developer.channel` config field > prod. Read live (graph.sdk.config, imported lazily) so a Settings save applies without a restart, and degrades to env/default when there's no graph state (ACP/headless). - `developer.channel` added to LangGraphConfig + the settings schema (select prod/beta/dev, section "Developer" → Behavior domain), and the config-roundtrip goldens updated. 22 flag unit tests + the config-roundtrip/settings suites green; ruff clean; import contracts kept (runtime→graph.sdk/infra.paths is allowed). The /api/flags route (slice 2) and the Developer panel (slice 4) build on resolved_flags(). Co-Authored-By: Claude Opus 4.8 (1M context) * test: add 'developer' to config_to_dict top-level section set (#1506) test_config_io.py::test_config_to_dict_mirrors_yaml_shape hardcodes the full top-level key set config_to_dict emits; the new developer.channel field adds a 'developer' section. Verified against the full suite (2574 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++ graph/config.py | 6 ++ graph/settings_schema.py | 14 ++++ runtime/flags.py | 133 +++++++++++++++++++++++++++++++++ tests/test_config_io.py | 2 + tests/test_config_roundtrip.py | 2 + tests/test_flags.py | 128 +++++++++++++++++++++++++++++++ 7 files changed, 292 insertions(+) create mode 100644 runtime/flags.py create mode 100644 tests/test_flags.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a56955..28009ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Developer flags — backend foundation** (#1506, ADR 0068, slice 1). A small local/static + feature-flag system to gate pre-release functionality: `runtime/flags.py` with a `Flag` registry + (`off`·`dev`·`beta`·`on` tiers) and `flag_enabled(id)` / `resolved_flags()`. Enablement resolves + a flag's tier against a runtime **channel** (`prod ⊂ beta ⊂ dev`) — derived from the dev sandbox + instance, a `PROTOAGENT_CHANNEL` env, or the new **`developer.channel`** setting — with a + `PROTOAGENT_FLAG_` env override on top. No flags ship yet; the `/api/flags` route and the + Developer panel are later slices. - **Chat composer: terminal-style input history** (#1496). Press **↑** to recall previously-sent messages into the composer (newest first), **↓** to walk back toward your in-progress draft — just like a shell. Recalled messages are editable before resending; history only triggers at the top/bottom diff --git a/graph/config.py b/graph/config.py index b61383ea..b913155b 100644 --- a/graph/config.py +++ b/graph/config.py @@ -570,6 +570,11 @@ class LangGraphConfig: # LangGraph loop (default). "acp:" (e.g. "acp:codex", "acp:claude") = an # external coding agent drives the turn over ACP, mounting the operator MCP bus. agent_runtime: str = "native" + # Developer channel (ADR 0068) — which pre-release feature tier this instance exposes: + # "prod" (released only) | "beta" (opt-in previews) | "dev" (everything, incl. in-progress). + # The dev sandbox instance defaults to "dev"; env fallback PROTOAGENT_CHANNEL. Read by + # ``runtime.flags`` to resolve developer flags. + developer_channel: str = "prod" # ACP launch overrides (ADR 0033) — per-agent ``{command, args}`` from the YAML # ``acp.agents`` block, e.g. ``acp: {agents: {claude: {command: claude-agent-acp}}}``. # ``runtime.acp_runtime.adapter_for`` reads this to override the built-in default @@ -938,6 +943,7 @@ def from_dict( operator_mcp_enabled=operator_mcp.get("enabled", cls.operator_mcp_enabled), operator_mcp_tools=list(operator_mcp.get("tools", []) or []), agent_runtime=str(data.get("agent_runtime", cls.agent_runtime) or "native"), + developer_channel=str((data.get("developer", {}) or {}).get("channel", cls.developer_channel) or "prod"), acp_agents=dict(acp.get("agents", {}) or {}), plugins_enabled=list(plugins.get("enabled", []) or []), plugins_disabled=list(plugins.get("disabled", []) or []), diff --git a/graph/settings_schema.py b/graph/settings_schema.py index 84e6668f..1f8f7029 100644 --- a/graph/settings_schema.py +++ b/graph/settings_schema.py @@ -684,6 +684,17 @@ class Field: minimum=0, scope="host", ), + Field( + "developer.channel", + "developer_channel", + "Developer channel", + "select", + "Developer", + "Which pre-release features this instance exposes (ADR 0068). `prod` = released " + "features only; `beta` = opt-in previews; `dev` = everything, incl. in-progress " + "work. The dev sandbox instance defaults to `dev`. Env fallback: PROTOAGENT_CHANNEL.", + options=["prod", "beta", "dev"], + ), ] # Knowledge domain sub-sections (console grouping). The Knowledge fields are declared with @@ -807,6 +818,9 @@ def _plugin_group(sch, spec) -> str: "Network": "Box", "Discovery": "Box", "Keep-warm": "Box", + # Developer — pre-release feature gating (ADR 0068). The channel this instance runs on; + # the flags themselves live in a device-local Developer panel, not the schema. + "Developer": "Behavior", # Discord / Google / GitHub / other plugin sections → "Plugins" (the default), the # Integrations surface. } diff --git a/runtime/flags.py b/runtime/flags.py new file mode 100644 index 00000000..02991d46 --- /dev/null +++ b/runtime/flags.py @@ -0,0 +1,133 @@ +"""Developer flags (ADR 0068) — a small, local/static feature-flag system that gates +pre-release functionality behind tiers (``off`` < ``dev`` < ``beta`` < ``on``), measured +against a runtime *channel* (``prod`` ⊂ ``beta`` ⊂ ``dev``). + +A flag is a **temporary** gate on a core code path, meant to be deleted when the feature +graduates — *not* a plugin (a permanent capability toggle) and *not* a setting (permanent +user config). See ``docs/adr/0068-developer-flags-and-panel.md``. + +This module is slice 1 (backend core): the registry + resolution. ``flag_enabled(id)`` is +the check core code wraps a pre-release path in; ``resolved_flags()`` is the payload the +``/api/flags`` route (slice 2) serves and the Developer panel (slice 4) renders. + +Resolution precedence (ADR 0068 D3, backend half): + ``PROTOAGENT_FLAG_`` env override > the flag's tier vs. the runtime channel > off +(The query-param and panel-toggle override layers are frontend, slices 3–4.) +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal + +Tier = Literal["off", "dev", "beta", "on"] +Channel = Literal["prod", "beta", "dev"] + +# Channel openness — higher sees more. A flag at tier T is enabled when the channel's rank +# meets the tier's requirement. ``off`` requires more than any channel can offer (never on); +# ``on`` requires nothing (always on). +_CHANNEL_RANK: dict[str, int] = {"prod": 0, "beta": 1, "dev": 2} +_TIER_REQUIRES: dict[str, int] = {"on": 0, "beta": 1, "dev": 2, "off": 99} + + +@dataclass(frozen=True) +class Flag: + """One pre-release feature gate. Declared once in ``FLAGS`` — the single source of truth.""" + + id: str # dotted, stable — the override + lookup key, e.g. "chat.new_dashboard" + description: str # what it gates (shown in the Developer panel) + tier: Tier = "off" # rollout stage: off (nobody) · dev · beta · on (everybody) + owner: str = "" # who to ask — makes a stale flag actionable + remove_by: str = "" # a version or ISO date; a staleness test (slice 5) will guard it + + +# The registry — the SINGLE source of truth. Add a flag here; check it with ``flag_enabled``. +# Empty until a real pre-release feature needs gating (slice 6 dogfoods the first one). +FLAGS: list[Flag] = [] + + +def _registry() -> dict[str, Flag]: + """Flags keyed by id (built fresh so tests can monkeypatch ``FLAGS``).""" + return {f.id: f for f in FLAGS} + + +def _env_key(flag_id: str) -> str: + """``PROTOAGENT_FLAG_`` — the id upper-cased with every non-alphanumeric run → ``_`` + (``chat.new_dashboard`` → ``PROTOAGENT_FLAG_CHAT_NEW_DASHBOARD``).""" + return "PROTOAGENT_FLAG_" + "".join(c if c.isalnum() else "_" for c in flag_id).upper() + + +def _env_override(flag_id: str) -> bool | None: + """The forced state from ``PROTOAGENT_FLAG_`` (headless / CI / deployment escape + hatch), or ``None`` when the var is unset.""" + raw = os.environ.get(_env_key(flag_id)) + if raw is None: + return None + return raw.strip().lower() in ("1", "true", "on", "yes") + + +def current_channel() -> Channel: + """The runtime's openness. Explicit ``PROTOAGENT_CHANNEL`` wins; else the dev sandbox + instance (``PROTOAGENT_INSTANCE=dev``, ADR 0065) is ``dev``; else the ``developer.channel`` + config field; else ``prod``. + + Read live (like plugin config) so a Settings save applies without a restart, and degrades + to env/default when there's no graph state (ACP / headless / import-time).""" + explicit = os.environ.get("PROTOAGENT_CHANNEL", "").strip().lower() + if explicit in _CHANNEL_RANK: + return explicit # type: ignore[return-value] + try: + from infra.paths import instance_id + + if instance_id() == "dev": + return "dev" + except Exception: + pass + try: + from graph.sdk import config + + configured = str(getattr(config(), "developer_channel", "") or "").strip().lower() + if configured in _CHANNEL_RANK: + return configured # type: ignore[return-value] + except Exception: + pass + return "prod" + + +def _tier_enabled(tier: str, channel: str) -> bool: + return _CHANNEL_RANK.get(channel, 0) >= _TIER_REQUIRES.get(tier, 99) + + +def flag_enabled(flag_id: str, *, channel: Channel | None = None) -> bool: + """Is ``flag_id`` enabled in the current process? Env override > tier-vs-channel > off. + An unregistered id is always off (fail-closed). Pass ``channel`` to resolve against a + specific channel instead of the live one (used by ``resolved_flags`` and tests).""" + override = _env_override(flag_id) + if override is not None: + return override + flag = _registry().get(flag_id) + if flag is None: + return False + return _tier_enabled(flag.tier, channel or current_channel()) + + +def resolved_flags(*, channel: Channel | None = None) -> dict: + """Every registered flag with its metadata + resolved state, plus the active channel — + the payload for ``GET /api/flags`` (slice 2) and the Developer panel (slice 4).""" + resolved_channel = channel or current_channel() + flags = [] + for f in FLAGS: + override = _env_override(f.id) + flags.append( + { + "id": f.id, + "description": f.description, + "tier": f.tier, + "owner": f.owner, + "remove_by": f.remove_by, + "enabled": override if override is not None else _tier_enabled(f.tier, resolved_channel), + "source": "env" if override is not None else "channel", + } + ) + return {"channel": resolved_channel, "flags": flags} diff --git a/tests/test_config_io.py b/tests/test_config_io.py index 9b93f1dd..3f25382e 100644 --- a/tests/test_config_io.py +++ b/tests/test_config_io.py @@ -143,6 +143,8 @@ def test_config_to_dict_mirrors_yaml_shape() -> None: "runtime", "operator", "agent_runtime", + # Developer channel (ADR 0068) — the pre-release feature tier this instance exposes. + "developer", "checkpoint", "compaction", "goal", diff --git a/tests/test_config_roundtrip.py b/tests/test_config_roundtrip.py index 65564647..a2fc5b87 100644 --- a/tests/test_config_roundtrip.py +++ b/tests/test_config_roundtrip.py @@ -52,6 +52,7 @@ def _isolate_from_installed_plugins(monkeypatch): "a2a_skills": [], "acp_agents": {}, "agent_runtime": "native", + "developer_channel": "prod", "api_base": "http://gateway:4000/v1", "audit_middleware": True, "autostart_on_boot": False, @@ -189,6 +190,7 @@ def _isolate_from_installed_plugins(monkeypatch): # prompt_cache / routing / telemetry appeared; no pre-existing value changed. CONFIG_TO_DICT_GOLDEN = { "agent_runtime": "native", + "developer": {"channel": "prod"}, "auth": { "token": "", }, diff --git a/tests/test_flags.py b/tests/test_flags.py new file mode 100644 index 00000000..49cec8dd --- /dev/null +++ b/tests/test_flags.py @@ -0,0 +1,128 @@ +"""Developer flags (ADR 0068, slice 1) — the registry + resolution. + +Freezes the tier-vs-channel matrix, the env-override precedence, and channel derivation. +Tests monkeypatch ``flags.FLAGS`` to a hermetic registry and wipe flag/channel env vars.""" + +from __future__ import annotations + +import os +import types + +import pytest + +from runtime import flags +from runtime.flags import Flag + + +@pytest.fixture(autouse=True) +def _hermetic_env(monkeypatch): + """Wipe any real flag/channel/instance env so resolution is deterministic per test.""" + for k in list(os.environ): + if k.startswith("PROTOAGENT_FLAG_") or k in ( + "PROTOAGENT_CHANNEL", + "PROTOAGENT_INSTANCE", + "PROTOAGENT_AUTO_SCOPE", + ): + monkeypatch.delenv(k, raising=False) + yield + + +def _use(monkeypatch, *registry: Flag) -> None: + monkeypatch.setattr(flags, "FLAGS", list(registry)) + + +# ── tier vs channel ───────────────────────────────────────────────────────────── + +@pytest.mark.parametrize( + "tier,channel,expected", + [ + ("on", "prod", True), ("on", "beta", True), ("on", "dev", True), + ("beta", "prod", False), ("beta", "beta", True), ("beta", "dev", True), + ("dev", "prod", False), ("dev", "beta", False), ("dev", "dev", True), + ("off", "prod", False), ("off", "beta", False), ("off", "dev", False), + ], +) +def test_tier_vs_channel(monkeypatch, tier, channel, expected): + _use(monkeypatch, Flag("x", "d", tier=tier)) + assert flags.flag_enabled("x", channel=channel) is expected + + +def test_unregistered_flag_is_off(monkeypatch): + _use(monkeypatch) # empty registry + assert flags.flag_enabled("nope", channel="dev") is False + + +# ── env override precedence ───────────────────────────────────────────────────── + +def test_env_override_forces_state(monkeypatch): + _use(monkeypatch, Flag("chat.new", "d", tier="off")) + monkeypatch.setenv("PROTOAGENT_FLAG_CHAT_NEW", "on") + assert flags.flag_enabled("chat.new", channel="prod") is True # off tier, env forces on + monkeypatch.setenv("PROTOAGENT_FLAG_CHAT_NEW", "0") + assert flags.flag_enabled("chat.new", channel="dev") is False # on-in-dev, env forces off + + +def test_env_key_derivation(): + assert flags._env_key("chat.new_dashboard") == "PROTOAGENT_FLAG_CHAT_NEW_DASHBOARD" + + +# ── channel derivation ────────────────────────────────────────────────────────── + +def test_channel_explicit_env_wins(monkeypatch): + monkeypatch.setenv("PROTOAGENT_CHANNEL", "beta") + assert flags.current_channel() == "beta" + + +def test_channel_dev_sandbox_instance(monkeypatch): + monkeypatch.setenv("PROTOAGENT_INSTANCE", "dev") # the dev sandbox (ADR 0065) + assert flags.current_channel() == "dev" + + +def test_channel_from_config_field(monkeypatch): + import graph.sdk + + monkeypatch.setattr(graph.sdk, "config", lambda: types.SimpleNamespace(developer_channel="beta")) + assert flags.current_channel() == "beta" + + +def test_channel_defaults_to_prod(monkeypatch): + import graph.sdk + + monkeypatch.setattr(graph.sdk, "config", lambda: types.SimpleNamespace(developer_channel="")) + assert flags.current_channel() == "prod" + + +def test_bad_channel_value_ignored(monkeypatch): + monkeypatch.setenv("PROTOAGENT_CHANNEL", "banana") # not a real channel → ignored + import graph.sdk + + monkeypatch.setattr(graph.sdk, "config", lambda: types.SimpleNamespace(developer_channel="")) + assert flags.current_channel() == "prod" + + +# ── the API payload ───────────────────────────────────────────────────────────── + +def test_resolved_flags_payload(monkeypatch): + _use(monkeypatch, Flag("a.b", "desc", tier="beta", owner="me", remove_by="v1.0")) + out = flags.resolved_flags(channel="beta") + assert out["channel"] == "beta" + assert out["flags"] == [ + { + "id": "a.b", "description": "desc", "tier": "beta", "owner": "me", + "remove_by": "v1.0", "enabled": True, "source": "channel", + } + ] + # an env override flips enabled + tags the source. + monkeypatch.setenv("PROTOAGENT_FLAG_A_B", "off") + flipped = flags.resolved_flags(channel="dev")["flags"][0] + assert flipped["enabled"] is False and flipped["source"] == "env" + + +# ── registry hygiene (guards the real FLAGS as it grows) ──────────────────────── + +def test_real_registry_is_well_formed(): + ids = [f.id for f in flags.FLAGS] + assert len(ids) == len(set(ids)), "duplicate flag id in FLAGS" + for f in flags.FLAGS: + assert f.tier in ("off", "dev", "beta", "on"), f"{f.id}: invalid tier {f.tier!r}" + assert f.id and f.description, "every flag needs an id + description" From 3c1b413594c6c56732849d508ab919d0291bb0b4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:13:34 -0700 Subject: [PATCH 02/81] test(config): derive config-surface goldens from FIELDS (one value gate) (#1538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a LangGraphConfig field used to require updating FOUR parallel hand-maintained lists of the config surface, and they'd already drifted: - test_config_roundtrip.CONFIG_TO_DICT_GOLDEN (exhaustive nested dict) - test_config_roundtrip.EMITTED_ATTRS (round-trip attr set) - test_config_roundtrip.FROM_YAML_EXAMPLE_FIELDS (value map) - test_config_io section set (top-level key set) config_to_dict is FIELDS-driven, so the SHAPE ones are redundant with the schema. Derive them: - EMITTED_ATTRS = {f.attr for f in FIELDS} | <18 non-FIELDS §B legacy attrs>. This FIXES a latent gap: EMITTED_ATTRS was silently missing 7 fields (developer_channel, checkpoint_vacuum, commons_path, egress_allowed_hosts, identity_org, knowledge_scope, skills_scope) — their round-trip wasn't tested. - the test_config_io top-level section set = {FIELDS sections} | {subagents, plugins}. - drop the exhaustive CONFIG_TO_DICT_GOLDEN; replace its test with a focused shape + redaction check. Value coverage stays in FROM_YAML_EXAMPLE_FIELDS (the flat view of the same cfg) and test_round_trip_preserves_emitted_fields (field-agnostic drop/mis-parse guard). Net: adding a config field now touches ONE golden (FROM_YAML_EXAMPLE_FIELDS, the value gate), not four. -314/+63 lines. Full suite 2574 passed. Co-authored-by: Claude Opus 4.8 (1M context) --- tests/test_config_io.py | 37 +--- tests/test_config_roundtrip.py | 342 ++++++--------------------------- 2 files changed, 64 insertions(+), 315 deletions(-) diff --git a/tests/test_config_io.py b/tests/test_config_io.py index 3f25382e..346c045e 100644 --- a/tests/test_config_io.py +++ b/tests/test_config_io.py @@ -19,6 +19,8 @@ from pathlib import Path from unittest.mock import MagicMock +from graph.settings_schema import FIELDS + import httpx import pytest @@ -129,35 +131,12 @@ def test_config_to_dict_mirrors_yaml_shape() -> None: # too). discord/google are NOT here — they're plugin sections, present only # when their plugin is enabled (surfaced via plugin_config), and a default # LangGraphConfig() carries no plugin_config. - assert set(d.keys()) == { - "model", - "subagents", - "middleware", - "knowledge", - "skills", - "commons", - "mcp", - "plugins", - "identity", - "auth", - "runtime", - "operator", - "agent_runtime", - # Developer channel (ADR 0068) — the pre-release feature tier this instance exposes. - "developer", - "checkpoint", - "compaction", - "goal", - "operator_mcp", - "prompt_cache", - "routing", - "telemetry", - # Box runtime (Host layer, ADR 0047 D8). - "network", - "fleet", - # Egress allowlist (ADR 0008) — surfaced in Settings ▸ Box ▸ Network. - "egress", - } + # config_to_dict is FIELDS-driven, so the top-level sections it emits are exactly the + # schema's top-level keys, plus two legacy sections config_io §B emits that have no + # FIELDS entry (subagents.researcher and the plugins.* knobs). Deriving from FIELDS + # means a new field doesn't require re-typing the section list here. + _LEGACY_SECTIONS = {"subagents", "plugins"} + assert set(d.keys()) == {f.key.split(".", 1)[0] for f in FIELDS} | _LEGACY_SECTIONS assert d["model"]["name"] == cfg.model_name assert d["model"]["temperature"] == cfg.temperature # Secrets are redacted out of the UI-facing dict. diff --git a/tests/test_config_roundtrip.py b/tests/test_config_roundtrip.py index a2fc5b87..7c64ddc0 100644 --- a/tests/test_config_roundtrip.py +++ b/tests/test_config_roundtrip.py @@ -24,8 +24,18 @@ save_yaml_doc, ) +from graph.settings_schema import FIELDS + EXAMPLE_PATH = "config/langgraph-config.example.yaml" +# config_to_dict is FIELDS-driven (graph/config_io.py §A) plus a small set of explicit +# non-FIELDS "legacy" keys (§B). So the SHAPE it emits derives from the schema — only the +# legacy extras and per-field VALUES are worth hand-maintaining. Deriving the shape means +# adding a LangGraphConfig field only touches ONE golden below (FROM_YAML_EXAMPLE_FIELDS, +# the value gate), not four parallel hand-kept lists. +_FIELDS_ATTRS = {f.attr for f in FIELDS} +_FIELDS_SECTIONS = {f.key.split(".", 1)[0] for f in FIELDS} + @pytest.fixture(autouse=True) def _isolate_from_installed_plugins(monkeypatch): @@ -182,289 +192,6 @@ def _isolate_from_installed_plugins(monkeypatch): # Fields handled by their own dedicated assertions, not the golden map. _GOLDEN_EXEMPT = {"api_key", "auth_token", "federation_token", "plugin_config"} -# config_to_dict(from_yaml(example)) exact output — freezes the now-FIELDS- -# complete emitted surface (B1 PR-3) so a later change shows up as a reviewable -# diff. The diff vs the old (partial, 11-section) golden is purely ADDITIVE: -# the new sections agent_runtime / checkpoint / compaction / execute_code / goal -# / knowledge.embeddings+facts / middleware.enforcement / operator_mcp / -# prompt_cache / routing / telemetry appeared; no pre-existing value changed. -CONFIG_TO_DICT_GOLDEN = { - "agent_runtime": "native", - "developer": {"channel": "prod"}, - "auth": { - "token": "", - }, - "checkpoint": { - "background_keep": 1, - "db_path": "/sandbox/checkpoints.db", - "harvest_enabled": True, - "keep_per_thread": 5, - "max_age_days": 30, - "prune_interval_hours": 6, - "vacuum": True, - }, - "commons": { - "path": "", - }, - "compaction": { - "enabled": True, - "keep_messages": 20, - "model": "", - "trigger": "fraction:0.8", - }, - "egress": { - "allowed_hosts": [], - }, - "fleet": { - "port_base": 7870, - "discovery": { - "port_min": 7860, - "port_max": 7910, - "mdns": True, - }, - "warm": { - "max": 0, - "grace_seconds": 0, - }, - }, - "goal": { - "enabled": True, - "eval_model": "", - "max_iterations": 8, - }, - "identity": { - "name": "protoagent", - "operator": "", - "org": "", - }, - "knowledge": { - "attach_inline_budget": 8000, - "chunk_max_chars": 1200, - "chunk_min_chars": 200, - "chunk_overlap_chars": 150, - "context_max_doc_chars": 12000, - "contextual_enrichment": False, - "db_path": "/sandbox/knowledge/agent.db", - "embed_breaker_cooldown_s": 300.0, - "embed_breaker_threshold": 2, - "embed_model": "qwen3-embedding", - "embeddings": True, - "facts": True, - "min_score": 0.0, - "recall_preview_chars": 1000, - "rrf_k": 60, - "image_describe_model": "", - "scope": "", - "top_k": 5, - "transcribe_model": "whisper-1", - "vector_k": 20, - }, - "mcp": { - "denylist": [], - "enabled": False, - "scope": "", - "servers": [], - "timeout_seconds": 20.0, - }, - "middleware": { - "audit": True, - "enforcement": False, - "knowledge": True, - "memory": True, - "scheduler": True, - }, - "model": { - "api_base": "http://gateway:4000/v1", - "api_key": "", - "max_iterations": 50, - "max_tokens": 32768, - "name": "protolabs/reasoning", - "provider": "openai", - "reasoning_effort": None, - "temperature": 0.2, - "thinking": "", - "vision": False, - }, - "network": { - "bind": "127.0.0.1", - }, - "operator": { - "allowed_dirs": [], - "project_dir": "", - }, - "operator_mcp": { - "tools": [], - }, - "plugins": { - "dir": "", - "disabled": [], - "enabled": [], - "sources": { - "allow": [], - }, - }, - "prompt_cache": { - "enabled": True, - "ttl": "5m", - "warm": { - "enabled": False, - "interval_seconds": 3300, - }, - }, - "routing": { - "aux_model": "", - "fallback_models": [], - }, - "runtime": { - "autostart_on_boot": False, - }, - "skills": { - "db_path": "/sandbox/skills.db", - "dir": "", - "enabled": True, - "scope": "", - "top_k": 5, - }, - "subagents": { - "researcher": { - "enabled": True, - "max_turns": 40, - "model": "", - "tools": [ - "current_time", - "web_search", - "fetch_url", - "memory_recall", - "memory_list", - ], - }, - }, - "telemetry": { - "enabled": True, - "retention_days": 90, - }, -} - -# The dataclass attrs config_to_dict ACTUALLY emits, derived from the -# section/key structure of the golden dict. (a) maps each emitted golden -# leaf to the dataclass attr it feeds, (b) excludes attrs config_to_dict -# does NOT emit. The round-trip test asserts equality over exactly this set. -# -# plugin sections (when any plugin claims one) round-trip via plugin_config, -# checked separately. identity.org has no dataclass attr (getattr default). -EMITTED_ATTRS = { - # model.* - "model_provider", - "model_name", - "api_base", - "api_key", # redacted -> resolves to "" - "temperature", - "max_tokens", - "model_vision", - "max_iterations", - "thinking", - "reasoning_effort", - # subagents.researcher - "researcher", - # middleware.* - "knowledge_middleware", - "audit_middleware", - "memory_middleware", - "scheduler_enabled", - # knowledge.* - "knowledge_db_path", - "embed_model", - "transcribe_model", - "image_describe_model", - "knowledge_top_k", - "knowledge_vector_k", - "knowledge_rrf_k", - "knowledge_min_score", - "knowledge_recall_preview_chars", - "knowledge_embed_breaker_threshold", - "knowledge_embed_breaker_cooldown_s", - "knowledge_chunk_max_chars", - "knowledge_chunk_overlap_chars", - "knowledge_chunk_min_chars", - "knowledge_contextual_enrichment", - "knowledge_context_max_doc_chars", - "knowledge_attach_inline_budget", - # skills.* - "skills_enabled", - "skills_db_path", - "skills_top_k", - "skills_dir", - # mcp.* - "mcp_enabled", - "mcp_servers", - "mcp_timeout_seconds", - "mcp_denylist", - "mcp_scope", - # plugins.* (disabled + sources.allow added by the 2026-06-10 N6 fix — - # config_to_dict used to emit only enabled/dir, so any complete-dict - # consumer lost them) - "plugins_enabled", - "plugins_disabled", - "plugins_dir", - "plugins_sources_allow", - # identity.* - "identity_name", - "identity_operator", - # auth.token - "auth_token", # redacted -> resolves to "" - # runtime.* - "autostart_on_boot", - # operator.* - "operator_allowed_dirs", - "operator_project_dir", - # --- B1 PR-3: config_to_dict is now FIELDS-complete, so these 27 - # newly-emitted keys (derived key->attr from FIELDS) must also round-trip. --- - # agent_runtime - "agent_runtime", - # operator_mcp.tools - "operator_mcp_tools", - # routing.* - "aux_model", - "routing_fallback_models", - # compaction.* - "compaction_enabled", - "compaction_trigger", - "compaction_keep_messages", - "compaction_model", - # goal.* - "goal_enabled", - "goal_max_iterations", - "goal_eval_model", - # prompt_cache.* (incl. prompt_cache.warm.*) - "prompt_cache_enabled", - "prompt_cache_ttl", - "cache_warming_enabled", - "cache_warming_interval_seconds", - # knowledge.embeddings / knowledge.facts - "knowledge_embeddings", - "knowledge_facts", - # checkpoint.* - "checkpoint_background_keep", - "checkpoint_db_path", - "checkpoint_keep_per_thread", - "checkpoint_max_age_days", - "checkpoint_prune_interval_hours", - "checkpoint_harvest_enabled", - # middleware.enforcement - "enforcement_enabled", - # telemetry.* - "telemetry_enabled", - "telemetry_retention_days", - # network.bind / fleet.* (box runtime, ADR 0047 D8) - "bind_host", - "fleet_port_base", - "discovery_port_min", - "discovery_port_max", - "discovery_mdns", - "fleet_max_warm", - "fleet_warm_grace_seconds", -} - def _write_yaml(dir_path: Path, body: str, *, secrets: str | None = None) -> str: """Write a langgraph-config.yaml (and optional sibling secrets.yaml) and @@ -506,13 +233,27 @@ def test_from_yaml_example_golden(): # --------------------------------------------------------------------------- -# (b) config_to_dict golden (frozen partial output) +# (b) config_to_dict shape + redaction (shape derived from FIELDS, values gated by (a)) # --------------------------------------------------------------------------- -def test_config_to_dict_golden(): +def test_config_to_dict_shape_and_redaction(): + """config_to_dict's SHAPE derives from FIELDS (graph/config_io.py §A), so we assert the + top-level sections against the schema + a few representative nested values + secret + redaction — NOT a frozen copy of the whole nested dict (which drifted on every field + add). The exhaustive value coverage lives in FROM_YAML_EXAMPLE_FIELDS (the flat view of + the same cfg) and test_round_trip_preserves_emitted_fields below.""" cfg = LangGraphConfig.from_yaml(EXAMPLE_PATH) - assert config_to_dict(cfg) == CONFIG_TO_DICT_GOLDEN + d = config_to_dict(cfg) + # every FIELDS top-level section is emitted (§B legacy keys only add sub-keys under + # existing sections, never a new top-level one). + assert _FIELDS_SECTIONS <= set(d.keys()) + # secrets redacted (blank-means-unchanged). + assert d["model"]["api_key"] == "" and d["auth"]["token"] == "" + # representative nested values round-trip from the cfg. + assert d["model"]["name"] == cfg.model_name + assert d["model"]["temperature"] == cfg.temperature + assert d["developer"]["channel"] == cfg.developer_channel # --------------------------------------------------------------------------- @@ -520,6 +261,35 @@ def test_config_to_dict_golden(): # --------------------------------------------------------------------------- +# The non-FIELDS attrs config_to_dict still emits (graph/config_io.py §B) — hand-listed +# because they're not in the schema. Everything else derives from _FIELDS_ATTRS, so a +# newly-added field is round-trip-checked automatically. (This set previously drifted +# silently — developer_channel, checkpoint_vacuum, commons_path, egress_allowed_hosts, +# identity_org, knowledge_scope and skills_scope were all missing from round-trip coverage.) +_LEGACY_EMITTED_ATTRS = { + "researcher", # subagents.researcher (a SubagentDef) + "checkpoint_background_keep", + "knowledge_db_path", + "knowledge_embed_breaker_threshold", + "knowledge_embed_breaker_cooldown_s", + "knowledge_chunk_min_chars", + "knowledge_context_max_doc_chars", + "mcp_enabled", + "mcp_servers", + "mcp_timeout_seconds", + "mcp_denylist", + "skills_enabled", + "skills_db_path", + "skills_dir", + "plugins_enabled", + "plugins_disabled", + "plugins_dir", + "plugins_sources_allow", +} +# Redacted secrets (api_key / auth_token / federation_token) resolve to "" on both sides. +EMITTED_ATTRS = _FIELDS_ATTRS | _LEGACY_EMITTED_ATTRS + + def test_round_trip_preserves_emitted_fields(tmp_path): cfg = LangGraphConfig.from_yaml(EXAMPLE_PATH) d = config_to_dict(cfg) From 21957820311d74b8de4afc2593f5cb49af882f38 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:14:20 -0700 Subject: [PATCH 03/81] feat(flags): GET /api/flags operator route (#1506, ADR 0068 slice 2) (#1539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves runtime.flags.resolved_flags() — the active channel + every registered developer flag with its resolved enabled state + source — so the console Developer panel (slice 4) can render and toggle them. Read-only, gated by the /api/* operator bearer like every operator route. operator_api/flags_routes.py + registered in server bootstrap next to the fleet/theme routes. 3 route tests; import contracts kept (operator_api -> runtime.flags is allowed). Co-authored-by: Claude Opus 4.8 (1M context) --- operator_api/flags_routes.py | 26 +++++++++++++++ server/__init__.py | 6 ++++ tests/test_flags_routes.py | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 operator_api/flags_routes.py create mode 100644 tests/test_flags_routes.py diff --git a/operator_api/flags_routes.py b/operator_api/flags_routes.py new file mode 100644 index 00000000..9820bb9b --- /dev/null +++ b/operator_api/flags_routes.py @@ -0,0 +1,26 @@ +"""Developer flags (ADR 0068) — ``GET /api/flags``. + +Serves the resolved flag list (registry metadata + each flag's enabled state for the +current channel) so the console **Developer panel** (slice 4) can render and toggle them. +Read-only; gated by the ``/api/*`` operator bearer (a2a_impl/auth.py) like every operator +route. The registry + resolution live in ``runtime.flags`` (slice 1). +""" + +from __future__ import annotations + + +def register_flags_routes(app) -> None: + from fastapi import APIRouter + + router = APIRouter() + + @router.get("/api/flags") + async def _flags() -> dict: + """The active channel + every registered developer flag with its resolved state + (ADR 0068). Shape: ``{"channel": "prod|beta|dev", "flags": [{id, description, tier, + owner, remove_by, enabled, source}]}``. See ``runtime.flags.resolved_flags``.""" + from runtime.flags import resolved_flags + + return resolved_flags() + + app.include_router(router) diff --git a/server/__init__.py b/server/__init__.py index 6e546c74..5f0e0977 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -674,6 +674,12 @@ async def _scheduler_shutdown() -> None: register_fleet_routes(fastapi_app) + # Developer flags (ADR 0068) — /api/flags serves the resolved flag states the + # console Developer panel renders + toggles. + from operator_api.flags_routes import register_flags_routes + + register_flags_routes(fastapi_app) + # Per-agent theme (ADR 0042) — each agent saves its own look; the console repaints # to the focused agent's theme (proxied via /agents//api/theme, slug routing). from operator_api.theme_routes import register_theme_routes diff --git a/tests/test_flags_routes.py b/tests/test_flags_routes.py new file mode 100644 index 00000000..01277460 --- /dev/null +++ b/tests/test_flags_routes.py @@ -0,0 +1,62 @@ +"""Developer flags API (ADR 0068, slice 2) — GET /api/flags serves resolved_flags().""" + +from __future__ import annotations + +import os + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from operator_api.flags_routes import register_flags_routes +from runtime import flags +from runtime.flags import Flag + + +@pytest.fixture(autouse=True) +def _hermetic_env(monkeypatch): + for k in list(os.environ): + if k.startswith("PROTOAGENT_FLAG_") or k in ("PROTOAGENT_CHANNEL", "PROTOAGENT_INSTANCE", "PROTOAGENT_AUTO_SCOPE"): + monkeypatch.delenv(k, raising=False) + yield + + +def _client() -> TestClient: + app = FastAPI() + register_flags_routes(app) + return TestClient(app) + + +def test_flags_route_serves_resolved_payload(monkeypatch): + monkeypatch.setenv("PROTOAGENT_CHANNEL", "beta") + monkeypatch.setattr(flags, "FLAGS", [Flag("chat.new", "A new thing", tier="beta", owner="kj", remove_by="v2")]) + + r = _client().get("/api/flags") + assert r.status_code == 200 + body = r.json() + assert body["channel"] == "beta" + assert body["flags"] == [ + { + "id": "chat.new", "description": "A new thing", "tier": "beta", + "owner": "kj", "remove_by": "v2", "enabled": True, "source": "channel", + } + ] + + +def test_flags_route_reflects_env_override_and_channel(monkeypatch): + # prod channel: a dev-tier flag is off… + monkeypatch.setenv("PROTOAGENT_CHANNEL", "prod") + monkeypatch.setattr(flags, "FLAGS", [Flag("x.y", "d", tier="dev")]) + off = _client().get("/api/flags").json()["flags"][0] + assert off["enabled"] is False and off["source"] == "channel" + + # …until an env override forces it on (source flips to "env"). + monkeypatch.setenv("PROTOAGENT_FLAG_X_Y", "on") + on = _client().get("/api/flags").json()["flags"][0] + assert on["enabled"] is True and on["source"] == "env" + + +def test_flags_route_empty_registry(monkeypatch): + monkeypatch.setattr(flags, "FLAGS", []) + body = _client().get("/api/flags").json() + assert body["flags"] == [] and body["channel"] in ("prod", "beta", "dev") From 33cb2c31c102ecbb7c26de9a00f0f89fb7190775 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:26:35 -0700 Subject: [PATCH 04/81] =?UTF-8?q?feat(flags):=20Developer=20panel=20+=20us?= =?UTF-8?q?eFlag=20hook=20(#1506,=20ADR=200068=20slices=203=E2=80=934)=20(?= =?UTF-8?q?#1540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console half of developer flags, on top of the /api/flags route (#1539): - flags/flags.ts — useFlag(id) gates console UI (fail-closed while loading); useFlags()/useDeveloperChannel() read /api/flags. Two frontend override layers on top of the server's channel resolution: a device-local panel toggle (createUISlice, persisted per-agent) and a shareable ?flag:=on|off query param. Precedence: query-param > panel toggle > server channel state. - settings/DeveloperPanel.tsx — Settings ▸ Developer: each flag with tier badge + resolved state + a per-device Switch + Reset; "Reset all" clears overrides. - SettingsSurface wires it into "This console" ONLY off prod (developerPanelVisible: dev build / non-prod channel / ?dev|?flag: reveal), so production operators never see it. - lib/types + api.flags() + flagsQuery. Verified: tsc clean; vitest (override store) + full web unit suite 217; a new developer-flags.spec.ts e2e (panel lists flags, toggle persists an override, Reset clears) run against the built app; settings.spec section-list updated. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++ apps/web/e2e/developer-flags.spec.ts | 28 ++++++++ apps/web/e2e/mock-server.mjs | 9 +++ apps/web/e2e/settings.spec.ts | 1 + apps/web/src/flags/flags.test.ts | 30 ++++++++ apps/web/src/flags/flags.ts | 79 ++++++++++++++++++++ apps/web/src/lib/api.ts | 4 ++ apps/web/src/lib/queries.ts | 10 +++ apps/web/src/lib/types.ts | 14 ++++ apps/web/src/settings/DeveloperPanel.tsx | 88 +++++++++++++++++++++++ apps/web/src/settings/SettingsSurface.tsx | 15 +++- 11 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 apps/web/e2e/developer-flags.spec.ts create mode 100644 apps/web/src/flags/flags.test.ts create mode 100644 apps/web/src/flags/flags.ts create mode 100644 apps/web/src/settings/DeveloperPanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 28009ed4..a9d814db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Developer panel — view & toggle developer flags** (#1506, ADR 0068). A new **Settings ▸ Developer** + section (surfaced only off prod — a dev build, a non-prod `developer.channel`, or a `?dev` reveal — + so production operators never see it) lists every registered flag with its tier + resolved state and + lets you flip it **per-device** (device-local overrides, *Reset* to clear). Backed by `GET /api/flags`; + `useFlag(id)` gates console UI, and `?flag:=on|off` gives a shareable per-load override. - **Developer flags — backend foundation** (#1506, ADR 0068, slice 1). A small local/static feature-flag system to gate pre-release functionality: `runtime/flags.py` with a `Flag` registry (`off`·`dev`·`beta`·`on` tiers) and `flag_enabled(id)` / `resolved_flags()`. Enablement resolves diff --git a/apps/web/e2e/developer-flags.spec.ts b/apps/web/e2e/developer-flags.spec.ts new file mode 100644 index 00000000..d453e077 --- /dev/null +++ b/apps/web/e2e/developer-flags.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from "@playwright/test"; + +// Developer flags panel (ADR 0068). The mock serves channel "dev", so the Developer section +// appears under Settings ▸ This console; toggling a flag persists a device-local override that +// a Reset clears. + +test("Developer panel lists flags and a toggle persists as an override", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await page.getByTestId("settings-widget").click(); + await expect(page.locator(".settings-overlay")).toBeVisible(); + + // Off prod (mock channel = dev) the Developer section is present. + await page.locator(".settings-overlay .pl-sidenav").getByRole("tab", { name: "Developer", exact: true }).click(); + const panel = page.getByTestId("developer-panel"); + await expect(panel).toBeVisible(); + await expect(panel.getByText("chat.new_dashboard")).toBeVisible(); + await expect(panel.getByText("chat.experimental_widget")).toBeVisible(); + + // Toggle the beta flag → an "overridden" badge + a Reset appear. + const row = panel.locator('[data-key="flag.chat.new_dashboard"]'); + await expect(row.getByText("overridden")).toHaveCount(0); + await row.locator(".pl-switch").click(); + await expect(row.getByText("overridden")).toBeVisible(); + + // Reset → the override clears. + await row.getByRole("button", { name: "Reset" }).click(); + await expect(row.getByText("overridden")).toHaveCount(0); +}); diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index 1ca712af..5d216569 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -256,6 +256,15 @@ function handleApiGet(pathname, fleet = FLEET) { commons: knowledgeChunks.filter((c) => c.tier === "commons").length, }, }; + case "/api/flags": + // Developer flags (ADR 0068). channel "dev" so the Developer panel is visible in e2e. + return { + channel: "dev", + flags: [ + { id: "chat.new_dashboard", description: "Preview of the redesigned dashboard.", tier: "beta", owner: "kj", remove_by: "v1.0", enabled: true, source: "channel" }, + { id: "chat.experimental_widget", description: "An in-progress widget.", tier: "dev", owner: "kj", remove_by: "", enabled: true, source: "channel" }, + ], + }; default: return null; } diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index 33129151..b7aec79f 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -72,6 +72,7 @@ test("the settings dialog lists the domain groups (host, no scope toggle)", asyn "Theme", "Chat", "Keyboard", + "Developer", // ADR 0068 — shown off prod (the mock serves channel "dev") ]); await section(page, "Behavior"); await expect(page.locator(".pl-accordion__title").first()).toBeVisible(); diff --git a/apps/web/src/flags/flags.test.ts b/apps/web/src/flags/flags.test.ts new file mode 100644 index 00000000..3b669aaa --- /dev/null +++ b/apps/web/src/flags/flags.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { clearFlagOverride, resetFlagOverrides, setFlagOverride, useFlagOverrides } from "./flags"; + +// The device-local override store (ADR 0068) — the Developer panel's toggles. The useFlag / +// channel hooks are exercised end-to-end in e2e/developer-flags.spec.ts (they need the query). + +describe("flag overrides store", () => { + beforeEach(() => resetFlagOverrides()); + + it("sets, clears, and resets device-local overrides", () => { + expect(useFlagOverrides.getState().overrides).toEqual({}); + + setFlagOverride("chat.new", true); + setFlagOverride("chat.old", false); + expect(useFlagOverrides.getState().overrides).toEqual({ "chat.new": true, "chat.old": false }); + + clearFlagOverride("chat.new"); + expect(useFlagOverrides.getState().overrides).toEqual({ "chat.old": false }); + + resetFlagOverrides(); + expect(useFlagOverrides.getState().overrides).toEqual({}); + }); + + it("re-setting an override replaces its value", () => { + setFlagOverride("x.y", true); + setFlagOverride("x.y", false); + expect(useFlagOverrides.getState().overrides["x.y"]).toBe(false); + }); +}); diff --git a/apps/web/src/flags/flags.ts b/apps/web/src/flags/flags.ts new file mode 100644 index 00000000..4c5e157b --- /dev/null +++ b/apps/web/src/flags/flags.ts @@ -0,0 +1,79 @@ +// Developer flags (ADR 0068) — the console half. The backend (runtime/flags.py) resolves +// each flag's tier against the runtime channel and serves it at /api/flags; here we add the +// two frontend override layers (a shareable ?flag:= query param, and a device-local +// panel toggle) on top, and expose `useFlag(id)` for gating console UI. +// +// Effective state precedence (ADR 0068 D3, frontend half): +// ?flag:= query param > device-local panel toggle > server channel-resolved state +// (The server has already applied the PROTOAGENT_FLAG_ env override beneath that.) + +import { useQuery } from "@tanstack/react-query"; + +import { createUISlice } from "../ext/uiStateRegistry"; +import { flagsQuery } from "../lib/queries"; +import type { FlagChannel } from "../lib/types"; + +// Device-local panel overrides — persisted per-agent (like the other UI slices), so a +// developer's toggles never leak into shared config. `undefined` = follow the server state. +type OverrideState = { overrides: Record }; +export const useFlagOverrides = createUISlice("dev-flags", { overrides: {} }); + +export function setFlagOverride(id: string, on: boolean): void { + useFlagOverrides.setState((s) => ({ overrides: { ...s.overrides, [id]: on } })); +} +export function clearFlagOverride(id: string): void { + useFlagOverrides.setState((s) => { + const next = { ...s.overrides }; + delete next[id]; + return { overrides: next }; + }); +} +export function resetFlagOverrides(): void { + useFlagOverrides.setState({ overrides: {} }); +} + +// Transient, shareable overrides parsed ONCE from ?flag:=on|off (a "try this build" +// link). Not persisted — they last only for the current page load. +const TRUEY = new Set(["1", "on", "true", "yes"]); +const _queryOverrides: Record = (() => { + const out: Record = {}; + try { + new URLSearchParams(window.location.search).forEach((value, key) => { + if (key.startsWith("flag:")) out[key.slice(5)] = TRUEY.has(value.trim().toLowerCase()); + }); + } catch { + /* no location (SSR/tests) — no query overrides */ + } + return out; +})(); + +export function useFlags() { + return useQuery(flagsQuery()); +} + +/** Is a developer flag ON for this session? Fail-closed: an unknown/loading flag is off. */ +export function useFlag(id: string): boolean { + const { data } = useFlags(); + const override = useFlagOverrides((s) => s.overrides[id]); + if (id in _queryOverrides) return _queryOverrides[id]; + if (override !== undefined) return override; + return data?.flags.find((f) => f.id === id)?.enabled ?? false; +} + +/** The runtime channel this instance runs on (prod | beta | dev). */ +export function useDeveloperChannel(): FlagChannel { + return useFlags().data?.channel ?? "prod"; +} + +/** Whether to surface the Developer panel: a dev build, a non-prod channel, or an explicit + * `?dev` / `?flag:` reveal — so production users never see it, but a developer always can. */ +export function developerPanelVisible(channel: FlagChannel): boolean { + let revealed = false; + try { + const q = new URLSearchParams(window.location.search); + revealed = q.has("dev") || [...q.keys()].some((k) => k.startsWith("flag:")); + } catch { + /* no location */ + } + return import.meta.env.DEV || channel !== "prod" || revealed; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c3b0b03f..c377ac2c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -15,6 +15,7 @@ import type { DiscoveredAgent, FleetAgent, FleetStatus, + FlagsPayload, GoalState, HitlPayload, InboxItem, @@ -1070,6 +1071,9 @@ export const api = { fleet() { return request("/api/fleet"); }, + flags() { + return request("/api/flags"); + }, discoverAgents() { return request<{ discovered: DiscoveredAgent[] }>("/api/fleet/discover"); }, diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 7ac79a1b..ab71ed9f 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -26,6 +26,7 @@ export const queryKeys = { archetypes: ["archetypes"] as const, playbooks: ["playbooks"] as const, knowledge: ["knowledge"] as const, + flags: ["flags"] as const, }; // The fleet of workspace agents (ADR 0042). `running` is a live-pid probe, so poll @@ -37,6 +38,15 @@ export const fleetQuery = () => refetchInterval: 3_000, }); +// Developer flags (ADR 0068) — the active channel + resolved flag states. Static per +// process (channel + registry don't change without a config edit / restart), so no poll. +export const flagsQuery = () => + queryOptions({ + queryKey: queryKeys.flags, + queryFn: () => api.flags(), + staleTime: 5 * 60_000, + }); + // Archetypes for the new-agent picker (Basic + installed bundles) — config, not live. export const archetypesQuery = () => queryOptions({ diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index cd44cbdc..fbff4adc 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -776,3 +776,17 @@ export type Archetype = { bundle: string | null; // null = Basic; else the bundle git URL soul: string; // base SOUL.md the wizard seeds when this archetype is picked ("" = none) }; + +// Developer flags (ADR 0068) — the /api/flags payload the Developer panel renders. +export type FlagTier = "off" | "dev" | "beta" | "on"; +export type FlagChannel = "prod" | "beta" | "dev"; +export type FlagInfo = { + id: string; + description: string; + tier: FlagTier; + owner: string; + remove_by: string; + enabled: boolean; // channel-resolved (before any device-local override) + source: "channel" | "env"; +}; +export type FlagsPayload = { channel: FlagChannel; flags: FlagInfo[] }; diff --git a/apps/web/src/settings/DeveloperPanel.tsx b/apps/web/src/settings/DeveloperPanel.tsx new file mode 100644 index 00000000..2fd1d481 --- /dev/null +++ b/apps/web/src/settings/DeveloperPanel.tsx @@ -0,0 +1,88 @@ +import { Switch } from "@protolabsai/ui/forms"; +import { PanelHeader } from "@protolabsai/ui/navigation"; +import { Badge, Button, Empty } from "@protolabsai/ui/primitives"; + +import { + clearFlagOverride, + resetFlagOverrides, + setFlagOverride, + useFlag, + useFlagOverrides, + useFlags, +} from "../flags/flags"; +import type { FlagInfo, FlagTier } from "../lib/types"; + +// Settings → Developer (ADR 0068) — view + toggle pre-release feature flags for THIS +// device/session. Only surfaced off prod (a dev build / a non-prod channel / ?dev), so +// production users never see it. Toggles are device-local (they never touch shared config); +// the channel + tiers come from the server (/api/flags). + +const TIER_STATUS: Record = { + off: "neutral", + dev: "warning", + beta: "info", + on: "success", +}; + +function FlagRow({ flag }: { flag: FlagInfo }) { + const effective = useFlag(flag.id); + const override = useFlagOverrides((s) => s.overrides[flag.id]); + const overridden = override !== undefined; + return ( +
+
+ + {flag.id} {flag.tier} + {overridden ? overridden : null} + +

+ {flag.description} + {flag.owner ? · owner {flag.owner} : null} + {flag.remove_by ? · remove by {flag.remove_by} : null} +

+
+
+ setFlagOverride(flag.id, on)} + label={effective ? "on" : "off"} + /> + {overridden ? ( + + ) : null} +
+
+ ); +} + +export function DeveloperPanel() { + const { data } = useFlags(); + const overrideCount = useFlagOverrides((s) => Object.keys(s.overrides).length); + const flags = data?.flags ?? []; + const channel = data?.channel ?? "prod"; + return ( +
+ + Reset all + + } + /> +
+ {flags.length === 0 ? ( + + No developer flags are registered. Add one in runtime/flags.py. + + ) : ( + flags.map((f) => ) + )} +
+
+ ); +} diff --git a/apps/web/src/settings/SettingsSurface.tsx b/apps/web/src/settings/SettingsSurface.tsx index 7508fbc1..d83907f1 100644 --- a/apps/web/src/settings/SettingsSurface.tsx +++ b/apps/web/src/settings/SettingsSurface.tsx @@ -1,4 +1,4 @@ -import { BarChart3, Bot, BookMarked, Boxes, Brain, Cpu, Database, Gauge, Keyboard, KeyRound, MessageSquare, Network, Palette, Plug, Puzzle, Server, Sparkles, Store, Wrench } from "lucide-react"; +import { BarChart3, Bot, BookMarked, Boxes, Brain, Cpu, Database, FlaskConical, Gauge, Keyboard, KeyRound, MessageSquare, Network, Palette, Plug, Puzzle, Server, Sparkles, Store, Wrench } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { useEffect, type ReactNode } from "react"; @@ -17,6 +17,8 @@ import { DelegatesSection } from "./DelegatesSection"; import { FleetSurface } from "./FleetSurface"; import { KeybindingsPanel } from "./KeybindingsPanel"; import { ChatSettingsPanel } from "./ChatSettingsPanel"; +import { DeveloperPanel } from "./DeveloperPanel"; +import { developerPanelVisible, useDeveloperChannel } from "../flags/flags"; import { OverviewPanel } from "./OverviewPanel"; import { SettingsCategoryPanel } from "./SettingsCategory"; import { ThemeSurface } from "./ThemeSurface"; @@ -111,11 +113,18 @@ export function SettingsSurface({ initialSection }: { only?: "host" | "workspace if (initialSection) setSection(initialSection); }, [initialSection, setSection]); + // The Developer panel (ADR 0068) joins "This console" only off prod — a dev build, a + // non-prod channel, or an explicit ?dev/?flag: reveal — so production operators never see it. + const channel = useDeveloperChannel(); + const consoleSections: Section[] = developerPanelVisible(channel) + ? [...CONSOLE_SECTIONS, { id: "developer", label: "Developer", icon: FlaskConical, render: () => }] + : CONSOLE_SECTIONS; + const sections = [ ...AGENT_SECTIONS, ...CAPABILITY_SECTIONS, ...(onHost ? BOX_SECTIONS : []), - ...CONSOLE_SECTIONS, + ...consoleSections, ]; const active = sections.find((s) => s.id === persistedSection) ?? sections[0]; const toItem = (s: Section) => ({ id: s.id, label: s.label, icon: }); @@ -123,7 +132,7 @@ export function SettingsSurface({ initialSection }: { only?: "host" | "workspace { label: "Agent", items: AGENT_SECTIONS.map(toItem) }, { label: "Capabilities", items: CAPABILITY_SECTIONS.map(toItem) }, ...(onHost ? [{ label: "Box", items: BOX_SECTIONS.map(toItem) }] : []), - { label: "This console", items: CONSOLE_SECTIONS.map(toItem) }, + { label: "This console", items: consoleSections.map(toItem) }, ]; return ( From 4e12fe366b8a8a182323fdc794f9bddd8c3e6086 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:28:31 -0700 Subject: [PATCH 05/81] test(flags): fail CI on a flag past its remove_by (#1506, ADR 0068 slice 5) (#1541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup contract (ADR 0068 D6): a developer flag with an ISO-date remove_by in the past is overdue debt — graduate it to `on` and delete the flag + old code path. test_no_flag_is_past_its_remove_by fails CI so a stale gate is visible instead of accreting. Non-date remove_by values (e.g. a version) are skipped (can't be auto-compared). No-op today (FLAGS is empty); a guard for as the registry grows. Co-authored-by: Claude Opus 4.8 (1M context) --- tests/test_flags.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_flags.py b/tests/test_flags.py index 49cec8dd..819beb27 100644 --- a/tests/test_flags.py +++ b/tests/test_flags.py @@ -126,3 +126,23 @@ def test_real_registry_is_well_formed(): for f in flags.FLAGS: assert f.tier in ("off", "dev", "beta", "on"), f"{f.id}: invalid tier {f.tier!r}" assert f.id and f.description, "every flag needs an id + description" + + +def test_no_flag_is_past_its_remove_by(): + """The cleanup contract (ADR 0068 D6): a flag whose ISO-date `remove_by` has passed is + overdue debt — graduate it to `on` and delete the flag + the old code path. This guard + fails CI so a stale gate is visible instead of accreting. `remove_by` values that aren't + ISO dates (e.g. a version like "v2.0") can't be auto-compared, so they're skipped.""" + import datetime + + today = datetime.date.today().isoformat() + overdue = [] + for f in flags.FLAGS: + rb = (f.remove_by or "").strip() + try: + datetime.date.fromisoformat(rb) # only date-form remove_by is auto-checked + except ValueError: + continue + if rb < today: # ISO dates sort chronologically as strings + overdue.append(f"{f.id} (remove_by {rb}, owner {f.owner or '?'})") + assert not overdue, "overdue developer flags — graduate to `on` and delete: " + ", ".join(overdue) From 7a2b364655a74d12a810d113d614b75c06e0b4d2 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:31:26 -0700 Subject: [PATCH 06/81] =?UTF-8?q?fix(chat):=20disable=20streamdown=20line?= =?UTF-8?q?=20numbers=20=E2=80=94=20they=20render=20as=20a=20broken=20gutt?= =?UTF-8?q?er=20(#1542)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code blocks in chat rendered wonky (a huge left gutter, code shoved sideways, not responsive). Root cause: streamdown 2.5.0 defaults `lineNumbers: true` and renders the line-number gutter PURELY via `before:` Tailwind utility classes with NO `data-streamdown` hook. The console styles streamdown's markdown via the DS `@protolabsai/ui/markdown` attribute-selector CSS and deliberately purges streamdown's inert Tailwind classes — but the line-number gutter has no attribute selector to hook, so it can be neither themed by the DS nor generated by Tailwind → an unstyled, half-broken gutter. Line numbers are unthemeable in this model, so turn them off (`lineNumbers={false}`). Every other part of the code block (border, header, body, copy button, shiki colors) is already themed by the DS CSS and renders clean. Follow-up: the DS `` should default this off since it can't theme the gutter (protoContent gap). --- apps/web/src/chat/Markdown.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/chat/Markdown.tsx b/apps/web/src/chat/Markdown.tsx index 65a7564d..f73039f3 100644 --- a/apps/web/src/chat/Markdown.tsx +++ b/apps/web/src/chat/Markdown.tsx @@ -10,7 +10,18 @@ import { Markdown as DSMarkdown } from "@protolabsai/ui/markdown"; * * `className="markdown"` rides the same element the DS scopes as `.pl-markdown`, so existing * `.markdown` selectors (e2e + message-layout) keep matching. + * + * `lineNumbers={false}`: streamdown defaults line numbers ON, but renders them purely via + * `before:` Tailwind utilities with NO `data-streamdown` hook — so the DS's attribute-selector + * theming can't reach them and the console (which purges streamdown's inert Tailwind by design) + * leaves a broken, unstyled gutter that pushes code sideways. Code reads clean without them. + * (DS gap — the DS `` should default this off since it can't theme the gutter; + * file on protoContent.) */ export function Markdown({ children }: { children: string }) { - return {children}; + return ( + + {children} + + ); } From bfb5b6265e46bfb0002408a561dae4bc62f70532 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:31:47 -0700 Subject: [PATCH 07/81] docs(flags): developer-flags how-to guide (#1506, ADR 0068 slice 6a) (#1543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrap-and-delete workflow end to end: define a flag in runtime/flags.py, gate a backend path (flag_enabled) and console UI (useFlag), flip it via env / ?flag: / the Developer panel, and graduate+delete under the remove_by cleanup contract. Explains tiers × channel and why a flag is neither a plugin nor a setting. Wired into the Console & UI guides (sidebar + index) and nav.json regenerated. Co-authored-by: Claude Opus 4.8 (1M context) --- docs/.vitepress/config.mts | 1 + docs/guides/developer-flags.md | 101 +++++++++++++++++++++++++++++++++ docs/guides/index.md | 1 + plugins/docs/nav.json | 4 ++ 4 files changed, 107 insertions(+) create mode 100644 docs/guides/developer-flags.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 14830c8a..ebd62792 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -136,6 +136,7 @@ export default defineConfig({ items: [ { text: "Operator console (React/Tauri)", link: "/guides/react-tauri-ui" }, { text: "Command palette (⌘K)", link: "/guides/command-palette" }, + { text: "Developer flags (gate pre-release features)", link: "/guides/developer-flags" }, { text: "Access from your phone (LAN / Tailscale)", link: "/guides/phone-access" }, { text: "Run headless (API + A2A)", link: "/guides/headless" }, ], diff --git a/docs/guides/developer-flags.md b/docs/guides/developer-flags.md new file mode 100644 index 00000000..60b5b8e1 --- /dev/null +++ b/docs/guides/developer-flags.md @@ -0,0 +1,101 @@ +# Developer flags + +A **developer flag** is a temporary gate on a **pre-release** code path — a way to merge a +half-built feature to `main` (exercised internally, invisible in production) instead of parking +it on a long-lived branch that drifts. Flags are **local and static** (no A/B, no percentage +rollouts, no remote service) and they're meant to be **deleted** when the feature ships (ADR +0068). + +A flag is *not* a [plugin](/guides/plugins) (a permanent capability you install/enable) and *not* +a [setting](/guides/customize-and-deploy) (permanent user configuration). It's a developer's +switch on unfinished work, with a built-in expiry. + +## The model: tiers × channel + +Each flag declares a **tier** — its rollout stage — and enablement is that tier measured against +the runtime **channel**: + +| tier | who sees it | +|------|-------------| +| `off` | nobody (a kill switch) | +| `dev` | developers only | +| `beta` | opt-in preview users | +| `on` | everybody (shipped) | + +The **channel** is `prod ⊂ beta ⊂ dev` (dev sees the most) and is *derived*, not per-flag: + +- the **dev sandbox instance** (`PROTOAGENT_INSTANCE=dev`, see [multi-instance](/guides/multi-instance)) is `dev`; +- a Vite dev build (`import.meta.env.DEV`) is `dev` in the console; +- otherwise the **`developer.channel`** setting (`prod` | `beta` | `dev`, default `prod`) sets it. + +So a developer on the dev instance auto-sees `dev`-tier features; production sees only `on`. + +## 1. Define the flag + +Add one entry to the registry — the single source of truth: + +```python +# runtime/flags.py +FLAGS: list[Flag] = [ + Flag( + id="chat.new_dashboard", # dotted, stable — the lookup + override key + description="Redesigned dashboard (WIP).", + tier="dev", # off → dev → beta → on + owner="you@example.com", # who to ask + remove_by="2026-09-01", # ISO date (or a version) — the cleanup deadline + ), +] +``` + +## 2. Gate a backend path + +```python +from runtime.flags import flag_enabled + +if flag_enabled("chat.new_dashboard"): + ... # the new path +else: + ... # the shipped path +``` + +`flag_enabled` resolves **`PROTOAGENT_FLAG_` env override → tier-vs-channel → off** (an +unregistered id is off — fail-closed). + +## 3. Gate console UI + +```tsx +import { useFlag } from "../flags/flags"; + +function Panel() { + if (!useFlag("chat.new_dashboard")) return ; + return ; +} +``` + +`useFlag` layers two frontend overrides on top of the server's channel resolution: +**`?flag:=on|off` query param → device-local panel toggle → server state**. It's fail-closed +while `/api/flags` loads. + +## 4. Flip it while you work + +Without editing config or restarting: + +- **Env** — `PROTOAGENT_FLAG_CHAT_NEW_DASHBOARD=on` (headless / CI / a deploy). +- **Query param** — append `?flag:chat.new_dashboard=on` to the console URL (a shareable "try + this build" link; lasts the page load). +- **The Developer panel** — **Settings ▸ Developer** (shown off prod, or via `?dev`) lists every + flag with its tier + state and a per-device toggle. Overrides are device-local — they never + touch shared config. *Reset* returns a flag to its channel default. + +## 5. Graduate and delete + +When the feature ships, flip the tier to `on`, then — in **one PR** — **delete the flag entry and +the old code path**. A flag is a loan against future cleanup: `runtime/flags.py` is the loan book, +and `remove_by` is the due date. `tests/test_flags.py::test_no_flag_is_past_its_remove_by` **fails +CI** once a flag's ISO-date `remove_by` has passed, so a stale gate is visible debt rather than +silent accretion. + +## See also + +- [ADR 0068](/adr/0068-developer-flags-and-panel) — the design and the non-goals. +- [Multi-instance](/guides/multi-instance) — the dev sandbox instance that defaults to the `dev` channel. diff --git a/docs/guides/index.md b/docs/guides/index.md index 4127d0b6..7e436486 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -75,6 +75,7 @@ Surface the agent to people — the operator console, or no UI at all. |---|---| | [Operator console (React/Tauri)](/guides/react-tauri-ui) | You want the multi-chat React console and to package it for desktop | | [Command palette (⌘K)](/guides/command-palette) | You want the fast keyboard path to jump between surfaces + inline chat | +| [Developer flags](/guides/developer-flags) | You want to merge a half-built feature behind a tiered flag (off/dev/beta/on) instead of a long-lived branch | | [Access from your phone (LAN / Tailscale)](/guides/phone-access) | You want to drive the agent from your phone — installable PWA over your LAN or tailnet, add-to-home-screen | | [Run headless (API + A2A)](/guides/headless) | You want the agent as a service — REST + A2A — with no UI | diff --git a/plugins/docs/nav.json b/plugins/docs/nav.json index 4bcbe1db..a8bb2c5c 100644 --- a/plugins/docs/nav.json +++ b/plugins/docs/nav.json @@ -170,6 +170,10 @@ "path": "guides/command-palette.md", "title": "Command palette (⌘K)" }, + { + "path": "guides/developer-flags.md", + "title": "Developer flags (gate pre-release features)" + }, { "path": "guides/phone-access.md", "title": "Access from your phone (LAN / Tailscale)" From 822575435cbc0601c16125c790554757057115ec Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:18:42 -0700 Subject: [PATCH 08/81] ci(desktop): build on manual dispatch only, not every tag (#1547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS (10×) and Windows (2×) legs of desktop-build.yml were the repo's dominant CI cost — the full three-platform matrix fired on EVERY semver tag (~99 tags/month at current cadence), even though desktop drops don't need to track the tag firehose. Retire the `push: tags` trigger; desktop builds are now on-demand via workflow_dispatch. Two dispatch modes, keyed off the existing `tag` input: - WITH a tag (vX.Y.Z) → a real desktop RELEASE: binaries attach to that GitHub Release, the fan-in composes latest.json, and the release is promoted to 'Latest' (release.yml still creates releases --latest=false; promotion happens in the fan-in). This is the path that reaches users' in-app updater. - WITHOUT a tag → a test build: workflow artifacts only, no release/manifest. Tag pushes still ship the Docker image + a (non-Latest) GitHub Release via release.yml — only the desktop binaries wait for a dispatch. 'Latest' now tracks the last DESKTOP release (the one carrying latest.json), so the in-app updater never 404s on a manifest-less release. Also: the "unsigned release must fail" guard now keys on `inputs.tag` (a tagged publish still requires the full Apple secret set); fan-in checkout/TAG read from `inputs.tag` instead of github.ref_name. Docs updated (releasing.md § Desktop, apps/desktop/README.md, react-tauri-ui.md). Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/desktop-build.yml | 79 ++++++++++++++++------------- .github/workflows/release.yml | 13 ++--- apps/desktop/README.md | 9 +++- docs/guides/react-tauri-ui.md | 4 +- docs/guides/releasing.md | 46 +++++++++++------ 5 files changed, 91 insertions(+), 60 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 066762bf..ebbcffbd 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -19,26 +19,35 @@ name: Desktop Build # updater bundles, and the updater-manifest fan-in job composes latest.json # (the manifest the in-app updater polls) onto the release — uploaded last. # -# Fires on EVERY semver tag push (patch included) alongside release.yml's Docker build, -# and on manual dispatch. Patches used to be skipped to save CI minutes, but this repo is -# public so GitHub-hosted runners (incl. the 10×/2× macOS/Windows legs) are free + -# unlimited — so every release, patch or minor, ships fresh desktop binaries and an -# updated latest.json (no more hand-attaching a patch's binaries). macOS builds are signed -# only when the full Apple secret set is present; Linux/Windows are always unsigned. See -# ADR 0010 (UI tiers / desktop). +# Runs on MANUAL DISPATCH ONLY (workflow_dispatch) — deliberately NOT on tag pushes. +# The macOS (10×) and Windows (2×) legs are this repo's only paid CI, and building the +# full matrix on EVERY semver tag (dozens/month at current release cadence) was the +# dominant cost — so desktop drops are now on-demand instead of per-release. Tag pushes +# still ship the Docker image + a (non-Latest) GitHub Release via release.yml; only the +# desktop binaries wait for a dispatch. +# +# Two dispatch modes, keyed off the `tag` input: +# • WITH a `tag` (a vX.Y.Z) → a real desktop RELEASE: binaries attach to that GitHub +# Release, the fan-in composes latest.json, and the release is promoted to 'Latest' +# (release.yml creates releases --latest=false; the promotion happens here). This is +# how a desktop update reaches users' in-app updater. +# • WITHOUT a tag (dispatched from a branch) → a TEST build: bundles upload as workflow +# artifacts only; no release, no latest.json, no 'Latest' change. +# +# macOS builds are signed only when the full Apple secret set is present; Linux/Windows +# are always unsigned. The repo guard keeps forks from running the matrix. See ADR 0010 +# (UI tiers / desktop) and docs/guides/releasing.md § Desktop. on: - push: - tags: - - 'v*.*.*' workflow_dispatch: inputs: tag: - description: 'Tag/ref to build against (blank = current ref)' + description: 'Tag to publish a desktop release for (vX.Y.Z). Blank = test build (artifacts only, no release).' required: false concurrency: - group: desktop-build-${{ github.ref }} + # Keyed on the dispatched tag (or ref) so two release dispatches don't collide. + group: desktop-build-${{ inputs.tag || github.ref }} cancel-in-progress: false permissions: @@ -47,11 +56,9 @@ permissions: jobs: build: name: ${{ matrix.target }} - # Build on every semver tag push (patch included) + any manual dispatch. The old - # patch-skip was a CI-cost guard; it's unnecessary on a public repo where GH-hosted - # runners are free, and it meant patch desktop fixes never reached users (their - # binaries weren't attached and latest.json kept pointing at the last minor). The - # repo guard keeps forks from running the matrix. + # Manual dispatch only (see the `on:` block) — the tag-push trigger was retired + # because the macOS/Windows matrix on every release was the repo's dominant CI cost. + # The repo guard keeps forks from running the matrix. if: github.repository == 'protoLabsAI/protoAgent' # Per-leg runner, overridable by repo variable so the expensive macOS/Windows # legs can move off GitHub-hosted runners (10×/2× billing multipliers) onto @@ -199,8 +206,8 @@ jobs: SIGNED="unsigned (dev)" if [ "${RUNNER_OS}" = "macOS" ]; then # tauri-bundler signs as soon as *any* Apple secret is set, then fails - # if the set is incomplete. Require the full set; otherwise unset all so - # a dispatch build falls back cleanly to unsigned. + # if the set is incomplete. Require the full set for a tagged (publish) + # dispatch; a test build (no tag) unsets all and falls back to unsigned. if [ -n "${APPLE_CERTIFICATE}" ] && [ -n "${APPLE_CERTIFICATE_PASSWORD}" ] \ && [ -n "${APPLE_SIGNING_IDENTITY}" ] && [ -n "${APPLE_API_ISSUER}" ] \ && [ -n "${APPLE_API_KEY}" ] && [ -n "${APPLE_API_KEY_BASE64}" ] \ @@ -210,8 +217,8 @@ jobs: python3 -c 'import base64,os,sys; open(sys.argv[1],"wb").write(base64.b64decode(os.environ["APPLE_API_KEY_BASE64"]))' "$KEYFILE" export APPLE_API_KEY_PATH="${KEYFILE}" SIGNED="Developer ID (signed + notarized)" - elif [ "${{ github.event_name }}" = "push" ]; then - echo "::error::Semver-tag desktop releases require the full Apple signing secret set." + elif [ -n "${{ inputs.tag }}" ]; then + echo "::error::A tagged desktop release (tag=${{ inputs.tag }}) requires the full Apple signing secret set." exit 2 else unset APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \ @@ -335,15 +342,17 @@ jobs: if [ "${{ steps.build.outputs.signed }}" = "true" ]; then FLAG="--require-signed"; fi scripts/verify-macos-desktop.sh $FLAG dist-desktop/*.dmg - - name: Upload dispatch artifact - if: github.event_name == 'workflow_dispatch' + - name: Upload test-build artifact + # No tag → a test build: bundles are downloadable workflow artifacts only. + if: inputs.tag == '' uses: actions/upload-artifact@v6 with: name: protoAgent-${{ steps.version.outputs.version }}-${{ matrix.target }} path: dist-desktop/* - name: Attach to release - if: github.event_name == 'push' + # A tag → publish: attach binaries to that GitHub Release (fan-in adds latest.json). + if: inputs.tag != '' shell: bash env: GH_TOKEN: ${{ github.token }} @@ -351,29 +360,27 @@ jobs: # Fan-in: compose latest.json from every leg's signed updater bundle and # upload it LAST, so the manifest never points at assets that don't exist - # yet. Runs only on semver tags, and only once ALL legs succeeded (a partial - # manifest would skew versions across platforms). If the release carries no - # updater bundles (key wasn't present), it skips — in-app updates are simply - # disabled for that release. + # yet. Runs only for a tagged (publish) dispatch, and only once ALL legs + # succeeded (a partial manifest would skew versions across platforms). If the + # release carries no updater bundles (key wasn't present), it skips — in-app + # updates are simply disabled for that release. updater-manifest: name: updater manifest (latest.json) needs: build - # Runs on every tag push (patch included) so the in-app updater's latest.json is - # refreshed for every release. Gated to `push` so a manual dispatch (which builds - # artifacts, with no release to attach to) doesn't try to compose a manifest; - # `needs: build` ties it to a successful matrix. + # Only for a publish dispatch (a `tag` was supplied) — a test build (no tag) has no + # release to attach a manifest to. `needs: build` ties it to a successful matrix. if: >- - github.event_name == 'push' && github.repository == 'protoLabsAI/protoAgent' + inputs.tag != '' && github.repository == 'protoLabsAI/protoAgent' runs-on: ubuntu-latest # workspace-config: allow-hosted-runner public repo — GH-hosted is free timeout-minutes: 10 steps: # Checkout at the tag so we can read the rolled CHANGELOG.md section for the - # in-app updater notes (below). By tag-push time CHANGELOG.md already holds the + # in-app updater notes (below). By publish time CHANGELOG.md already holds the # dated `## [VERSION]` section — the bump PR (prepare-release.yml) merged before # the tag. Without this the job has no working tree (it only `gh release`s). - uses: actions/checkout@v5 with: - ref: ${{ github.ref_name }} + ref: ${{ inputs.tag }} - name: Setup Node uses: actions/setup-node@v5 @@ -386,7 +393,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG="${{ github.ref_name }}" + TAG="${{ inputs.tag }}" VERSION="${TAG#v}" mkdir -p updater-dist gh release download "$TAG" --repo "${{ github.repository }}" --dir updater-dist \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81badd20..5163d428 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,13 +145,14 @@ jobs: NOTES: ${{ steps.notes.outputs.notes }} run: | printf '%s' "$NOTES" > "$RUNNER_TEMP/release-notes.md" - # --latest=false: do NOT promote to 'Latest' yet. The in-app updater fetches + # --latest=false: do NOT promote to 'Latest'. The in-app updater fetches # releases/latest/download/latest.json, so a release must not become 'Latest' - # until its latest.json exists — otherwise update checks 404 in the window - # before the desktop build's fan-in uploads it. The desktop fan-in flips this - # release to 'Latest' once latest.json is up (desktop-build.yml). Patch tags - # skip the desktop build entirely, so they stay non-Latest and 'Latest' - # correctly remains on the last .0 (the one carrying latest.json). + # until its latest.json exists — otherwise update checks 404. Desktop builds + # are now MANUAL (desktop-build.yml runs on workflow_dispatch, not on tags), so + # every tagged release stays non-Latest here; it's promoted to 'Latest' only + # when someone dispatches a desktop build for that tag and its fan-in uploads + # latest.json. 'Latest' therefore tracks the last DESKTOP release (the one the + # updater can actually use), not the newest tag. gh release create "${{ steps.version.outputs.tag }}" \ --title "${{ steps.version.outputs.tag }}" \ --notes-file "$RUNNER_TEMP/release-notes.md" \ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 5ed1b7ed..9cf653ea 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -54,8 +54,13 @@ relaunches — agent data (`PROTOAGENT_CONFIG_DIR`, workspaces) is untouched. ## Platforms & CI -`.github/workflows/desktop-build.yml` builds all three platforms on semver tags -(and on manual dispatch, which uploads workflow artifacts instead): +`.github/workflows/desktop-build.yml` builds all three platforms on **manual dispatch +only** (`workflow_dispatch`) — the tag-push trigger was retired because the macOS (10×) +and Windows (2×) legs were the repo's dominant CI cost. Dispatch **with** a `tag` input +(`gh workflow run desktop-build.yml -f tag=vX.Y.Z`) publishes a real release: binaries +attach to that GitHub Release, `latest.json` is composed, and the release is promoted to +`Latest`. Dispatch **without** a tag is a test build (workflow artifacts only). See +`docs/guides/releasing.md` § Desktop. | Platform | Artifact | Signing | |---|---|---| diff --git a/docs/guides/react-tauri-ui.md b/docs/guides/react-tauri-ui.md index ed843644..b6a3b806 100644 --- a/docs/guides/react-tauri-ui.md +++ b/docs/guides/react-tauri-ui.md @@ -126,7 +126,9 @@ PyInstaller-freezes the headless server (`binaries/protoagent-server-`), frozen build bundles the `plugins/` tree and `--collect-all`s `tools`/`websockets`/`mcp` (plugins load by file path, which PyInstaller's scan misses; a runtime-installed comms plugin, ADR 0058, can only import what's bundled). Signed macOS DMG / Linux AppImage+deb / -Windows NSIS artifacts + an in-app updater ship from the desktop-build CI on release tags. +Windows NSIS artifacts + an in-app updater ship from the desktop-build CI, dispatched +manually per release (`gh workflow run desktop-build.yml -f tag=vX.Y.Z`) — see +`docs/guides/releasing.md` § Desktop. On macOS, `spawn_sidecar` augments the sidecar's `PATH` with the user's login-shell `PATH` (via `$SHELL -ilc`, plus the Homebrew/local fallbacks) before spawning. A Finder/Dock launch diff --git a/docs/guides/releasing.md b/docs/guides/releasing.md index 14c2bc4e..c330d6ac 100644 --- a/docs/guides/releasing.md +++ b/docs/guides/releasing.md @@ -22,21 +22,37 @@ you merge the PR (CI green) ──▶ you push tag vX.Y.Z ──▶ Release `latest` Docker tag is pushed on every `main` merge by `docker-publish.yml` — independent of releases. -**Every** semver tag push (patch included) also triggers `desktop-build.yml`, which -builds the desktop app on a three-platform matrix and attaches the artifacts to the -same GitHub Release: the macOS `.dmg` (signed + notarized — requires the full Apple -secret set, the leg fails otherwise), the Linux `.AppImage` + `.deb`, and the Windows -NSIS `-setup.exe` (both unsigned). See `apps/desktop/README.md` § Platforms & CI. - -> **Patches ship desktop binaries too** (changed 2026-06-19). They used to skip the -> desktop build to save CI minutes, but this repo is public so GitHub-hosted runners -> (incl. the 10×/2× macOS/Windows legs) are free + unlimited — so every release, patch -> or minor, publishes fresh desktop binaries and refreshes `latest.json`. (The old -> skip also meant a patch's desktop fix never reached users — binaries weren't attached -> and the in-app updater stayed on the last minor.) -When the org updater signing key is present, the legs also attach signed updater -bundles and a fan-in job uploads `latest.json` — the manifest the desktop app's -in-app updater polls. See `apps/desktop/README.md` § Updates. +### Desktop + +Desktop builds are **manual** — `desktop-build.yml` runs on `workflow_dispatch` only, +**not** on tag pushes. The macOS (10×) and Windows (2×) legs are the repo's only paid +CI, and building the full matrix on every tag (dozens/month) was the dominant cost, so +desktop drops are on-demand. A normal `git push` of a tag still ships the Docker image +and a GitHub Release (via `release.yml`); only the desktop binaries wait for a dispatch. + +To cut a desktop release, dispatch `desktop-build.yml` with the **tag** input set to the +release tag (`vX.Y.Z`): + +```sh +gh workflow run desktop-build.yml -f tag=vX.Y.Z +``` + +That builds the three-platform matrix and attaches the artifacts to that GitHub Release: +the macOS `.dmg` (signed + notarized — requires the full Apple secret set, the leg fails +otherwise), the Linux `.AppImage` + `.deb`, and the Windows NSIS `-setup.exe` (both +unsigned). When the org updater signing key is present, the legs also attach signed +updater bundles and a fan-in job uploads `latest.json` (the manifest the in-app updater +polls) and promotes the release to **Latest**. See `apps/desktop/README.md` §§ Platforms +& CI / Updates. + +> **Dispatching without a tag** (from a branch) is a **test build**: bundles upload as +> workflow artifacts only — no release, no `latest.json`, no `Latest` change. + +> **`Latest` tracks the last desktop release, not the newest tag.** `release.yml` creates +> every release `--latest=false`; a release is promoted to `Latest` only when its desktop +> build's fan-in has uploaded `latest.json`, so the in-app updater never 404s on a release +> that has no manifest. Tags you never build desktop for stay non-Latest (their Docker +> image and notes are still published). > **Runner cost.** Every other workflow runs on Namespace; the desktop matrix is > the only GitHub-hosted usage (macOS bills at 10×, Windows 2×). To move a leg onto From a1e8d985d5694ffa25f9e2423bf28e9970f32c3f Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:35:30 -0700 Subject: [PATCH 09/81] fix(wait): humanize wait/yield tool output so agents don't echo raw system text (#1536) (#1548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wait tool returned raw "Yielding for 300s — turn ending now. You'll be re-invoked at … to: ", which agents recited verbatim into chat as a raw system/engineering message. Return a concise, human-friendly summary instead — "Wait scheduled: 5 minutes. Will resume to: ." — via a new _humanize_duration helper (300s → "5 minutes"), and add docstring guidance telling the agent not to echo the return value verbatim but to paraphrase it conversationally. The yield/re-invoke scheduling mechanism is unchanged. Also refreshes the now-stale "Yielding…" references in server/chat.py's _last_tool_text docstring and the wait-yield test fixtures. Co-authored-by: Claude Opus 4.8 (1M context) --- server/chat.py | 4 +-- tests/test_chat_nonstreaming_robustness.py | 2 +- tests/test_wait_yield.py | 10 +++--- tools/lg_tools.py | 39 +++++++++++++++++++--- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/server/chat.py b/server/chat.py index ed0c548e..7996dc8c 100644 --- a/server/chat.py +++ b/server/chat.py @@ -389,8 +389,8 @@ async def _clear_pending_interrupt(config: dict) -> None: def _last_tool_text(result) -> str: """The last tool result's text in a turn — the fallback when a turn produced - no assistant text (e.g. a ``wait`` yield, whose 'Yielding…' confirmation is a - ToolMessage, not an AIMessage).""" + no assistant text (e.g. a ``wait`` yield, whose 'Wait scheduled…' confirmation + is a ToolMessage, not an AIMessage).""" from langchain_core.messages import ToolMessage for msg in reversed((result or {}).get("messages", [])): diff --git a/tests/test_chat_nonstreaming_robustness.py b/tests/test_chat_nonstreaming_robustness.py index 94761e42..373fd8c4 100644 --- a/tests/test_chat_nonstreaming_robustness.py +++ b/tests/test_chat_nonstreaming_robustness.py @@ -107,4 +107,4 @@ async def test_wait_yield_turn_falls_back_to_tool_text(monkeypatch): ) out = await chat("wait a bit then resume", "sessC") content = out[0]["content"] - assert content and "Yielding" in content # not a blank reply + assert content and "Wait scheduled" in content # not a blank reply diff --git a/tests/test_wait_yield.py b/tests/test_wait_yield.py index a45e02e1..35d03010 100644 --- a/tests/test_wait_yield.py +++ b/tests/test_wait_yield.py @@ -25,14 +25,14 @@ def _tool_msg(name: str, content: str = "ok", status: str | None = None) -> Tool def test_just_waited_true_after_successful_wait(): - msgs = [HumanMessage("go"), AIMessage("…"), _tool_msg("wait", "Yielding for 40s …")] + msgs = [HumanMessage("go"), AIMessage("…"), _tool_msg("wait", "Wait scheduled: 40 seconds …")] assert _just_waited(msgs) is True def test_just_waited_false_on_fresh_turn(): # A new stimulus is the trailing message — wait may be deep in history but # didn't just run. - msgs = [_tool_msg("wait", "Yielding …"), AIMessage("done"), HumanMessage("new task")] + msgs = [_tool_msg("wait", "Wait scheduled: …"), AIMessage("done"), HumanMessage("new task")] assert _just_waited(msgs) is False @@ -43,7 +43,7 @@ def test_just_waited_false_for_other_tools(): def test_just_waited_true_with_parallel_tools(): # wait alongside another tool in the same step still yields. - msgs = [AIMessage("…"), _tool_msg("st_ship", "ok"), _tool_msg("wait", "Yielding …")] + msgs = [AIMessage("…"), _tool_msg("st_ship", "ok"), _tool_msg("wait", "Wait scheduled: …")] assert _just_waited(msgs) is True @@ -56,7 +56,7 @@ def test_just_waited_false_when_wait_errored(): def test_middleware_jumps_to_end_only_after_wait(): mw = WaitYieldMiddleware() - waited = {"messages": [AIMessage("…"), _tool_msg("wait", "Yielding …")]} + waited = {"messages": [AIMessage("…"), _tool_msg("wait", "Wait scheduled: …")]} assert mw.before_model(waited, None) == {"jump_to": "end"} fresh = {"messages": [HumanMessage("hello")]} @@ -101,7 +101,7 @@ async def test_wait_schedules_a_one_shot_in_the_future(): fire = datetime.fromisoformat(schedule) # parses ISO-8601 (one-shot) delta = (fire - datetime.now(UTC)).total_seconds() assert 30 < delta <= 41 # ~40s out - assert "Dock and sell ore." in out and "re-invoked" in out + assert "Dock and sell ore." in out and "resume" in out.lower() @pytest.mark.asyncio diff --git a/tools/lg_tools.py b/tools/lg_tools.py index 1fd20206..427df031 100644 --- a/tools/lg_tools.py +++ b/tools/lg_tools.py @@ -806,6 +806,27 @@ async def forget_memory(chunk_id: int, reason: str = "") -> str: # instances on the same machine never see each other's jobs. +def _humanize_duration(seconds: int) -> str: + """Turn a raw second count into a short, human phrase (300 -> "5 minutes"). + + Keeps the ``wait`` confirmation conversational so the agent can relay it + naturally instead of parroting a machine-y "300s / ISO timestamp".""" + s = max(1, int(seconds)) + if s < 60: + return f"{s} second{'s' if s != 1 else ''}" + mins, secs = divmod(s, 60) + if mins < 60: + parts = [f"{mins} minute{'s' if mins != 1 else ''}"] + if secs: + parts.append(f"{secs} second{'s' if secs != 1 else ''}") + return " ".join(parts) + hours, mins = divmod(mins, 60) + parts = [f"{hours} hour{'s' if hours != 1 else ''}"] + if mins: + parts.append(f"{mins} minute{'s' if mins != 1 else ''}") + return " ".join(parts) + + def _build_scheduler_tools(scheduler) -> list: """Bind scheduler tools to a ``SchedulerBackend``. Returns a list.""" @@ -930,9 +951,13 @@ async def wait(seconds: int, then: str, state: Annotated[Any, InjectedState] = N contract." This is your only context when you wake, so be specific about what to do and which entities are involved. - Returns a confirmation with the resume time. For an absolute time or a - recurring schedule use ``schedule_task`` instead — ``wait`` is for - "yield for a bit, then pick this back up". + Returns a short confirmation of the scheduled wait. Do NOT echo this + return value verbatim into chat — it's a status line for you, not a reply + to the user. If you say anything, paraphrase it conversationally and + briefly (e.g. "Okay, I'll check back in about 5 minutes."); never paste + the raw string or an ISO timestamp. For an absolute time or a recurring + schedule use ``schedule_task`` instead — ``wait`` is for "yield for a + bit, then pick this back up". """ if not (then or "").strip(): return "Error: `then` is required — describe what to do on resume." @@ -949,10 +974,14 @@ async def wait(seconds: int, then: str, state: Annotated[Any, InjectedState] = N # LangGraph, which silently dropped this resume to the Activity thread. ctx = _session_id_from(state) or None try: - job = await asyncio.to_thread(scheduler.add_job, then, when, context_id=ctx) + await asyncio.to_thread(scheduler.add_job, then, when, context_id=ctx) except Exception as exc: # noqa: BLE001 return f"Error: couldn't schedule the wake-up: {exc}" - return f"Yielding for {secs}s — turn ending now. You'll be re-invoked at {job.next_fire or when} to: {then}" + # Concise, human-friendly summary — the agent should paraphrase this, not + # echo it (the old ISO timestamp / "re-invoked at" phrasing read as raw + # system text when parroted into chat). Machine-relevant facts stay intact: + # the humanized duration and the resume instruction. + return f"Wait scheduled: {_humanize_duration(secs)}. Will resume to: {then}" return [schedule_task, list_schedules, cancel_schedule, wait] From 059af47721bdd95c8eee05ea6c409f141ec0f1d7 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:01:22 -0700 Subject: [PATCH 10/81] docs(proto): fully-isolate throwaway test servers (own box root), not just an instance id (#1553) When an agent boots a local server for someone to test against while their real instance(s) are running, sharing only the box root (plain dev.sh) is data-safe but trips the desktop's co-residence warning (#1552) and collides on box-level resources (mDNS, scheduler owner-lock). Document the fully-isolated boot (PROTOAGENT_BOX_ROOT + PROTOAGENT_INSTANCE + free port), its config-inheritance tradeoff, and the worktree dist-serving note (_bundle_root anchors to the loaded server package). Co-authored-by: Claude Opus 4.8 (1M context) --- PROTO.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/PROTO.md b/PROTO.md index 31cd01c3..7bf12fc1 100644 --- a/PROTO.md +++ b/PROTO.md @@ -22,6 +22,18 @@ TypeScript is the console. chat/tasks/knowledge. The default instance is `~/.protoagent/default/` on `:7870`, untouched. `scripts/dev-reset.sh` wipes just the sandbox. Use this for feature testing instead of the default instance. +- **Spinning up a throwaway test server while the user's real instance(s) run + (e.g. an agent booting a PR build for review): FULLY isolate it — own box root + too, not just an instance id.** Plain `dev.sh` shares the box root (`~/.protoagent`), + which is data-safe but trips the desktop's co-residence warning (#1552) and can + collide on box-level resources (mDNS advertise, scheduler owner-lock). Instead: + `PROTOAGENT_BOX_ROOT=/tmp/pa- PROTOAGENT_INSTANCE= python -m server + --port ` — nothing under `~/.protoagent` is shared or touched. Tradeoff: a + fresh box root does **not** inherit box config (`host-config.yaml` gateway/model + defaults), so seed a gateway in that instance if the test needs model-backed + features; pure-console/UI review works as-is. (Serving a worktree's own + `apps/web/dist`: `cd && … python -m server` — `_bundle_root()` anchors + to the loaded `server/` package, so it serves that checkout's build.) - **Factory-reset the default instance:** `scripts/reset.sh` wipes the **prod** instance back to a clean slate (next boot runs the setup wizard) — for testing the fresh-user flow via CLI (there is no in-app reset). **Always `--dry-run` first** to From 72c600e4a264d95de37ba8e39e69c7b2558226f4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:01:42 -0700 Subject: [PATCH 11/81] fix(fleet): recover gracefully when the focused agent is down instead of bricking the console (#1554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a fleet member that fails to start (or navigating to a stopped one) left the console stuck at the boot gate: the focused agent's slug routes EVERY API call through the hub proxy, which returns 409 "agent not running" for a down member, so the boot probe just retried ~30× into a generic "isn't responding" gate whose only escape ("Continue anyway") opened a fully 409-broken app. No indication it was the ONE agent, and no way back to a working console. Now: when a NON-host slug keeps 409'ing past a normal spawn window (≥6 retries ≈ ~6s), the gate switches to targeted recovery — "Agent '' isn't running" with **Return to host** (navigate to the host console, which always works) and **Try to start it** (re-activate + refetch). Uses TanStack Query v5 `failureReason`/`failureCount` so it appears DURING retries, not only after the probe gives up. host / 502 / cold-start paths are unchanged (502 stays a cold-start retry, not a down-agent — a booting proxy will come up). + `isAgentNotRunning(err)` helper (409-only, distinct from `isColdStart` which also rides 502/no-response) + unit tests. Build + 218 unit tests green. NOTE: builds + typechecks; the recovery UI itself wants a quick manual check — point a window at a stopped agent's slug (/app/agent//) and confirm the card + both buttons. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/app/App.tsx | 61 ++++++++++++++++++++++++++++++------ apps/web/src/lib/api.test.ts | 22 ++++++++++++- apps/web/src/lib/api.ts | 8 +++++ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 82a999c7..201fb7f5 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -87,7 +87,7 @@ import { type RightPanel, type Surface, } from "../state/uiStore"; -import { api, apiUrl, authToken, is401 } from "../lib/api"; +import { api, apiUrl, authToken, is401, isAgentNotRunning, currentSlug, agentHref, activateSlugAgent } from "../lib/api"; import { PluginView, consoleTheme } from "./PluginView"; import { UtilityWidget } from "./UtilityWidget"; import { AppShell, Header, UtilityBar } from "@protolabsai/ui/app-shell"; @@ -368,6 +368,19 @@ export function App() { // copy/escape-hatch swap. STUCK_AFTER_MS=45s — past it, offer "Continue anyway" // so a graph that never compiles can't trap the operator on the loading screen. const bootFailed = !runtime && runtimeQ.isError; + // Focused fleet agent is DOWN (ADR 0042): a non-host slug whose boot probe keeps 409'ing + // ("agent not running") past a normal spawn window — `activate` didn't bring it up (or it + // failed to start). Without this the operator waited out the full ~30 retries for a generic + // "isn't responding" gate whose "Continue anyway" just opens a 409-broken app. Detect it + // early (≥6 retries ≈ ~6s at the 1s delay) and offer targeted recovery: return to the host + // console, or try starting the agent again. `failureReason`/`failureCount` are live during + // retries (TanStack Query v5), so recovery shows before the probe fully gives up. + const focusedSlug = currentSlug(); + const agentDown = + focusedSlug !== "host" && + !runtime && + isAgentNotRunning(runtimeQ.failureReason) && + runtimeQ.failureCount >= 6; // Token-gated first run (#873): the boot probe itself 401s. The BootGate's // "Starting… / isn't responding" copy is wrong for that — and its overlay // (z-1900) would cover the AuthGate dialog (z-1000) — so the gate yields to @@ -742,19 +755,47 @@ export function App() { } title={ - bootFailed - ? `${bootName} isn’t responding` - : `Starting ${bootName}…` + agentDown + ? `Agent “${focusedSlug}” isn’t running` + : bootFailed + ? `${bootName} isn’t responding` + : `Starting ${bootName}…` } detail={ - bootFailed - ? "The engine didn’t come up in time. It may still be warming up — give it another moment, then retry." - : bootStuck - ? "This is taking longer than usual. The engine may still be compiling, or it may need attention in Settings." - : "Warming up the engine — first launch (and finishing setup) can take up to a minute. Later launches are quick." + agentDown + ? "This fleet agent didn’t start. Return to the host console to keep working, or try starting it again." + : bootFailed + ? "The engine didn’t come up in time. It may still be warming up — give it another moment, then retry." + : bootStuck + ? "This is taking longer than usual. The engine may still be compiling, or it may need attention in Settings." + : "Warming up the engine — first launch (and finishing setup) can take up to a minute. Later launches are quick." } action={ - bootFailed ? ( + agentDown ? ( +
+ + +
+ ) : bootFailed ? ( diff --git a/apps/web/src/lib/api.test.ts b/apps/web/src/lib/api.test.ts index 6afa330d..16fe4f71 100644 --- a/apps/web/src/lib/api.test.ts +++ b/apps/web/src/lib/api.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from "vitest"; -import { ApiError, apiUrl, drainSseBuffer, frameIsForeign, isColdStart, textFromParts, hitlFromParts } from "./api"; +import { + ApiError, + apiUrl, + drainSseBuffer, + frameIsForeign, + isColdStart, + isAgentNotRunning, + textFromParts, + hitlFromParts, +} from "./api"; describe("cold-start detection (ApiError / isColdStart)", () => { it("ApiError carries the HTTP status", () => { @@ -27,6 +36,17 @@ describe("cold-start detection (ApiError / isColdStart)", () => { }); }); +describe("focused-agent-down detection (isAgentNotRunning)", () => { + it("true ONLY for a 409 (the fleet proxy's 'agent not running')", () => { + expect(isAgentNotRunning(new ApiError(409, "agent 'x' is not running"))).toBe(true); + // 502 is a proxy/boot hiccup, not a definitively-down agent — stays a cold-start retry, not recovery. + expect(isAgentNotRunning(new ApiError(502, "not reachable"))).toBe(false); + expect(isAgentNotRunning(new ApiError(404, "nope"))).toBe(false); + expect(isAgentNotRunning(new TypeError("Load failed"))).toBe(false); + expect(isAgentNotRunning(undefined)).toBe(false); + }); +}); + const HITL_MIME = "application/vnd.protolabs.hitl-v1+json"; function drain(buffer: string) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c377ac2c..6bf5ec9e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -313,6 +313,14 @@ export function isColdStart(error: unknown): boolean { return true; // no HTTP response at all ⇒ not reachable yet (desktop sidecar booting) } +/** The fleet proxy's "agent isn't running/registered" signal (ADR 0042): a 409 from a + * slug-routed call. Distinct from `isColdStart` (which also rides 502/no-response) — this + * is specifically "the focused fleet agent is down", used to offer a return-to-host recovery + * once it persists past a normal spawn window instead of the generic "isn't responding" gate. */ +export function isAgentNotRunning(error: unknown): boolean { + return error instanceof ApiError && error.status === 409; +} + /** True for a 401 from request() — retrying can't help until the operator supplies * a token (#873); the AuthGate owns recovery. */ export function is401(error: unknown): boolean { From e238c179ce2e5f2d28dffffb8390620b56271662 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:06:59 -0700 Subject: [PATCH 12/81] =?UTF-8?q?feat(chat):=20slash-command=20UX=20?= =?UTF-8?q?=E2=80=94=20mid-input=20trigger,=20popover=20auto-scroll,=20com?= =?UTF-8?q?mand=20bubbles=20(#1530,=20#1528,=20#1529)=20(#1549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1530: the slash popover now triggers MID-INPUT at any caret position (not only when "/" is char 0), via a caret-aware slashTokenAt parser + native caret listeners; completing inserts/removes only the token so surrounding text is preserved at start/middle/end. #1528: the keyboard-focused item auto-scrolls into view (scrollIntoView block:nearest tied to the active index). #1529: an issued (sent) slash command renders as a distinct user bubble (violet tint + monospace + "/" badge), detected via slashCommandName which ignores file paths. Review fix: slashTokenAt now returns the token's true `end` (scan to next whitespace), so completing with the caret in the MIDDLE of a token replaces the whole token instead of leaving a trailing suffix. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/chat/ChatMessageView.tsx | 28 ++++++++- apps/web/src/chat/ChatSurface.tsx | 87 ++++++++++++++++++++++---- apps/web/src/chat/chat.css | 35 +++++++++++ apps/web/src/ext/slashRegistry.test.ts | 60 +++++++++++++++++- apps/web/src/ext/slashRegistry.ts | 32 ++++++++++ 5 files changed, 225 insertions(+), 17 deletions(-) diff --git a/apps/web/src/chat/ChatMessageView.tsx b/apps/web/src/chat/ChatMessageView.tsx index 6a005d4e..5e2511ae 100644 --- a/apps/web/src/chat/ChatMessageView.tsx +++ b/apps/web/src/chat/ChatMessageView.tsx @@ -5,6 +5,7 @@ import { Spinner } from "@protolabsai/ui/data"; import { ArrowDownToLine, Check, Clock, Coins, Copy, GitBranch, Gauge, Maximize2, RotateCcw } from "lucide-react"; import { openDocument } from "../docviewer"; +import { slashCommandName } from "../ext/slashRegistry"; import { api } from "../lib/api"; import { useUI } from "../state/uiStore"; import type { ChatMessage, ChatPart, ContextWindow, TurnUsage } from "../lib/types"; @@ -43,6 +44,24 @@ export function ChatMessageView({ const streaming = message.status === "streaming"; // Per-turn token/cost footer is an opt-out display pref (Settings ▸ Chat, #1372). const showChatUsage = useUI((s) => s.showChatUsage); + // An issued slash command (/goal …) renders as a distinct user bubble (subtle tint / + // monospace / "/" badge) so it reads as a command in history, to operator and agent (#1529). + const userSlashCmd = message.role === "user" ? slashCommandName(message.content) : null; + // Literal user text — a monospace "/command" chip when it's a slash command, else plain + // whitespace-preserving text. Shared by the ordered-parts and history render paths. + const renderUserText = (text: string, key?: string) => + slashCommandName(text) ? ( + + + / + + {text.replace(/^\s*\//, "")} + + ) : ( + + {text} + + ); return ( {message.reasoning && !(message.parts && message.parts.length) ? ( @@ -78,7 +100,7 @@ export function ChatMessageView({ const { fold, workParts, answerParts } = foldPlan(parts, streaming); const renderText = (part: ChatPart, key: string) => part.kind !== "text" || !part.text.trim() ? null : message.role === "user" ? ( - {part.text} + renderUserText(part.text, key) ) : ( {part.text} ); @@ -117,7 +139,7 @@ export function ChatMessageView({ ) : null} {message.content ? ( message.role === "user" ? ( - {message.content} + renderUserText(message.content) ) : ( {message.content} ) diff --git a/apps/web/src/chat/ChatSurface.tsx b/apps/web/src/chat/ChatSurface.tsx index ea800feb..5e57c92c 100644 --- a/apps/web/src/chat/ChatSurface.tsx +++ b/apps/web/src/chat/ChatSurface.tsx @@ -4,7 +4,7 @@ import { Switch } from "@protolabsai/ui/forms"; import { Conversation, Message, PromptInput } from "@protolabsai/ui/ai"; import { TabBar } from "@protolabsai/ui/navigation"; import { Check, TerminalSquare } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -23,7 +23,7 @@ import { effectiveReasoningEffort, } from "./chat-store"; import "./coreSlashCommands"; // registers /new, /clear, /effort via the slash-command seam (ADR 0061) -import { findSlashCommand, registeredSlashCommands } from "../ext/slashRegistry"; +import { findSlashCommand, registeredSlashCommands, slashTokenAt } from "../ext/slashRegistry"; import { registeredComposerActions } from "../ext/composerRegistry"; import { ChatMessageView } from "./ChatMessageView"; import { ComposerModelSelect } from "./ComposerModelSelect"; @@ -382,20 +382,48 @@ function ChatSessionSlot({ } // Slash-command autocomplete. Commands the server handles (e.g. /goal) are - // fetched once; the dropdown is active while typing "/name" (before a space). + // fetched once; the dropdown is active while typing a "/name" token (before a space). const [commands, setCommands] = useState([]); const [slashIndex, setSlashIndex] = useState(0); const [slashDismissed, setSlashDismissed] = useState(false); + // The "/name" token the caret currently sits in ({query, start}), or null. Recomputed + // from the LIVE textarea caret (not just the draft) so the popover triggers MID-INPUT — + // typing "/" at any cursor position opens it, not only when "/" is char 0 (#1530). + const [slashCtx, setSlashCtx] = useState<{ query: string; start: number; end: number } | null>(null); + // Keeps the keyboard-selected item scrolled into view during ↑/↓ nav (#1528). + const activeSlashRef = useRef(null); useEffect(() => { api.chatCommands().then((r) => setCommands(r.commands)).catch(() => {}); }, []); - const slashQuery = useMemo(() => { - if (slashDismissed || !draft.startsWith("/")) return null; - const after = draft.slice(1); - return after.includes(" ") ? null : after; // closes once a space is typed - }, [draft, slashDismissed]); + // Re-parse the slash token from the textarea's current value + caret. Called on input, + // on caret moves (native keyup/click/select/focus listeners below), and after any + // programmatic caret change — so the popover state tracks the caret wherever it is. + const refreshSlash = useCallback(() => { + const ta = textareaRef.current; + if (!ta) return; + setSlashCtx(slashTokenAt(ta.value, ta.selectionStart ?? ta.value.length)); + }, []); + + // Caret moves that don't fire onChange (arrow keys, clicks, selection, focus) still need + // to re-evaluate the popover so "/" mid-input opens/closes as the caret enters/leaves a token. + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.addEventListener("keyup", refreshSlash); + ta.addEventListener("click", refreshSlash); + ta.addEventListener("select", refreshSlash); + ta.addEventListener("focus", refreshSlash); + return () => { + ta.removeEventListener("keyup", refreshSlash); + ta.removeEventListener("click", refreshSlash); + ta.removeEventListener("select", refreshSlash); + ta.removeEventListener("focus", refreshSlash); + }; + }, [refreshSlash]); + + const slashQuery = slashDismissed ? null : slashCtx?.query ?? null; const slashMatches = useMemo(() => { if (slashQuery === null) return []; @@ -415,6 +443,12 @@ function ChatSessionSlot({ const slashActive = slashMatches.length > 0; const slashSel = slashActive ? Math.min(slashIndex, slashMatches.length - 1) : 0; + // Auto-scroll the keyboard-selected item into view during ↑/↓ nav so it never hides + // below the popover's scroll edge (standard listbox behavior, #1528). + useEffect(() => { + if (slashActive) activeSlashRef.current?.scrollIntoView({ block: "nearest" }); + }, [slashSel, slashActive]); + // Post a local SYSTEM NOTE to the thread (e.g. a /effort confirmation, a status line, a // warning) — never sent to the agent, just shown so the operator sees a local action took // effect. role "system" so it renders distinctly and never gets the answer action row @@ -450,17 +484,38 @@ function ChatSessionSlot({ } function completeCommand(cmd: SlashCommand) { - // A client command runs on pick; a server skill fills the draft to edit + send. + // Replace ONLY the "/name" token the caret is in — surrounding text is preserved so a + // command can be completed at the start, middle, or end of the draft (#1530). Fall back + // to the whole draft if the token is somehow unknown (defensive). + const token = slashCtx; + const start = token ? token.start : 0; + const end = token ? token.end : draft.length; + // Place the caret + re-sync the popover after React commits the new value. + const settleCaret = (pos: number) => { + requestAnimationFrame(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.focus(); + ta.selectionStart = ta.selectionEnd = pos; + refreshSlash(); + }); + }; + // A client command runs on pick — drop just its token from the draft (keeping any + // surrounding text); a server skill inserts "/name " to edit + send. if (runClientSlash(cmd.name)) { - setDraft(""); + setDraft(draft.slice(0, start) + draft.slice(end)); setSlashIndex(0); setSlashDismissed(true); + setSlashCtx(null); + settleCaret(start); return; } - setDraft(`/${cmd.name} `); + const insert = `/${cmd.name} `; + setDraft(draft.slice(0, start) + insert + draft.slice(end)); setSlashIndex(0); setSlashDismissed(true); // a space follows, so it would close anyway - textareaRef.current?.focus(); + setSlashCtx(null); + settleCaret(start + insert.length); } // Runs BEFORE the DS PromptInput's Enter-to-submit (via its onKeyDown seam): @@ -508,7 +563,10 @@ function ChatSessionSlot({ // caret to end so the next keystroke edits the recalled text (readline behaviour) requestAnimationFrame(() => { const t = textareaRef.current; - if (t) t.selectionStart = t.selectionEnd = val.length; + if (t) { + t.selectionStart = t.selectionEnd = val.length; + refreshSlash(); // keep the slash popover in sync with the moved caret + } }); }; if (event.key === "ArrowUp" && onFirstLine) { @@ -546,6 +604,7 @@ function ChatSessionSlot({ setDraft(`${draft.slice(0, start)}\n${draft.slice(end)}`); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = start + 1; + refreshSlash(); // keep the slash popover in sync with the moved caret }); } } @@ -1261,6 +1320,7 @@ function ChatSessionSlot({ setDraft(v); setSlashDismissed(false); // re-open the menu when the input changes histIndexRef.current = null; // typing detaches from history nav (readline) + refreshSlash(); // re-parse the "/name" token at the (post-input) caret (#1530) }} // Idle → send. While a turn streams (`busy`), the field stays live: Enter // queues a steer into the running turn (onQueue) without stopping it, and @@ -1351,6 +1411,7 @@ function ChatSessionSlot({ + } + > + } onSelect={() => openGlobalSettings("identity")}> + Agent settings + + } onSelect={() => openGlobalSettings("fleet")}> + Fleet settings + + } onSelect={() => openGlobalSettings("theme")}> + Theme + + + ); +} diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index 32d4ab35..12652d82 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -2019,6 +2019,36 @@ textarea { .fleet-switcher-trigger:hover { background: var(--bg-hover); } + +/* Header brand mark = a compact settings menu trigger (#1544). The logo + a subtle chevron + that fades in on hover/focus/open; the whole mark is a button (pointer cursor, hover tint). + The dropdown itself is the DS Menu (.pl-menu), portaled to . */ +.brand-menu-trigger { + display: inline-flex; + align-items: center; + gap: 3px; + background: none; + border: none; + padding: 2px 4px; + margin: -2px -4px; + border-radius: var(--radius); + color: inherit; + line-height: 0; + cursor: pointer; +} +.brand-menu-trigger:hover { + background: var(--bg-hover); +} +.brand-menu-chevron { + color: var(--fg-muted); + opacity: 0; + transition: opacity 0.12s ease; +} +.brand-menu-trigger:hover .brand-menu-chevron, +.brand-menu-trigger:focus-visible .brand-menu-chevron, +.brand-menu-trigger[data-state="open"] .brand-menu-chevron { + opacity: 1; +} .fleet-switcher-name { display: inline-flex; align-items: center; diff --git a/apps/web/src/settings/DelegatesSection.tsx b/apps/web/src/settings/DelegatesSection.tsx index cba66b33..1a0c3726 100644 --- a/apps/web/src/settings/DelegatesSection.tsx +++ b/apps/web/src/settings/DelegatesSection.tsx @@ -17,6 +17,7 @@ import { api } from "../lib/api"; import { errMsg } from "../lib/format"; import { acpAgentsQuery, delegatesQuery, delegateTypesQuery, queryKeys } from "../lib/queries"; import type { DelegateFieldSpec, DelegateProbe, DelegateTypeSpec, DelegateView } from "../lib/types"; +import { SettingsSubPanel } from "./SettingsSubPanel"; // Delegates panel (ADR 0025, PR3) — manage the agents & endpoints the agent can // talk to via delegate_to, under Settings → Integrations. Hot-swappable: create/ @@ -92,14 +93,13 @@ export function DelegatesSection() { // an error. if (list.isError) { return ( -
-

Delegates

+

Couldn't reach the delegate registry for this agent — manage the agents and endpoints it can talk to once it's available.{" "} Guide

-
+ ); } @@ -107,76 +107,77 @@ export function DelegatesSection() { const typeSpecs = types.data?.types ?? []; return ( -
-

Delegates

-

- Agents & endpoints this agent can reach via delegate_to — changes apply on the next turn. -

- -
- {delegates.map((d) => { - const p = probes[d.name]; - return ( -
-
- - {d.health ? ( - - - - ) : null} - {d.name} {d.type} - {!d.configured ? : null} - {d.has_secret ? : null} - - {p ? probeLine(p) : d.description || d.error || ""} -
-
- - - -
-
- ); - })} - {!delegates.length ?

No delegates yet — add one below.

: null} -
- -
- -
+ +
+

+ Agents & endpoints this agent can reach via delegate_to — changes apply on the next turn. +

- {/* Add / edit happen in a dialog (the form used to render inline and push the - panel down). The DS Dialog supplies the header + close, so DelegateForm - carries only the fields + actions. */} - - + {delegates.map((d) => { + const p = probes[d.name]; + return ( +
+
+ + {d.health ? ( + + + + ) : null} + {d.name} {d.type} + {!d.configured ? : null} + {d.has_secret ? : null} + + {p ? probeLine(p) : d.description || d.error || ""} +
+
+ + + +
+
+ ); + })} + {!delegates.length ?

No delegates yet — add one below.

: null} +
+ +
+ +
+ + {/* Add / edit happen in a dialog (the form used to render inline and push the + panel down). The DS Dialog supplies the header + close, so DelegateForm + carries only the fields + actions. */} + { closeForm(); toast({ tone: "success", title: "Delegate saved", message: msg }); void invalidate(); }} - /> - -
+ title={editing ? `Edit ${editing.name}` : "Add a delegate"} + width="min(560px, 94vw)" + className="delegate-dialog" + > + { closeForm(); toast({ tone: "success", title: "Delegate saved", message: msg }); void invalidate(); }} + /> + + + ); } diff --git a/apps/web/src/settings/KeybindingsPanel.tsx b/apps/web/src/settings/KeybindingsPanel.tsx index 83cf8976..4b966593 100644 --- a/apps/web/src/settings/KeybindingsPanel.tsx +++ b/apps/web/src/settings/KeybindingsPanel.tsx @@ -9,6 +9,7 @@ import type { Keybinding } from "../ext/keybindingRegistry"; import { eventToCombo, formatCombo } from "../keybindings/combo"; import { useKbIntents } from "../keybindings/intents"; import { effectiveCombo, useKeybindingOverrides } from "../keybindings/overrides"; +import { SettingsSubPanel } from "./SettingsSubPanel"; // Settings ▸ Keyboard (ADR 0063) — view + rebind every registered keybinding. Click a // shortcut to record a new combo; conflicts (same combo in an overlapping scope) are @@ -65,63 +66,69 @@ export function KeybindingsPanel() { const overrideCount = Object.keys(overrides).length; return ( -
-
+ + Reset all + + } + > +

Click a shortcut to rebind it. Note: ⌘T, ⌘1–9 and ⌃Tab are reserved by the browser — they work in the desktop app; in a browser, rebind to a free combo.

- -
- {groups.map((g) => ( -
-
{g}
- {bindings - .filter((b) => (b.group || "Other") === g) - .map((b) => { - const recording = recordingId === b.id; - const overridden = b.id in overrides; - return ( -
-
- {b.label} - {b.scope ? {b.scope} : null} -
-
- - {overridden ? ( + {groups.map((g) => ( +
+
{g}
+ {bindings + .filter((b) => (b.group || "Other") === g) + .map((b) => { + const recording = recordingId === b.id; + const overridden = b.id in overrides; + return ( +
+
+ {b.label} + {b.scope ? {b.scope} : null} +
+
+ {overridden ? ( + + ) : null} +
+ {conflict?.id === b.id ? ( +
+ Already bound to “{conflict.with}” — pick another. +
) : null}
- {conflict?.id === b.id ? ( -
- Already bound to “{conflict.with}” — pick another. -
- ) : null} -
- ); - })} -
- ))} -
+ ); + })} +
+ ))} +
+ ); } diff --git a/apps/web/src/settings/SettingsSubPanel.tsx b/apps/web/src/settings/SettingsSubPanel.tsx new file mode 100644 index 00000000..01409845 --- /dev/null +++ b/apps/web/src/settings/SettingsSubPanel.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +import { PanelHeader } from "@protolabsai/ui/navigation"; + +import { StagePanel } from "../app/ErrorBoundary"; + +// Shared chrome for a bespoke Settings sub-panel (#1545). Wraps content in the canonical +// StagePanel scaffold (ADR 0013 — ErrorBoundary + Suspense) plus the DS PanelHeader title bar +// and the scrolling `stage-body`, so hand-built panels (Keyboard, Delegates) match the +// schema-driven ones (SettingsCategoryPanel) and the other bespoke panels (Theme, Chat). +// One container → the header/padding/scroll treatment can't drift per panel. +export function SettingsSubPanel({ + label, + title, + kicker, + actions, + children, +}: { + /** Error/loading label for the StagePanel scaffold. */ + label: string; + title: ReactNode; + kicker?: ReactNode; + actions?: ReactNode; + children: ReactNode; +}) { + return ( + + +
{children}
+
+ ); +} diff --git a/apps/web/src/settings/keybindings.css b/apps/web/src/settings/keybindings.css index 831bec42..8993ce68 100644 --- a/apps/web/src/settings/keybindings.css +++ b/apps/web/src/settings/keybindings.css @@ -4,12 +4,6 @@ flex-direction: column; gap: 18px; } -.kb-panel__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} .kb-panel__hint { margin: 0; font-size: 12px; diff --git a/apps/web/src/settings/settings.css b/apps/web/src/settings/settings.css index bc94a324..a4cbc703 100644 --- a/apps/web/src/settings/settings.css +++ b/apps/web/src/settings/settings.css @@ -113,9 +113,6 @@ border-radius: 0; background: transparent; } -.settings-group { - margin-bottom: 40px; -} /* Per-group action row (e.g. the Discord "Test connection" + help link). */ .settings-group-actions { display: flex; @@ -127,15 +124,6 @@ font-size: 0.8rem; color: var(--fg-muted); } -.settings-group-title { - margin: 0 0 4px; - font-size: 0.72rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--brand-violet-light); - border-bottom: 1px solid var(--border); - padding-bottom: 8px; -} .setting-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, 320px); From 3e761db3bdd0bf1f0e1383544173dd50d927c600 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:12:00 -0700 Subject: [PATCH 16/81] docs(changelog): record the quick-wins batch + desktop-build + wait-tool work (#1555) Adds [Unreleased] entries for work that merged without its own changelog note: slash-command UX (#1530/#1528/#1529), header brand menu (#1544), Cmd/Ctrl+O tool-block toggle (#1526), the shared settings sub-panel container (#1545), the conversational wait-tool output (#1536), manual desktop builds (#1547), and the isolated-dev-boot / manual-desktop docs (#1553, #1552). Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d814db..0b6d24c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 operator surface** (plugin install, config rewrite, subagent runs, goal/watch set-paths) with `403`. Opt-in — unset ⇒ single-token mode, unchanged. - **Console set-goal form** (#1510) and **live `goal.iteration` progress** in the Goals panel (#1498). +- **Chat: slash commands trigger mid-input + render as command bubbles** (#1530, #1528, #1529). Type + `/` at **any** caret position (not only the first character) to open the command popover; arrow-key + navigation auto-scrolls the focused item into view; an issued command renders as a distinct user + bubble (subtle tint + monospace + `/` badge) so it stays legible in the transcript. +- **Header brand menu — one-click nav to settings** (#1544). Click (or right-click) the agent brand + mark in the header for a compact menu: **Agent settings**, **Fleet settings**, **Theme** — each + deep-links the settings overlay. Keyboard-accessible (Enter/Space to open, arrows, Esc). +- **Chat: `Cmd/Ctrl+O` toggles the latest tool-call block** (#1526, ADR 0063). Expands the newest tool + call, then collapses it and walks upward through older ones on repeat; a reasoning-only turn is a + no-op. Rebindable in **Settings ▸ Keyboard**. ### Changed - **Knowledge panel: Upload / Add now open in a dialog** (#1502) instead of expanding inline in the @@ -67,6 +77,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 had to emit is retired for the `update_goal_plan` / `abandon_goal` tools. - The A2A-streaming and non-streaming **goal drive loops are unified** (#1497), fixing a fresh-context thread-id drift. +- **Settings sub-panels share one container** (#1545) — the Keyboard and Delegates panels now render + through the same `SettingsSubPanel` chrome (DS `PanelHeader` + scrolling body) as the schema-driven + and other bespoke panels, so header/padding/scroll can't drift per panel. +- **`wait` tool output is conversational** (#1536) — the tool returns a concise summary (e.g. "Wait + scheduled: 5 minutes. Will resume to: …") instead of the raw "Yielding for 300s — you'll be + re-invoked at …", and its docstring tells the agent to paraphrase rather than echo it verbatim. +- **Desktop app builds are on-demand** (#1547) — `desktop-build.yml` now runs on manual dispatch only, + not on every version tag (the macOS/Windows matrix was the dominant CI cost). Cut a desktop release + with `gh workflow run desktop-build.yml -f tag=vX.Y.Z` (attaches binaries + `latest.json`, promotes + to Latest); tag pushes still publish the Docker image + a non-Latest GitHub Release. ### Security - **RCE-via-chat closed** (#1492) — a `/goal` chat message can no longer arm a `command` / `test` / `ci` @@ -82,6 +102,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Docs - New **ADR 0066** (federation token + operator channel) and **ADR 0067** (watch primitive); **ADR 0030** marked superseded. New **Watches** guide; the goal-mode + plugins guides updated for the drive-only model. +- **PROTO.md § Run it**: agent-launched throwaway test servers should be **fully isolated** (own + `PROTOAGENT_BOX_ROOT`, not just an instance id) to avoid clobbering / the desktop co-residence warning + (#1553, #1552); the releasing + desktop docs updated for the manual desktop-build flow (#1547). ## [0.77.0] - 2026-07-01 From e9e9048ab2a606c95d7797dc66c4eca639d8c76b Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:33:53 -0700 Subject: [PATCH 17/81] revert(console): drop brand-mark settings menu; fleet switcher always shows + gains Fleet settings (#1544 follow-up) (#1556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1544's brand-mark settings menu (Agent · Fleet · Theme deep-links on the header logo) wasn't the intended UX. Revert it: the header logo is a plain mark again (delete BrandMenu + its .brand-menu-* CSS, restore the plain ProtoLabsIcon logo). Instead, the existing FleetSwitcher dropdown (on the agent name) now ALWAYS shows — not only when >1 agent is in the fleet — so New agent + fleet nav are reachable even for a solo agent (only a hard fleet-API error falls back to the plain name). It gains a "Fleet settings" item that opens the fleet management dialog (openGlobalSettings("fleet")), alongside the existing agent list + New agent. #1545 (shared settings sub-panel container) is unaffected. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/app/App.tsx | 7 ++-- apps/web/src/app/BrandMenu.tsx | 54 ------------------------------ apps/web/src/app/FleetSwitcher.tsx | 30 ++++++++++++----- apps/web/src/app/theme.css | 29 ---------------- 4 files changed, 24 insertions(+), 96 deletions(-) delete mode 100644 apps/web/src/app/BrandMenu.tsx diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 387ed360..55efc3d5 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -82,7 +82,6 @@ import { PluginSettingsDialog } from "../plugins/PluginSettingsDialog"; import { AppDrawer } from "./AppDrawer"; import { HamburgerMenu } from "./HamburgerMenu"; import { FleetSwitcher } from "./FleetSwitcher"; -import { BrandMenu } from "./BrandMenu"; import { useUI, type RightPanel, @@ -813,9 +812,7 @@ export function App() {
} />} + logo={} name={ openGlobalSettings("fleet")} /> } org={runtime?.identity?.org || "protoLabs.studio"} diff --git a/apps/web/src/app/BrandMenu.tsx b/apps/web/src/app/BrandMenu.tsx deleted file mode 100644 index f18185e8..00000000 --- a/apps/web/src/app/BrandMenu.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useRef } from "react"; -import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; - -import { Menu, MenuItem, type MenuHandle } from "@protolabsai/ui/menu"; -import { Bot, ChevronDown, Palette, Server } from "lucide-react"; - -import { useUI } from "../state/uiStore"; - -// Compact settings menu anchored to the header brand mark (#1544). Click — or right-click — -// the logo to open a short list of settings deep-links; each opens the settings overlay on -// that section. The DS Menu (Radix) owns keyboard access (Enter/Space open, arrows move, Esc -// close), focus management, and outside-click dismissal. -// -// DS gap: @protolabsai/ui/app-shell `Header` exposes no onClick/hook for its brand slot, so we -// pass the logo through our own trigger button (the Header renders it inside `.pl-header__brand`) -// and anchor the menu to that button. -export function BrandMenu({ logo }: { logo: ReactNode }) { - const openGlobalSettings = useUI((s) => s.openGlobalSettings); - const menuRef = useRef(null); - - // Right-click opens the same menu (anchored to the mark) instead of the browser's context menu. - const onContextMenu = (e: ReactMouseEvent) => { - e.preventDefault(); - menuRef.current?.open(); - }; - - return ( - - {logo} - - - } - > - } onSelect={() => openGlobalSettings("identity")}> - Agent settings - - } onSelect={() => openGlobalSettings("fleet")}> - Fleet settings - - } onSelect={() => openGlobalSettings("theme")}> - Theme - - - ); -} diff --git a/apps/web/src/app/FleetSwitcher.tsx b/apps/web/src/app/FleetSwitcher.tsx index fe91d682..c819914d 100644 --- a/apps/web/src/app/FleetSwitcher.tsx +++ b/apps/web/src/app/FleetSwitcher.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Check, ChevronDown, ExternalLink, Plus } from "lucide-react"; +import { Check, ChevronDown, ExternalLink, Plus, Settings } from "lucide-react"; import type { ReactNode } from "react"; import { Menu, MenuItem, MenuSeparator } from "@protolabsai/ui/menu"; @@ -15,19 +15,28 @@ const slugOf = (a: { id: string; host?: boolean }) => (a.host ? "host" : a.id); // Topbar agent switcher (ADR 0042 slug routing). The focused agent lives in the URL // (/app/agent//), so picking one NAVIGATES there — each window is its own agent, a reload -// can't desync, and you can open a second agent in a new window. Single-agent (just the host) → -// plain name, no dropdown. The dropdown is the DS Menu (#1078): open/close, outside-click, focus -// trap, and the trailing per-row "open in a new window" action all come from @protolabsai/ui. -export function FleetSwitcher({ fallbackName, onNewAgent }: { fallbackName: ReactNode; onNewAgent?: () => void }) { - // Poll so the topbar reflects the fleet live (a newly-added agent makes the switcher appear). +// can't desync, and you can open a second agent in a new window. The dropdown ALWAYS shows (so +// New agent + Fleet settings are reachable even with a single agent); only a hard fleet-API error +// falls back to the plain name. The dropdown is the DS Menu (#1078): open/close, outside-click, +// focus trap, and the trailing per-row "open in a new window" action all come from @protolabsai/ui. +export function FleetSwitcher({ + fallbackName, + onNewAgent, + onManageFleet, +}: { + fallbackName: ReactNode; + onNewAgent?: () => void; + onManageFleet?: () => void; +}) { + // Poll so the topbar reflects the fleet live (a newly-added agent shows up in the list). const fleet = useQuery({ queryKey: queryKeys.fleet, queryFn: () => api.fleet(), retry: false, refetchInterval: 3_000 }); const agents = fleet.data?.agents ?? []; const slug = currentSlug(); // the agent THIS window is on const current = agents.find((a) => slugOf(a) === slug); - // Solo (just the host) or no hub → plain name, no switcher. - if (fleet.isError || agents.length <= 1) return <>{fallbackName}; + // Only a hard fleet-API error hides the switcher; otherwise it's always available. + if (fleet.isError) return <>{fallbackName}; return ( ); })} - + {agents.length > 0 ? : null} } onSelect={() => onNewAgent?.()}> New agent + } onSelect={() => onManageFleet?.()}> + Fleet settings + ); } diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index 12652d82..822c07a0 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -2020,35 +2020,6 @@ textarea { background: var(--bg-hover); } -/* Header brand mark = a compact settings menu trigger (#1544). The logo + a subtle chevron - that fades in on hover/focus/open; the whole mark is a button (pointer cursor, hover tint). - The dropdown itself is the DS Menu (.pl-menu), portaled to . */ -.brand-menu-trigger { - display: inline-flex; - align-items: center; - gap: 3px; - background: none; - border: none; - padding: 2px 4px; - margin: -2px -4px; - border-radius: var(--radius); - color: inherit; - line-height: 0; - cursor: pointer; -} -.brand-menu-trigger:hover { - background: var(--bg-hover); -} -.brand-menu-chevron { - color: var(--fg-muted); - opacity: 0; - transition: opacity 0.12s ease; -} -.brand-menu-trigger:hover .brand-menu-chevron, -.brand-menu-trigger:focus-visible .brand-menu-chevron, -.brand-menu-trigger[data-state="open"] .brand-menu-chevron { - opacity: 1; -} .fleet-switcher-name { display: inline-flex; align-items: center; From f3675c01e5628c9bb97e8230381bc62000410b19 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:21:53 -0700 Subject: [PATCH 18/81] =?UTF-8?q?feat(chat):=20/compact=20=E2=80=94=20summ?= =?UTF-8?q?arize=20+=20archive=20a=20thread,=20rewrite=20the=20checkpoint?= =?UTF-8?q?=20(#1527)=20(#1558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat): /compact — summarize + archive a thread, rewrite the checkpoint (#1527) Server-side /compact: summarize the current chat thread, archive the FULL raw transcript to the searchable knowledge store (domain=conversation, namespace=chat-archive:), then rewrite the live LangGraph checkpoint to [RemoveMessage(REMOVE_ALL_MESSAGES), summary, *recent_tail] so the agent keeps context at a fraction of the token cost. The manual analogue of the automatic SummarizationMiddleware. Two hard invariants: - Never-lossy: rewrite happens ONLY after the raw history is archived AND a non-empty summary exists. Refuses (no checkpoint change) on no_store / empty_archive / archive_error / no_summary / summary_error / no_checkpointer. - Message-boundary integrity: the retained tail never orphans a ToolMessage from its parent AIMessage(tool_calls) — reuses the middleware's safe-cut. graph/compaction_op.py (host-free) → server.chat.compact_session (under _thread_lock) → POST /api/chat/sessions/{id}/compact; /compact client command + api.compactChatSession. render_transcript gains max_chars=None for the uncapped archive. Tests: test_compaction_op.py (invariants incl. real-SQLite e2e + summarizer-exception refuse) + a route test. Co-Authored-By: Claude Opus 4.8 (1M context) * test(e2e): add /compact to the slash-menu expected list (#1527) /compact registers as a new client slash command, so commands.spec's exact CLIENT_SLASH list must include it (it sorts after /clear). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/e2e/commands.spec.ts | 2 +- apps/web/src/chat/coreSlashCommands.test.ts | 6 +- apps/web/src/chat/coreSlashCommands.ts | 75 +++++++ apps/web/src/lib/api.ts | 19 ++ graph/compaction_op.py | 219 ++++++++++++++++++++ graph/conversation_harvest.py | 12 +- operator_api/chat_routes.py | 15 +- server/chat.py | 56 +++++ tests/test_chat_routes.py | 28 +++ tests/test_compaction_op.py | 189 +++++++++++++++++ 10 files changed, 612 insertions(+), 9 deletions(-) create mode 100644 graph/compaction_op.py create mode 100644 tests/test_compaction_op.py diff --git a/apps/web/e2e/commands.spec.ts b/apps/web/e2e/commands.spec.ts index ed9bb156..0480813c 100644 --- a/apps/web/e2e/commands.spec.ts +++ b/apps/web/e2e/commands.spec.ts @@ -6,7 +6,7 @@ import { SLASH_COMMANDS } from "./fixtures.mjs"; // (GET /api/chat/commands) and autocompletes them as you type "/name". // Deterministic client-side commands (ADR 0057) surface FIRST, then the server skills. -const CLIENT_SLASH = ["/new", "/clear", "/effort", "/bypass"]; +const CLIENT_SLASH = ["/new", "/clear", "/compact", "/effort", "/bypass"]; test.beforeEach(async ({ page }) => { await page.goto("/app/", { waitUntil: "load" }); diff --git a/apps/web/src/chat/coreSlashCommands.test.ts b/apps/web/src/chat/coreSlashCommands.test.ts index 24e00134..3197b456 100644 --- a/apps/web/src/chat/coreSlashCommands.test.ts +++ b/apps/web/src/chat/coreSlashCommands.test.ts @@ -10,15 +10,17 @@ function ctx(over: Partial = {}): SlashContext { } describe("core slash commands (dogfood the seam, ADR 0061)", () => { - it("registers /new, /clear, /effort through the same registry a fork uses", () => { + it("registers /new, /clear, /effort, /compact through the same registry a fork uses", () => { expect(findSlashCommand("new")).toBeTruthy(); expect(findSlashCommand("clear")).toBeTruthy(); expect(findSlashCommand("effort")).toBeTruthy(); + expect(findSlashCommand("compact")).toBeTruthy(); }); - it("/clear and /effort are no-ops (return false → fall through) without a session", () => { + it("/clear, /effort, /compact are no-ops (return false → fall through) without a session", () => { expect(findSlashCommand("clear")!.run(ctx())).toBe(false); expect(findSlashCommand("effort")!.run(ctx())).toBe(false); + expect(findSlashCommand("compact")!.run(ctx())).toBe(false); }); it("/effort with an unknown level notes the error and still handles it", () => { diff --git a/apps/web/src/chat/coreSlashCommands.ts b/apps/web/src/chat/coreSlashCommands.ts index 32e205a8..6b9dfab4 100644 --- a/apps/web/src/chat/coreSlashCommands.ts +++ b/apps/web/src/chat/coreSlashCommands.ts @@ -7,8 +7,15 @@ import { registerSlashCommand } from "../ext/slashRegistry"; import { api } from "../lib/api"; +import type { ChatMessage } from "../lib/types"; import { chatStore, DEFAULT_REASONING_EFFORT, REASONING_EFFORTS } from "./chat-store"; +// Local id for the system notes /compact posts (the command manages messages +// directly, like /clear, so it needs to own the ids it can later replace). +function noteId() { + return `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + registerSlashCommand({ name: "new", description: "Open a new chat tab", @@ -31,6 +38,74 @@ registerSlashCommand({ }, }); +registerSlashCommand({ + name: "compact", + description: "Summarize & archive older history, keeping recent context", + run: (ctx) => { + if (!ctx.sessionId) return false; // no session → fall through + const sessionId = ctx.sessionId; + const messagesOf = () => + chatStore.getSnapshot().sessions.find((s) => s.id === sessionId)?.messages ?? []; + + // Optimistic note (own id so we can drop it once the server responds). + const pendingId = noteId(); + chatStore.updateMessages(sessionId, [ + ...messagesOf(), + { + id: pendingId, + role: "system", + content: "Compacting this conversation — archiving older history and summarizing…", + noteTone: "info", + createdAt: Date.now(), + status: "done", + }, + ]); + ctx.focusComposer(); + + const note = (content: string, tone: ChatMessage["noteTone"]): ChatMessage => ({ + id: noteId(), + role: "system", + content, + noteTone: tone, + createdAt: Date.now(), + status: "done", + }); + // Drop only the optimistic note — preserve anything that streamed in meanwhile. + const withoutPending = () => messagesOf().filter((m) => m.id !== pendingId); + + void api + .compactChatSession(sessionId) + .then((res) => { + // Never-lossy: only drop history when the server actually rewrote the + // checkpoint (archived + removed > 0). Otherwise just surface the status. + if (res.refused || !res.archived || res.removed <= 0) { + chatStore.updateMessages(sessionId, [ + ...withoutPending(), + note(res.message, res.refused ? "warning" : "info"), + ]); + return; + } + // Mirror the server: replace the view with a summary bubble + the recent + // tail. Slice from the CURRENT messages (minus the pending note) so nothing + // that arrived during the compaction is lost. + const kept = res.kept > 0 ? withoutPending().slice(-res.kept) : []; + const summary = note( + `**Conversation compacted.** ${res.message}\n\n---\n\n${res.summary}`, + "success", + ); + chatStore.updateMessages(sessionId, [summary, ...kept]); + }) + .catch(() => { + chatStore.updateMessages(sessionId, [ + ...withoutPending(), + note("Compaction failed — nothing was changed.", "danger"), + ]); + }); + + return true; + }, +}); + registerSlashCommand({ name: "effort", description: "Reasoning effort: low | medium | high | max | off", diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6bf5ec9e..d726e555 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1170,6 +1170,25 @@ export const api = { ); }, + // Compact a chat session server-side (#1527): archive the raw history into + // searchable memory, summarize it, and rewrite the LangGraph checkpoint to + // [summary, recent tail] so the agent keeps context at lower token cost. The + // checkpoint is the agent's REAL context, so this must be server-side — a + // client-only trim would leave the agent's context untouched. `refused` (never + // lossy: nothing could be archived) means the server left the thread intact. + compactChatSession(sessionId: string) { + return request<{ + summary: string; + archived_chunks: number; + kept: number; + removed: number; + archived: boolean; + refused: boolean; + reason: string; + message: string; + }>(`/api/chat/sessions/${encodeURIComponent(sessionId)}/compact`, { method: "POST", body: {} }); + }, + async streamChat( message: string, sessionId: string, diff --git a/graph/compaction_op.py b/graph/compaction_op.py new file mode 100644 index 00000000..601a0648 --- /dev/null +++ b/graph/compaction_op.py @@ -0,0 +1,219 @@ +"""On-demand conversation compaction — the ``/compact`` operator gesture (#1527). + +This is the *manual*, whole-thread analogue of the automatic +``SummarizationMiddleware`` (``graph/middleware/compaction.py``): instead of +waiting for the context window to fill, an operator asks for a compaction now. +The live LangGraph checkpoint is the agent's *real* context, so a client-only +compaction would do nothing — this runs SERVER-SIDE against the checkpointer. + +The pass, for one thread: + +1. ``aget_state`` the current messages off the checkpoint. +2. Render the **full** transcript and archive it into the searchable knowledge + store (``domain="conversation"``, ``namespace="chat-archive:"``) + so nothing is lost — the raw history stays recallable via ``memory_recall``. +3. Summarize the conversation with the cheap aux model. +4. Rewrite the checkpoint to ``[RemoveMessage(REMOVE_ALL_MESSAGES), summary, + *recent_tail]`` via ``aupdate_state`` — so the next turn carries the whole + thread's context at a fraction of the token cost. + +**Never-lossy (hard invariant).** Compaction must never drop history it could +not archive. If there is no knowledge store, or the archive write yields no +chunks, or the summarizer produces nothing, we DO NOT touch the checkpoint and +return ``refused=True`` — the operator keeps their full, intact context. + +**Message-boundary integrity (hard invariant).** The recent tail must never +orphan a ``ToolMessage`` from the ``AIMessage(tool_calls=…)`` that spawned it — +the next model call errors ("tool_call without response"). We reuse the same +safe-cut the auto-summarizer uses: if the naive cutoff lands on a +``ToolMessage``, walk back to include its parent ``AIMessage`` (so the pair is +kept together), summarizing slightly more rather than splitting a pair. + +Host-free and unit-testable: it takes the graph, checkpointer, knowledge store, +and config as arguments (no ``STATE`` import), mirroring +``conversation_harvest.harvest_thread``. +""" + +from __future__ import annotations + +import logging + +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +from graph.conversation_harvest import _default_summarizer, render_transcript + +log = logging.getLogger(__name__) + +_DEFAULT_KEEP_MESSAGES = 20 + + +def _safe_cut_index(messages: list, keep_last: int) -> int: + """Index where the retained tail should start so it keeps at least + ``keep_last`` messages WITHOUT orphaning a ``ToolMessage`` from its parent + ``AIMessage``. + + Mirrors ``SummarizationMiddleware._find_safe_cutoff`` / + ``_find_safe_cutoff_point``: land ``keep_last`` from the end, then, if that + lands on a ``ToolMessage``, walk *back* to the ``AIMessage`` whose + ``tool_calls`` produced it so the tool request/response pair stays together + (falling forward past the tool block only if no parent is found). Returns 0 + when everything fits — keep it all. + """ + n = len(messages) + if keep_last <= 0: + target = n # keep nothing but the summary + elif n <= keep_last: + return 0 + else: + target = n - keep_last + + if target >= n or not isinstance(messages[target], ToolMessage): + return target + + # target sits on a ToolMessage — gather the ids of the consecutive tool block + # and search backward for the AIMessage that requested them. + tool_call_ids: set[str] = set() + idx = target + while idx < n and isinstance(messages[idx], ToolMessage): + tcid = getattr(messages[idx], "tool_call_id", None) + if tcid: + tool_call_ids.add(tcid) + idx += 1 + for i in range(target - 1, -1, -1): + m = messages[i] + if isinstance(m, AIMessage) and getattr(m, "tool_calls", None): + ai_ids = {tc.get("id") for tc in m.tool_calls if tc.get("id")} + if tool_call_ids & ai_ids: + return i + # No matching AIMessage (edge case) — fall forward past the orphan tool block. + return idx + + +def _refused(reason: str, *, kept: int, archived: bool = False, archived_chunks: int = 0, summary: str = "") -> dict: + """A no-rewrite result. ``refused`` is the never-lossy signal (the caller must + NOT drop client history); ``too_short`` is a benign no-op, not a refusal.""" + return { + "summary": summary, + "archived_chunks": archived_chunks, + "kept": kept, + "removed": 0, + "archived": archived, + "refused": reason not in ("", "too_short"), + "reason": reason, + } + + +async def compact_thread( + graph, + checkpointer, + knowledge_store, + config, + thread_id: str, + session_id: str, + *, + summarizer=_default_summarizer, + keep_recent: int | None = None, +) -> dict: + """Compact ``thread_id``'s live context: archive the raw transcript, summarize, + then rewrite the checkpoint to ``[summary, *recent_tail]``. + + Returns ``{summary, archived_chunks, kept, removed, archived, refused, + reason}``. Honors the never-lossy invariant — a rewrite happens ONLY after the + raw history is safely archived and a non-empty summary exists. + """ + if graph is None or checkpointer is None: + return _refused("no_checkpointer", kept=0) + + lg_config = {"configurable": {"thread_id": thread_id}} + snapshot = await graph.aget_state(lg_config) + messages = list((getattr(snapshot, "values", None) or {}).get("messages") or []) + + keep = keep_recent if keep_recent is not None else getattr(config, "compaction_keep_messages", _DEFAULT_KEEP_MESSAGES) + keep = max(0, int(keep)) + + # Already small enough — nothing to gain, nothing removed (not a refusal). + if len(messages) <= keep: + return _refused("too_short", kept=len(messages)) + + # Never-lossy: no archive target ⇒ never touch the checkpoint. + if knowledge_store is None: + return _refused("no_store", kept=len(messages)) + + # Archive the FULL transcript (uncapped) so the raw history is recallable — + # a capped render would silently drop the head we're about to remove. + full_transcript = render_transcript(messages, max_chars=None) + if not full_transcript.strip(): + # Nothing renderable to archive (e.g. an all-tool-noise thread) — refuse + # rather than drop un-archived history. + return _refused("empty", kept=len(messages)) + + import asyncio + + from knowledge import add_document + + # add_document does blocking gateway work per chunk (embed + optional + # enrichment) — keep it off the event loop (mirrors conversation_harvest). + try: + chunk_ids = await asyncio.to_thread( + add_document, + knowledge_store, + full_transcript, + domain="conversation", + heading=f"Conversation archive ({session_id})", + namespace=f"chat-archive:{session_id}", + ) + except Exception: + log.exception("[compact] archive failed for thread %s — refusing to rewrite", thread_id) + return _refused("archive_error", kept=len(messages)) + if not chunk_ids: + return _refused("empty_archive", kept=len(messages)) + + # Summarize the capped tail (cost-bounded classification-grade work); the head + # beyond the cap is already archived + searchable, not lost. + try: + summary = (await summarizer(render_transcript(messages), config)).strip() + except Exception: + # The archive already succeeded; a summarizer failure must not 500 or leave + # the checkpoint half-rewritten. Refuse (never-lossy) — the raw history stands + # as a searchable archive and the live context is untouched. + log.exception("[compact] summarize failed for thread %s — refusing to rewrite", thread_id) + return _refused("summary_error", kept=len(messages), archived=True, archived_chunks=len(chunk_ids)) + if not summary: + # We DID archive, but with no summary a rewrite would strip the context + # thread — keep the full live context (archive stands as a searchable bonus). + return _refused("no_summary", kept=len(messages), archived=True, archived_chunks=len(chunk_ids)) + + cut = _safe_cut_index(messages, keep) + recent_tail = messages[cut:] + + from langchain_core.messages import RemoveMessage + from langgraph.graph.message import REMOVE_ALL_MESSAGES + + summary_msg = HumanMessage( + content=( + "Here is a summary of the earlier conversation " + "(the full transcript is archived and searchable via memory recall):\n\n" + f"{summary}" + ), + additional_kwargs={"lc_source": "compaction"}, + ) + await graph.aupdate_state( + lg_config, + {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), summary_msg, *recent_tail]}, + ) + log.info( + "[compact] thread %s: archived %d chunk(s), removed %d msg(s), kept %d", + thread_id, + len(chunk_ids), + cut, + len(recent_tail), + ) + return { + "summary": summary, + "archived_chunks": len(chunk_ids), + "kept": len(recent_tail), + "removed": cut, + "archived": True, + "refused": False, + "reason": "", + } diff --git a/graph/conversation_harvest.py b/graph/conversation_harvest.py index 7a51c83f..9e0b71e8 100644 --- a/graph/conversation_harvest.py +++ b/graph/conversation_harvest.py @@ -24,12 +24,14 @@ _MAX_TRANSCRIPT_CHARS = 16000 -def render_transcript(messages: list) -> str: +def render_transcript(messages: list, *, max_chars: int | None = _MAX_TRANSCRIPT_CHARS) -> str: """Render a User/Assistant transcript from checkpoint messages. Assistant turns are run through ``extract_output`` (drop scratch_pad/think); - tool and system messages are skipped. Returns the most-recent - ``_MAX_TRANSCRIPT_CHARS`` when long. + tool and system messages are skipped. Truncated to the most-recent + ``max_chars`` when long; pass ``max_chars=None`` for the full transcript (the + compaction path archives the *whole* conversation losslessly before it + rewrites the live context — a capped render would silently drop the head). """ lines: list[str] = [] for m in messages: @@ -43,8 +45,8 @@ def render_transcript(messages: list) -> str: if clean: lines.append(f"Assistant: {clean}") transcript = "\n".join(lines) - if len(transcript) > _MAX_TRANSCRIPT_CHARS: - transcript = "…\n" + transcript[-_MAX_TRANSCRIPT_CHARS:] + if max_chars is not None and len(transcript) > max_chars: + transcript = "…\n" + transcript[-max_chars:] return transcript diff --git a/operator_api/chat_routes.py b/operator_api/chat_routes.py index 14fb6fa2..1aa51abe 100644 --- a/operator_api/chat_routes.py +++ b/operator_api/chat_routes.py @@ -27,7 +27,7 @@ log = logging.getLogger("protoagent.server") from server import agent_name from server.agent_init import _retire_thread -from server.chat import chat +from server.chat import chat, compact_session class ChatRequest(BaseModel): @@ -75,6 +75,19 @@ async def _api_delete_session(session_id: str, harvest: bool = False): log.warning("[chat] attachment cleanup failed for %s: %s", session_id, exc) return {"deleted": True, "harvested": chunk_id is not None} + @app.post("/api/chat/sessions/{session_id}/compact") + async def _api_compact_session(session_id: str): + """Compact a chat session's live context (#1527): archive the raw history + into searchable memory, summarize it, and rewrite the LangGraph checkpoint + to ``[summary, recent tail]`` so the agent keeps context at lower token + cost. Runs SERVER-SIDE — the checkpoint is the agent's real context, so a + client-only compaction would do nothing. + + Never-lossy: if there's no store or the archive write yields nothing, the + checkpoint is left untouched and ``refused`` is true (the console then + keeps the full thread rather than dropping anything).""" + return await compact_session(session_id) + @app.post("/api/chat/sessions/{session_id}/steer") async def _api_steer(session_id: str, body: dict | None = None): """Queue a user message into a RUNNING turn (mid-turn steering). diff --git a/server/chat.py b/server/chat.py index 7996dc8c..86c14d82 100644 --- a/server/chat.py +++ b/server/chat.py @@ -1162,6 +1162,62 @@ async def _runner() -> str: tracing.flush() +def _compaction_message(result: dict) -> str: + """Human-readable status line for a compaction result — surfaced as the + system-note in the chat thread (and returned to non-UI callers).""" + reason = result.get("reason") or "" + if reason == "too_short": + return f"Nothing to compact — this conversation is already short ({result.get('kept', 0)} messages)." + if reason == "no_store": + return ( + "Compaction skipped — no searchable knowledge store is configured, so the raw history " + "couldn't be archived. Nothing was changed (your full context is intact)." + ) + if reason in ("empty", "empty_archive", "archive_error"): + return "Compaction skipped — the conversation couldn't be archived, so nothing was changed." + if reason in ("no_summary", "summary_error"): + return ( + f"Archived {result.get('archived_chunks', 0)} chunk(s) to searchable memory, but the summary " + "couldn't be generated — kept your full context rather than compacting it." + ) + if reason == "no_checkpointer": + return "Compaction unavailable — no conversation checkpoint to compact." + removed, kept = result.get("removed", 0), result.get("kept", 0) + return ( + f"Compacted this conversation — archived {removed} older message(s) to searchable memory and kept the " + f"last {kept}. The agent now carries a summary of the earlier messages plus the recent ones, at a " + f"fraction of the token cost; the full raw history stays searchable via memory recall." + ) + + +async def compact_session(session_id: str, *, request_metadata: dict | None = None) -> dict: + """Compact a chat session's live context (the ``/compact`` gesture, #1527). + + Resolves the session's checkpointer ``thread_id`` (the A2A ``a2a:`` + thread — the one the live streaming turns write to) and runs + ``compact_thread`` under the per-thread lock, so a compaction can never race a + live streaming turn on the same thread (mirrors the turn driver). Returns the + ``compact_thread`` result dict plus a human-readable ``message``. + """ + base = {"summary": "", "archived_chunks": 0, "kept": 0, "removed": 0, "archived": False, "refused": True} + if STATE.graph is None: + return {**base, "reason": "setup", "message": "Setup required — finish the setup wizard first."} + + from graph.compaction_op import compact_thread + + tid = _resolve_thread_id(request_metadata, session_id) + async with _thread_lock(tid): + result = await compact_thread( + STATE.graph, + STATE.checkpointer, + STATE.knowledge_store, + STATE.graph_config, + tid, + session_id, + ) + return {**result, "message": _compaction_message(result)} + + async def _chat_langgraph(message: str, session_id: str, *, model: str | None = None) -> list[dict[str, Any]]: """Non-streaming LangGraph entry — used by the console + OpenAI-compat.""" from observability import tracing diff --git a/tests/test_chat_routes.py b/tests/test_chat_routes.py index 4392708c..91239f5a 100644 --- a/tests/test_chat_routes.py +++ b/tests/test_chat_routes.py @@ -79,6 +79,34 @@ async def _fake_retire(thread_id, *, harvest=None, cascade=True): ] +def test_compact_session_route(monkeypatch): + # The route is a thin pass-through to server.chat.compact_session — forwards the + # path session_id and returns the compaction result dict verbatim. + import operator_api.chat_routes as cr + + seen: list[str] = [] + + async def _fake_compact(session_id): + seen.append(session_id) + return { + "summary": "s", + "archived_chunks": 2, + "kept": 4, + "removed": 9, + "archived": True, + "refused": False, + "reason": "", + "message": "Compacted this conversation", + } + + monkeypatch.setattr(cr, "compact_session", _fake_compact) + c = _client(monkeypatch) + body = c.post("/api/chat/sessions/s1/compact").json() + assert seen == ["s1"] + assert body["removed"] == 9 and body["kept"] == 4 and body["archived"] is True + assert body["message"] == "Compacted this conversation" + + def test_delete_session_cleans_ephemeral_attachments(monkeypatch): """Deleting a chat drops its session-scoped attachment chunks.""" import operator_api.chat_routes as cr diff --git a/tests/test_compaction_op.py b/tests/test_compaction_op.py new file mode 100644 index 00000000..9f62a03e --- /dev/null +++ b/tests/test_compaction_op.py @@ -0,0 +1,189 @@ +"""Tests for on-demand conversation compaction (the /compact gesture, #1527).""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.graph.message import REMOVE_ALL_MESSAGES + +from graph.checkpointer import build_sqlite_checkpointer +from graph.compaction_op import compact_thread + + +class _FakeGraph: + """Records aupdate_state calls; serves seeded messages from aget_state.""" + + def __init__(self, messages): + self._messages = messages + self.updates: list = [] + + async def aget_state(self, config): + return SimpleNamespace(values={"messages": list(self._messages)}) + + async def aupdate_state(self, config, update): + self.updates.append((config, update)) + + +class _FakeKnowledge: + def __init__(self, *, yields=True): + self.docs: list = [] + self._yields = yields + + def add_document(self, content, *, domain=None, heading=None, namespace=None, **kw): + self.docs.append({"content": content, "domain": domain, "heading": heading, "namespace": namespace}) + return [1, 2] if self._yields else [] + + +async def _summ(transcript, config): + return "SUMMARY: user likes teal" + + +async def _boom(transcript, config): + raise AssertionError("summarizer must not run on this path") + + +async def _raise_summ(transcript, config): + raise RuntimeError("gateway down") + + +def _cfg(keep): + return SimpleNamespace(compaction_keep_messages=keep) + + +def test_compact_archives_and_rewrites_checkpoint(): + msgs = [ + HumanMessage(content="hi", id="h1"), + AIMessage(content="hello", id="a1"), + HumanMessage(content="favorite color?", id="h2"), + AIMessage(content="teal", id="a2"), + ] + g = _FakeGraph(msgs) + kb = _FakeKnowledge() + res = asyncio.run(compact_thread(g, object(), kb, _cfg(2), "a2a:s1", "s1", summarizer=_summ)) + + # Raw transcript archived into the conversation domain, session-scoped namespace. + assert kb.docs[0]["domain"] == "conversation" + assert kb.docs[0]["namespace"] == "chat-archive:s1" + assert "teal" in kb.docs[0]["content"] and "favorite color" in kb.docs[0]["content"] + + assert res["summary"] == "SUMMARY: user likes teal" + assert res["archived"] is True and res["refused"] is False + assert res["removed"] == 2 and res["kept"] == 2 and res["archived_chunks"] == 2 + + # Checkpoint rewritten to [REMOVE_ALL, summary, *keep_recent]. + assert len(g.updates) == 1 + _config, update = g.updates[0] + out = update["messages"] + assert isinstance(out[0], RemoveMessage) and out[0].id == REMOVE_ALL_MESSAGES + assert isinstance(out[1], HumanMessage) and "SUMMARY: user likes teal" in out[1].content + assert out[1].additional_kwargs.get("lc_source") == "compaction" + assert [m.id for m in out[2:]] == ["h2", "a2"] # the recent tail, untouched + + +def test_compact_preserves_tool_call_pairing(): + # Naive cutoff at keep=2 lands on the 2nd ToolMessage — the safe-cut must walk + # back to the AIMessage that spawned it so the pair isn't orphaned. + msgs = [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="", id="a1", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", id="tm1", tool_call_id="t1"), + AIMessage(content="answer1", id="a2"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="", id="a3", tool_calls=[{"id": "t2", "name": "search", "args": {}}]), + ToolMessage(content="res2", id="tm2", tool_call_id="t2"), + AIMessage(content="answer2", id="a4"), + ] + g = _FakeGraph(msgs) + res = asyncio.run(compact_thread(g, object(), _FakeKnowledge(), _cfg(2), "a2a:s1", "s1", summarizer=_summ)) + + _config, update = g.updates[0] + tail = update["messages"][2:] + # The tail starts on the AIMessage(tool_calls) — NOT the orphaned ToolMessage. + assert [m.id for m in tail] == ["a3", "tm2", "a4"] + assert isinstance(tail[0], AIMessage) and tail[0].tool_calls + assert isinstance(tail[1], ToolMessage) and tail[1].tool_call_id == "t2" + assert res["removed"] == 5 and res["kept"] == 3 + + +def test_compact_refuses_without_store(): + # Never-lossy: no archive target ⇒ the checkpoint is left untouched. + msgs = [HumanMessage(content=f"m{i}", id=f"m{i}") for i in range(6)] + g = _FakeGraph(msgs) + res = asyncio.run(compact_thread(g, object(), None, _cfg(2), "a2a:s1", "s1", summarizer=_boom)) + assert res["refused"] is True and res["archived"] is False and res["reason"] == "no_store" + assert g.updates == [] # NO rewrite + + +def test_compact_refuses_when_archive_yields_nothing(): + # Never-lossy: archive wrote no chunks ⇒ don't rewrite (and don't even summarize). + msgs = [HumanMessage(content=f"m{i}", id=f"m{i}") for i in range(6)] + g = _FakeGraph(msgs) + kb = _FakeKnowledge(yields=False) + res = asyncio.run(compact_thread(g, object(), kb, _cfg(2), "a2a:s1", "s1", summarizer=_boom)) + assert res["refused"] is True and res["archived"] is False and res["reason"] == "empty_archive" + assert g.updates == [] + + +def test_compact_refuses_when_summarizer_raises(): + # Never-lossy: the archive already succeeded, but a summarizer exception must NOT + # 500 or half-rewrite the checkpoint — refuse and leave the live context intact. + msgs = [HumanMessage(content=f"m{i}", id=f"m{i}") for i in range(6)] + g = _FakeGraph(msgs) + kb = _FakeKnowledge() + res = asyncio.run(compact_thread(g, object(), kb, _cfg(2), "a2a:s1", "s1", summarizer=_raise_summ)) + assert res["refused"] is True and res["reason"] == "summary_error" + assert res["archived"] is True and res["archived_chunks"] == 2 # the archive stands + assert g.updates == [] # NO checkpoint rewrite + + +def test_compact_noop_when_already_short(): + msgs = [HumanMessage(content="hi", id="h1"), AIMessage(content="yo", id="a1")] + g = _FakeGraph(msgs) + kb = _FakeKnowledge() + res = asyncio.run(compact_thread(g, object(), kb, _cfg(20), "a2a:s1", "s1", summarizer=_boom)) + assert res["refused"] is False and res["reason"] == "too_short" and res["removed"] == 0 + assert g.updates == [] and kb.docs == [] # nothing archived, nothing rewritten + + +def test_compact_refuses_without_checkpointer(): + res = asyncio.run(compact_thread(_FakeGraph([]), None, _FakeKnowledge(), _cfg(2), "a2a:s1", "s1", summarizer=_boom)) + assert res["refused"] is True and res["reason"] == "no_checkpointer" + + +def test_compact_rewrites_real_sqlite_checkpoint(tmp_path): + """End-to-end against a real compiled graph + SQLite checkpointer: the rewrite + actually lands (proves aupdate_state applies REMOVE_ALL + reducer, no as_node + ambiguity) and the resulting checkpoint is [summary, *recent_tail].""" + db = str(tmp_path / "c.db") + g = StateGraph(MessagesState) + g.add_node("n", lambda s: {"messages": []}) # no-op — we seed via the input + g.add_edge(START, "n") + g.add_edge("n", END) + saver = build_sqlite_checkpointer(db) + app = g.compile(checkpointer=saver) + cfg = {"configurable": {"thread_id": "a2a:s1"}} + seed = [ + HumanMessage(content="q1"), + AIMessage(content="", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", tool_call_id="t1"), + AIMessage(content="answer1"), + HumanMessage(content="q2"), + AIMessage(content="answer2"), + ] + kb = _FakeKnowledge() + + async def run(): + await app.ainvoke({"messages": seed}, cfg) + res = await compact_thread(app, saver, kb, _cfg(2), "a2a:s1", "s1", summarizer=_summ) + snap = await app.aget_state(cfg) + return res, snap.values["messages"] + + res, final = asyncio.run(run()) + assert res["archived"] is True and res["removed"] == 4 and res["kept"] == 2 + # [summary(Human), q2, answer2] — everything before the recent tail collapsed. + assert len(final) == 3 + assert "SUMMARY" in final[0].content and final[0].additional_kwargs.get("lc_source") == "compaction" + assert final[1].content == "q2" and final[2].content == "answer2" From c3367a2cfc0f2589b972bf4c54734823c1b7f4d4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:23:39 -0700 Subject: [PATCH 19/81] docs(changelog): /compact + the always-on agent switcher (round 2) (#1561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds [Unreleased] entries for /compact (#1527, #1558) and rewrites the reverted #1544 brand-menu entry to its net shipped result — the always-available agent switcher + Fleet-settings shortcut (#1544, #1556). (The /close command was reverted, so it's intentionally not documented.) Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6d24c8..ed7bba61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,12 +57,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `/` at **any** caret position (not only the first character) to open the command popover; arrow-key navigation auto-scrolls the focused item into view; an issued command renders as a distinct user bubble (subtle tint + monospace + `/` badge) so it stays legible in the transcript. -- **Header brand menu — one-click nav to settings** (#1544). Click (or right-click) the agent brand - mark in the header for a compact menu: **Agent settings**, **Fleet settings**, **Theme** — each - deep-links the settings overlay. Keyboard-accessible (Enter/Space to open, arrows, Esc). +- **Agent switcher: always available, with a Fleet-settings shortcut** (#1544, #1556). The agent-name + dropdown in the header now shows even for a single agent (not only in a multi-agent fleet), so **New + agent** and a new **Fleet settings** link (→ the fleet management dialog) are always one click away. + The brand logo stays a plain mark. - **Chat: `Cmd/Ctrl+O` toggles the latest tool-call block** (#1526, ADR 0063). Expands the newest tool call, then collapses it and walks upward through older ones on repeat; a reasoning-only turn is a no-op. Rebindable in **Settings ▸ Keyboard**. +- **Chat: `/compact` — summarize + archive a long thread** (#1527). Compresses the current conversation + into a summary and rewrites the live context to *[summary + recent messages]* so the agent keeps + context at a fraction of the token cost, while the **full raw transcript is archived to searchable + memory** (recallable via `memory_recall`). Never-lossy: it refuses rather than drop history it + couldn't archive, and keeps tool-call/response pairs intact across the cut. ### Changed - **Knowledge panel: Upload / Add now open in a dialog** (#1502) instead of expanding inline in the From 0406713803741f30f810645f2bf2c6d467623c79 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:26:16 -0700 Subject: [PATCH 20/81] fix(issue-template): drop expected-vs-actual, make acceptance optional on bug form (#1562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the "Expected vs. actual" field from the bug report form and makes the "Acceptance" field optional. The bug form's required fields now map cleanly to the issue gate, which for `bug`-labeled issues only requires a Problem section plus Steps-to-reproduce/evidence — it never required Acceptance for bugs. `hasRepro` is still satisfied by "Steps to reproduce / evidence," so dropping expected-vs-actual won't trip the gate. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/ISSUE_TEMPLATE/bug.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 3a413886..cc026e92 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -24,20 +24,13 @@ body: description: Minimal steps, logs, or a stack trace that demonstrate it. validations: required: true - - type: textarea - id: expected-actual - attributes: - label: Expected vs. actual - description: What you expected to happen, and what actually happened. - validations: - required: true - type: textarea id: acceptance attributes: label: Acceptance description: How we'll know it's fixed — verifiable criteria. validations: - required: true + required: false - type: input id: env attributes: From 1fb90edd900979abdc7a11c3a34ee6aaed2ae3ed Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:27:26 -0700 Subject: [PATCH 21/81] chore: release v0.78.0 (#1563) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- sites/marketing/data/changelog.json | 30 +++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7bba61..0e5a5526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.78.0] - 2026-07-01 + ### Added - **Developer panel — view & toggle developer flags** (#1506, ADR 0068). A new **Settings ▸ Developer** section (surfaced only off prod — a dev build, a non-prod `developer.channel`, or a `?dev` reveal — diff --git a/pyproject.toml b/pyproject.toml index b7c5a82e..9827cdbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.77.0" +version = "0.78.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" requires-python = ">=3.11" diff --git a/sites/marketing/data/changelog.json b/sites/marketing/data/changelog.json index 8629c3da..0b89b898 100644 --- a/sites/marketing/data/changelog.json +++ b/sites/marketing/data/changelog.json @@ -1,4 +1,34 @@ [ + { + "version": "v0.78.0", + "date": "2026-07-01", + "changes": [ + "Developer panel — view & toggle developer flags", + "Developer flags — backend foundation", + "Chat composer: terminal-style input history", + "Artifact panel: pan & zoom for diagrams", + "registerKeybinding on the fork extension seam", + "Watch primitive — supervise many external conditions at once", + "sdk.run_in_session(session_id, prompt)", + "Two-credential auth: auth.federation_token", + "Console set-goal form", + "Chat: slash commands trigger mid-input + render as command bubbles", + "Agent switcher: always available, with a Fleet-settings shortcut", + "Chat: Cmd/Ctrl+O toggles the latest tool-call block", + "Chat: /compact — summarize + archive a long thread", + "Knowledge panel: Upload / Add now open in a dialog", + "Goal mode is now drive-only; the monitor disposition is retired", + "Goal continuation protocol → tools", + "The A2A-streaming and non-streaming goal drive loops are unified (#1497), fixing a fresh-context thread-id drift.", + "Settings sub-panels share one container", + "wait tool output is conversational", + "Desktop app builds are on-demand", + "RCE-via-chat closed", + "Watch evaluation is serialized per watch id", + "New ADR 0066 (federation token + operator channel) and ADR 0067 (watch primitive); ADR 0030 marked superseded", + "PROTO.md § Run it" + ] + }, { "version": "v0.77.0", "date": "2026-07-01", From 6bdd1295fea781b7b6be23cd24f484278b9b0d83 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:07:04 -0700 Subject: [PATCH 22/81] fix(console-dev): default vite proxy to the isolated :7871 dev instance, not prod :7870 (#1564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web/vite.config.ts proxied `npm run dev`/`preview` backend calls (/api, /a2a, events, /agents, /plugins, /_ds) to http://127.0.0.1:7870 BY DEFAULT — the default/prod instance the desktop app runs on ~/.protoagent. So browser-based console dev silently read AND wrote the real desktop-app data (crossing dev↔prod streams). Yesterday's ADR-0065 instance isolation separated the backends but never covered this frontend-proxy layer. Default is now http://127.0.0.1:7871 (the isolated `scripts/dev.sh` instance) — fail-safe (a clean can't-connect if no dev backend is up, never a silent prod hit) — plus a loud red startup guard whenever PROTOAGENT_API_BASE points at :7870. The correct dev loop is `scripts/dev.sh` (backend :7871) + `npm run dev` (frontend), both isolated. Docs: PROTO.md § Run it + CHANGELOG. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ PROTO.md | 8 ++++++++ apps/web/vite.config.ts | 22 +++++++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5a5526..40475e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Console dev server no longer defaults to the prod backend.** `apps/web/vite.config.ts` proxied + `npm run dev` / `npm run preview` backend calls to `:7870` (the default/prod instance the desktop + app runs) by default — so browser-based console development silently read and **wrote your real + `~/.protoagent` data**. It now defaults to the **isolated dev instance `:7871`** (`scripts/dev.sh`), + which is fail-safe (a clean "can't connect" when no dev backend is up, never a silent prod hit), + and prints a **loud red guard** if `PROTOAGENT_API_BASE` is ever pointed at `:7870`. + ## [0.78.0] - 2026-07-01 ### Added diff --git a/PROTO.md b/PROTO.md index 7bf12fc1..bb033850 100644 --- a/PROTO.md +++ b/PROTO.md @@ -51,6 +51,14 @@ TypeScript is the console. the source of truth; `uv.lock` is tracked). `uv sync` to install. - **Console deps:** `npm ci` at the repo root (npm workspaces; the web app is `@protoagent/web`). +- **Console dev loop (frontend):** `npm run dev` (HMR) / `npm run preview` (built dist) serve + the console on `:5173` and **proxy all backend calls (`/api`, `/a2a`, events, `/agents`, + `/plugins`, `/_ds`) to `PROTOAGENT_API_BASE`, default `http://127.0.0.1:7871`** — the + ISOLATED dev instance from `scripts/dev.sh`, **not** the default/prod `:7870` the desktop app + runs. So the correct loop is *`scripts/dev.sh` (backend, :7871) + `npm run dev` (frontend)* — + both isolated, so dev testing never touches your `~/.protoagent` data. Vite prints a loud red + guard if you ever point `PROTOAGENT_API_BASE` at `:7870`. (Historically it defaulted to + `:7870`, which silently crossed dev traffic into the prod/desktop instance.) ## Must pass before opening a PR diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 840101c9..975532d3 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,7 +3,27 @@ import react from "@vitejs/plugin-react"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, new URL("../..", import.meta.url).pathname, "PROTOAGENT_"); - const apiBase = env.PROTOAGENT_API_BASE || "http://127.0.0.1:7870"; + // The dev/preview server proxies the console's backend calls to `apiBase`. It DEFAULTS to the + // ISOLATED dev instance (:7871, what `scripts/dev.sh` runs) — deliberately NOT the default/prod + // instance (:7870, what the desktop app runs). Rationale: `npm run dev`/`preview` must never + // silently read/write your real ~/.protoagent data. If no dev backend is up on :7871 you get a + // clean "can't connect" (fail-safe) instead of a silent prod hit. Override with PROTOAGENT_API_BASE. + const apiBase = env.PROTOAGENT_API_BASE || "http://127.0.0.1:7871"; + + // Loud guard: proxying the dev frontend at :7870 points it straight at the default/prod + // (desktop-app) instance — every console action would hit your real data. Scream about it. + if (apiBase.indexOf(":7870") !== -1) { + const bar = Array(77).join("═"); // ES5-safe (tsconfig.node lib predates String.repeat) + console.warn( + `\n\x1b[41m\x1b[97m\x1b[1m ${bar} \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m ⚠ DEV FRONTEND → ${apiBase} — the PROD / desktop-app backend (:7870). \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m Console actions (chat, goals, /compact…) will read/WRITE your real \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m ~/.protoagent data. Use an ISOLATED dev backend instead: \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m scripts/dev.sh # isolated instance on :7871 \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m unset PROTOAGENT_API_BASE (defaults to :7871), or set it to :7871. \x1b[0m` + + `\n\x1b[41m\x1b[97m\x1b[1m ${bar} \x1b[0m\n`, + ); + } // Module Federation was retired (ADR 0038): plugin UI is sandboxed iframes (untrusted / // generative) + the build-time fork seam (trusted) — no runtime remote loading. From 9aa019aafe753588c9ea85ef0d465ecdd3e575b9 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:18:45 -0700 Subject: [PATCH 23/81] test(compaction): prove /compact's archive is real + searchable end-to-end (#1527) (#1566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped suite proved the checkpoint rewrite against a real SQLite checkpointer but faked the knowledge store — so "the full transcript is archived and searchable" was asserted only at the add_document call, not that it's retrievable. Add an integration test using a REAL KnowledgeStore (FTS5, no gateway): seed a distinctive token ("pumpernickel") in the head that compaction REMOVES, compact, then assert (1) it actually compacted (archived_chunks>=1, removed=4, kept=2, summary returned), (2) the live checkpoint collapsed to [summary, tail] with the head gone, and (3) store.search finds the removed head in the conversation domain — proving the raw history is preserved + searchable. Co-authored-by: Claude Opus 4.8 (1M context) --- tests/test_compaction_op.py | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_compaction_op.py b/tests/test_compaction_op.py index 9f62a03e..77d54f53 100644 --- a/tests/test_compaction_op.py +++ b/tests/test_compaction_op.py @@ -187,3 +187,51 @@ async def run(): assert len(final) == 3 assert "SUMMARY" in final[0].content and final[0].additional_kwargs.get("lc_source") == "compaction" assert final[1].content == "q2" and final[2].content == "answer2" + + +def test_compact_archive_is_searchable_in_a_real_store(tmp_path): + """End-to-end 'searchable full-text save': the FULL raw transcript — including + the head that compaction REMOVES from live context — is archived into a REAL + KnowledgeStore (FTS5, no gateway) and retrievable by search. Only the + summarizer is stubbed; the checkpoint rewrite + archive are real.""" + from knowledge.store import KnowledgeStore + + g = StateGraph(MessagesState) + g.add_node("n", lambda s: {"messages": []}) + g.add_edge(START, "n") + g.add_edge("n", END) + saver = build_sqlite_checkpointer(str(tmp_path / "c.db")) + app = g.compile(checkpointer=saver) + cfg = {"configurable": {"thread_id": "a2a:s1"}} + seed = [ + HumanMessage(content="my favorite bread is pumpernickel"), + AIMessage(content="noted, pumpernickel"), + HumanMessage(content="old filler two"), + AIMessage(content="ok two"), + HumanMessage(content="what is the capital of France"), + AIMessage(content="Paris"), + ] + store = KnowledgeStore(db_path=str(tmp_path / "kb.db")) + + async def run(): + await app.ainvoke({"messages": seed}, cfg) + res = await compact_thread(app, saver, store, _cfg(2), "a2a:s1", "s1", summarizer=_summ) + snap = await app.aget_state(cfg) + return res, snap.values["messages"] + + res, final = asyncio.run(run()) + + # (1) Actually compacted: archived a chunk, removed the head, kept the tail. + assert res["archived"] is True and res["refused"] is False + assert res["archived_chunks"] >= 1 and res["removed"] == 4 and res["kept"] == 2 + assert res["summary"] + + # (2) Live context collapsed to [summary, capital-of-France, Paris] — head GONE. + assert len(final) == 3 + assert all("pumpernickel" not in (m.content or "") for m in final) + + # (3) …but the removed head is preserved AND SEARCHABLE in the real store. + hits = store.search("pumpernickel", domain="conversation") + assert hits, "archived transcript is NOT searchable" + blob = " ".join(str(h.get("content") or h.get("preview") or "") for h in hits) + assert "pumpernickel" in blob From 403ca3e8f2e512eecc1bf055674054540005ceec Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:19:17 -0700 Subject: [PATCH 24/81] feat(fleet): data-driven archetype registry + apply persona on create (ADR 0042) (#1565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archetypes were a hardcoded list in `_archetypes()`, and the built-in "Project Manager" pointed at `protoLabsAI/pm-stack` — a repo since split into product-stack / leadEngineer / portfolio-manager-stack, so the card no longer installed what it promised. - Move built-in archetypes to `config/archetype-catalog.json` (served by GET /api/archetypes), so they can be added/removed with no code change; live config dir overrides the bundled seed like plugin/mcp catalogs. A hardcoded Basic+Custom fallback keeps the picker working if it's missing. - Ship Basic + Custom; installed bundles that declare an `archetype:` block still self-register on top, now DEDUPED by id + normalized bundle URL so a card can't appear twice (fixes a latent duplicate React key). - Fleet "New agent" now applies the archetype's persona: POST /api/fleet gained `soul`, threaded to manager.create(), which writes it to the new workspace's config/SOUL.md — so a bundle agent arrives WITH its persona. - Refresh fleet guide + e2e fixture (dead pm-stack → product-stack) + tests (catalog fallback, dedupe, persona-write). CHANGELOG under [Unreleased]. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 +++ apps/web/e2e/fixtures.mjs | 2 +- apps/web/e2e/fleet.spec.ts | 4 +- apps/web/src/lib/api.ts | 9 +- apps/web/src/lib/types.ts | 2 +- apps/web/src/settings/NewAgentPanel.tsx | 6 +- apps/web/src/setup/SetupWizard.tsx | 12 +- config/archetype-catalog.json | 21 +++ docs/guides/fleet.md | 40 +++--- graph/workspaces/manager.py | 11 ++ operator_api/fleet_routes.py | 183 +++++++++++++++++------- tests/test_fleet_routes.py | 75 +++++++++- 12 files changed, 300 insertions(+), 85 deletions(-) create mode 100644 config/archetype-catalog.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 40475e28..2151bb39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Agent archetypes are a data-driven registry** (ADR 0042). The new-agent picker + setup + wizard now read their built-in starter types from `config/archetype-catalog.json` + (served by `GET /api/archetypes`) instead of a hardcoded list — so archetypes can be added + or removed **without a code change**, and a fork/instance overrides the set by dropping its + own `archetype-catalog.json` in the live config dir (same override rule as + `plugin-catalog.json`/`mcp-catalog.json`). Ships **Basic** + **Custom**; installed bundles + that declare an `archetype:` block still self-register on top, now **deduped** by id + bundle + URL so a card can't appear twice. + +### Changed +- **Fleet "New agent" now applies the archetype's persona**, not just its tools. Creating an + agent from an archetype writes its base `SOUL.md` into the new workspace (`POST /api/fleet` + gained a `soul` field), so a bundle agent arrives with its persona wired in. + ### Fixed - **Console dev server no longer defaults to the prod backend.** `apps/web/vite.config.ts` proxied `npm run dev` / `npm run preview` backend calls to `:7870` (the default/prod instance the desktop @@ -18,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `~/.protoagent` data**. It now defaults to the **isolated dev instance `:7871`** (`scripts/dev.sh`), which is fail-safe (a clean "can't connect" when no dev backend is up, never a silent prod hit), and prints a **loud red guard** if `PROTOAGENT_API_BASE` is ever pointed at `:7870`. +- **Removed the broken built-in "Project Manager" archetype.** It pointed at + `protoLabsAI/pm-stack`, which was split/renamed (→ `product-stack`, `leadEngineer`, + `portfolio-manager-stack`); the stale URL no longer installed what the card promised. Those + stacks self-register as archetypes when installed, and the URL is now a data edit rather than + a code constant. ## [0.78.0] - 2026-07-01 diff --git a/apps/web/e2e/fixtures.mjs b/apps/web/e2e/fixtures.mjs index 98fe37c7..e242ed97 100644 --- a/apps/web/e2e/fixtures.mjs +++ b/apps/web/e2e/fixtures.mjs @@ -111,7 +111,7 @@ export const FLEET = { export const ARCHETYPES = [ { id: "basic", label: "Basic", icon: "Sparkles", blurb: "A plain agent — add tools later.", bundle: null, soul: "# Identity\n\nI am a general-purpose agent." }, - { id: "pm-stack", label: "Project Manager", icon: "LayoutGrid", blurb: "PM tools + board.", bundle: "https://github.com/protoLabsAI/pm-stack", soul: "# Identity\n\nI am a project-management agent." }, + { id: "product-stack", label: "Product Manager", icon: "Compass", blurb: "Research, strategy, and specs — rendered inline.", bundle: "https://github.com/protoLabsAI/product-stack", soul: "# Identity\n\nI am a product-management agent." }, { id: "custom", label: "Custom", icon: "PenLine", blurb: "Write your own — fill in a template.", bundle: null, soul: "# Identity\n\n_Describe your agent in one paragraph._" }, ]; diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index 79b74e3e..ad57e7b2 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -55,8 +55,8 @@ test("New agent → archetype picker → create returns to the list", async ({ p await openAgents(page); await page.getByRole("button", { name: "New agent" }).click(); await expect(page.getByRole("heading", { name: "New agent" })).toBeVisible(); - await expect(page.locator(".pl-radiocard")).toHaveCount(2); // DS RadioCard, from GET /api/archetypes - await page.locator(".pl-radiocard", { hasText: "Project Manager" }).click(); + await expect(page.locator(".pl-radiocard")).toHaveCount(2); // DS RadioCard, from GET /api/archetypes (Custom filtered out) + await page.locator(".pl-radiocard", { hasText: "Product Manager" }).click(); await page.getByLabel("Agent name").fill("newbot"); await page.getByRole("button", { name: /Create/ }).click(); await expect(page.locator(".fleet-row", { hasText: "newbot" })).toBeVisible(); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index d726e555..16c95e88 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1088,7 +1088,14 @@ export const api = { archetypes() { return request<{ archetypes: Archetype[] }>("/api/archetypes"); }, - createAgent(body: { name: string; bundle?: string | null; port?: number; start?: boolean; shared_skills?: boolean }) { + createAgent(body: { + name: string; + bundle?: string | null; + soul?: string; + port?: number; + start?: boolean; + shared_skills?: boolean; + }) { return request<{ ok: boolean; agent: FleetAgent; installed: string[] }>("/api/fleet", { method: "POST", body, diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index fbff4adc..d61cd00e 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -769,7 +769,7 @@ export type FleetStatus = { agents: FleetAgent[] }; export type DiscoveredAgent = { name: string; url: string; host: string; port: number }; export type Archetype = { - id: string; // "basic", or a bundle id e.g. "pm-stack" + id: string; // "basic"/"custom", or a bundle id e.g. "product-stack" label: string; icon: string; // lucide-react icon name blurb: string; diff --git a/apps/web/src/settings/NewAgentPanel.tsx b/apps/web/src/settings/NewAgentPanel.tsx index 3bee62ab..9eefee6a 100644 --- a/apps/web/src/settings/NewAgentPanel.tsx +++ b/apps/web/src/settings/NewAgentPanel.tsx @@ -31,7 +31,11 @@ export function NewAgentPanel({ onDone, onCancel }: { onDone?: (name: string) => const nameOk = NAME_RE.test(name); const create = useMutation({ - mutationFn: () => api.createAgent({ name: name.trim(), bundle: archetype?.bundle ?? null }), + // Carry the archetype's base SOUL so a bundle agent arrives WITH its persona, not just + // its tools (ADR 0042). Blank soul (bundle with no inline persona) → server leaves the + // agent on the default SOUL. + mutationFn: () => + api.createAgent({ name: name.trim(), bundle: archetype?.bundle ?? null, soul: archetype?.soul || undefined }), onError: (e: Error) => toast({ tone: "error", title: "Couldn't create agent", message: e.message }), onSuccess: (res) => { qc.invalidateQueries({ queryKey: queryKeys.fleet }); diff --git a/apps/web/src/setup/SetupWizard.tsx b/apps/web/src/setup/SetupWizard.tsx index ed13cfe6..eb690e4f 100644 --- a/apps/web/src/setup/SetupWizard.tsx +++ b/apps/web/src/setup/SetupWizard.tsx @@ -153,9 +153,9 @@ export function SetupWizard({ }) { const [step, setStep] = useState("welcome"); const [state, setState] = useState(() => defaultState()); - // Starter archetypes (Basic + Project Manager + installed bundles) — the same - // source the fleet new-agent picker uses. Each carries a base SOUL the persona - // step seeds when picked (ADR 0042). + // Starter archetypes (the archetype-catalog: Basic + Custom, plus any installed bundle + // that self-registers) — the same GET /api/archetypes source the fleet new-agent picker + // uses. Each carries a base SOUL the persona step seeds when picked (ADR 0042). const archetypes = useQuery(archetypesQuery()); const acpAgentList = useQuery(acpAgentsQuery()).data?.agents ?? []; const [models, setModels] = useState([]); @@ -292,7 +292,7 @@ export function SetupWizard({ }, [loaded, archetypeList, state.soul, state.archetype]); // The picked archetype drives the finish summary AND (Plan C) the bundle install: - // an archetype with a bundle (e.g. Project Manager → pm-stack) installs its plugins + // an archetype with a bundle (e.g. Product Manager → product-stack) installs its plugins // into this host on finish, so the persona arrives WITH its tools. const pickedArchetype = archetypeList.find((a) => a.id === state.archetype); const personaLabel = pickedArchetype?.label ?? state.archetype; @@ -376,8 +376,8 @@ export function SetupWizard({ if (state.initTasks) { await api.initTasks(); } - // Plan C: if the chosen archetype carries a plugin bundle (e.g. Project - // Manager → pm-stack), install it into THIS host on finish — so the new user + // Plan C: if the chosen archetype carries a plugin bundle (e.g. Product + // Manager → product-stack), install it into THIS host on finish — so the new user // gets the persona AND its tools/board in one shot, not just the prose. // installPlugin auto-enables + hot-reloads the bundle's plugins (no restart). // A failure is non-fatal: setup is already written, so we finish anyway and diff --git a/config/archetype-catalog.json b/config/archetype-catalog.json new file mode 100644 index 00000000..63a76633 --- /dev/null +++ b/config/archetype-catalog.json @@ -0,0 +1,21 @@ +{ + "_comment": "Curated archetype directory for the new-agent picker + setup wizard (ADR 0042), served by GET /api/archetypes. Data-driven so archetypes can be added or removed WITHOUT a code change — a fork or instance overrides this file by dropping its own archetype-catalog.json in the live config dir (same override rule as plugin-catalog.json / mcp-catalog.json). Fields: id (also the RadioCard value + React key — keep unique), label, icon (a lucide-react name), blurb, bundle (a git URL installed into the agent on create, or null for a code-free persona), and the base SOUL.md the persona step seeds — either soul_preset (a file STEM under config/soul-presets/, resolved server-side) or an inline soul string. Installed plugin bundles that declare an `archetype:` manifest block self-register on top of these and don't need an entry here (deduped by id + bundle URL). Keep `custom` LAST — it's the catch-all write-your-own persona.", + "archetypes": [ + { + "id": "basic", + "label": "Basic", + "icon": "Sparkles", + "bundle": null, + "blurb": "A blank-slate agent — the core loop + built-in tools, no plugins.", + "soul_preset": "base" + }, + { + "id": "custom", + "label": "Custom", + "icon": "PenLine", + "bundle": null, + "blurb": "Write your own — start from a SOUL template and fill it in.", + "soul_preset": "blank" + } + ] +} diff --git a/docs/guides/fleet.md b/docs/guides/fleet.md index 61ab19d7..1962e828 100644 --- a/docs/guides/fleet.md +++ b/docs/guides/fleet.md @@ -9,7 +9,7 @@ primitives: |---|---|---| | **Workspace** | a named agent — its own config, secrets, plugins, scoped data, port | [0041](../adr/0041-workspaces-and-tiered-stores.md) | | **Bundle** | a curated, pinned set of plugins installed as one | [0040](../adr/0040-plugin-bundles.md) | -| **Archetype** | a bundle presented as a starter *agent type* (or the built-in **Basic**) | [0042](../adr/0042-fleet-supervisor-unified-console.md) | +| **Archetype** | a starter *agent type* in the new-agent picker — the built-in **Basic**/**Custom** (from a data-driven catalog) plus any installed bundle that declares one | [0042](../adr/0042-fleet-supervisor-unified-console.md) | | **Tiered stores** | per-agent private data + an opt-in shared **commons** | [0041](../adr/0041-workspaces-and-tiered-stores.md) | | **Supervisor** | run agents as persistent background processes (start/stop/status) | [0042](../adr/0042-fleet-supervisor-unified-console.md) | | **Unified console** | one slug-routed console that hot-swaps between running agents (per-agent layout/theme) | [0042](../adr/0042-fleet-supervisor-unified-console.md) | @@ -17,8 +17,8 @@ primitives: ## Quick start ```bash -# an agent from the "Project Manager" archetype (the pm-stack bundle) -python -m server workspace new pm --bundle https://github.com/protoLabsAI/pm-stack +# an agent from a bundle archetype (the product-stack bundle: PM toolkit + generative UI) +python -m server workspace new pm --bundle https://github.com/protoLabsAI/product-stack # a blank-slate agent (the built-in Basic archetype — core loop + tools, no plugins) python -m server workspace new scratch @@ -26,7 +26,7 @@ python -m server workspace new scratch # run the whole fleet in the background, then look at it python -m server fleet up python -m server fleet ls -# ● pm :7871 pid 12345 [pm-stack] +# ● pm :7871 pid 12345 [product-stack] # ● scratch :7872 pid 12346 ``` @@ -57,7 +57,7 @@ suggested enable list + config. Install one into a workspace and you skip the plugin-by-plugin setup: ```bash -python -m server plugin install https://github.com/protoLabsAI/pm-stack # fans out + pins each member +python -m server plugin install https://github.com/protoLabsAI/product-stack # fans out + pins each member ``` A bundle that carries an **`archetype:`** block becomes a **starter agent type** the @@ -65,22 +65,30 @@ new-agent picker offers — additive metadata, no change to the bundle shape: ```yaml # protoagent.bundle.yaml -id: pm-stack +id: product-stack plugins: [ … ] enabled: [ … ] archetype: - label: Project Manager - icon: LayoutGrid - blurb: Board-driven shipping agent — decomposes an idea and ships it via coding agents. + label: Product Manager + icon: Compass + blurb: Researches, strategizes, and specs products from evidence — renders roadmaps and personas inline. ``` -Two starter types exist today: - -- **Basic** — built-in, ships with protoAgent: the bare agent loop + built-in tools, **no - plugins**. It's just `workspace new ` with no `--bundle` (the "start from scratch"). -- **Project Manager** — the [pm-stack](https://github.com/protoLabsAI/pm-stack) bundle. - -Every future bundle that adds an `archetype:` block becomes a starter type for free. See +The picker draws from **two** sources: + +- **The archetype catalog** — `config/archetype-catalog.json`, served by `GET /api/archetypes`. + It ships the two code-free personas — **Basic** (the bare loop + tools, no plugins) and + **Custom** (write-your-own SOUL) — and is **data-driven**: add or remove archetypes by + editing the JSON, no code change. A fork or instance overrides it by dropping its own + `archetype-catalog.json` in the live config dir (same rule as `plugin-catalog.json`). Each + entry names a `soul_preset` (a file under `config/soul-presets/`) or an inline `soul` for the + base persona. +- **Installed bundles** — any bundle whose manifest carries an `archetype:` block + **self-registers** on top of the catalog (deduped by id + bundle URL). Install the bundle + and its starter type appears in the picker for free — no catalog edit needed. + +Picking an archetype seeds the new agent's **persona** (its `SOUL.md`) from that base, and — if +it carries a bundle — installs the bundle's plugins into the new agent. See [Install & publish plugins](./plugin-registry.md). ## Tiered stores — private by default, share what should be shared diff --git a/graph/workspaces/manager.py b/graph/workspaces/manager.py index ddf91a78..2a51fe6f 100644 --- a/graph/workspaces/manager.py +++ b/graph/workspaces/manager.py @@ -218,6 +218,7 @@ def create( bundle: str | None = None, port: int | None = None, shared_skills: bool = False, + soul: str | None = None, ) -> dict: """Scaffold a workspace: its config dir, ``workspace.yaml``, and (with ``bundle``) an installed plugin bundle. Does not start it. @@ -228,6 +229,10 @@ def create( secrets popped over (the gateway), so it boots ready-to-chat WITHOUT inheriting its plugins/skills. This is the fleet's default "new agent" (a blank agent, model carried). * neither — the plain blank template. + + ``soul`` (the picked archetype's base SOUL.md, ADR 0042) is written into the workspace's + ``config/SOUL.md`` — the member's live persona — so an agent created from an archetype + arrives with its persona, not just its tools. Blank leaves the agent on the default SOUL. """ name = _safe(name) if name.lower() in _RESERVED_NAMES: @@ -265,6 +270,12 @@ def create( if shared_skills: _stamp_identity(cfg, name, True, instance_id=wid) + # The archetype persona (ADR 0042) — the member reads its SOUL at /config/SOUL.md + # (instance_root=), so scaffold it there. Only when non-empty: a blank archetype + # (or none) leaves the agent on the default SOUL rather than writing an empty persona. + if soul and soul.strip(): + (cfg_dir / "SOUL.md").write_text(soul, encoding="utf-8") + import yaml assigned = _pick_port(port) diff --git a/operator_api/fleet_routes.py b/operator_api/fleet_routes.py index fb7fd5b2..dfb414fa 100644 --- a/operator_api/fleet_routes.py +++ b/operator_api/fleet_routes.py @@ -134,14 +134,19 @@ async def _agent_ws_proxy(ws: WebSocket, slug: str, path: str): async def _create_agent(body: dict = Body(...)): """Create an agent (optionally from a bundle archetype) and start it. - Body: ``{name, bundle?: , port?: int, start?: bool=true, - shared_skills?: bool, inherit_config?: bool=true}``. A blank ``bundle`` is the built-in + Body: ``{name, bundle?: , soul?: str, port?: int, start?: bool=true, + shared_skills?: bool, inherit_config?: bool=true}``. ``soul`` is the archetype's base + SOUL.md (persona), written into the workspace so a bundle agent gets its persona too. + A blank ``bundle`` is the built-in **Basic** archetype. By default a new agent is a **blank agent with the host's model config + secrets popped over** (the gateway only — NOT the host's plugins/skills), so it boots ready-to-chat. Set ``inherit_config: false`` for a fully blank agent you'll set up. """ name = str(body.get("name", "")).strip() bundle = (str(body.get("bundle") or "").strip()) or None + # The archetype's base SOUL.md (the persona picked in the new-agent picker), written + # into the workspace so a bundle agent arrives WITH its persona, not just its tools. + soul = (str(body.get("soul") or "").strip()) or None port = body.get("port") start = bool(body.get("start", True)) shared = bool(body.get("shared_skills", False)) @@ -157,7 +162,13 @@ async def _create_agent(body: dict = Body(...)): try: # create() may overlay the host model + install a bundle (subprocess) — off the loop. ws = await asyncio.to_thread( - manager.create, name, bundle=bundle, port=port, shared_skills=shared, inherit_model=inherit_model + manager.create, + name, + bundle=bundle, + port=port, + shared_skills=shared, + inherit_model=inherit_model, + soul=soul, ) agent = ( (await asyncio.to_thread(supervisor.start, name)) @@ -221,66 +232,130 @@ async def _list_archetypes(): return {"archetypes": _archetypes()} -def _archetypes() -> list[dict]: - """Built-in Basic + installed-bundle archetypes (cached in plugins.lock). +def _norm_url(u: str | None) -> str: + """Canonicalize a git URL for dedupe (drop trailing ``.git`` / ``/``, lowercase) — + the same normalization the plugin catalog uses to match install state by URL.""" + import re + + return re.sub(r"\.git$", "", (u or "").strip().rstrip("/")).lower() + + +# Last-resort archetypes if ``archetype-catalog.json`` is missing or unreadable — the two +# code-free personas, so the picker + wizard always work even on a broken/forked config. +_FALLBACK_ARCHETYPES = [ + { + "id": "basic", + "label": "Basic", + "icon": "Sparkles", + "bundle": None, + "blurb": "A blank-slate agent — the core loop + built-in tools, no plugins.", + "soul_preset": "base", + }, + { + "id": "custom", + "label": "Custom", + "icon": "PenLine", + "bundle": None, + "blurb": "Write your own — start from a SOUL template and fill it in.", + "soul_preset": "blank", + }, +] + + +def _load_archetype_catalog() -> list[dict]: + """Built-in archetype entries from ``archetype-catalog.json`` — the live config dir + overrides the bundled seed (a fork adds/removes archetypes with NO code change), same + lookup order as the plugin/MCP catalogs. Falls back to Basic + Custom if the file is + absent or malformed, so the new-agent picker + wizard never come up empty-handed.""" + import json + + from infra.paths import instance_paths + + ip = instance_paths() + for base in (ip.config_dir, ip.bundle_dir): + f = base / "archetype-catalog.json" + if f.exists(): + try: + entries = (json.loads(f.read_text()) or {}).get("archetypes") + if isinstance(entries, list) and entries: + return entries + except (json.JSONDecodeError, OSError): + log.warning("[fleet] archetype-catalog.json unreadable at %s", f) + break # live dir wins even if broken — don't silently fall through to the seed + return _FALLBACK_ARCHETYPES - Each archetype carries an optional ``soul`` — a base SOUL.md the setup - wizard seeds when the operator picks it (ADR 0042). Built-ins read it from - a ``config/soul-presets`` file; bundle archetypes declare it inline in - their ``archetype:`` manifest block. + +def _archetypes() -> list[dict]: + """Starter agent types for the new-agent picker + setup wizard (ADR 0042). + + Data-driven: the built-in set comes from ``archetype-catalog.json`` (see + ``_load_archetype_catalog``), merged with every installed bundle's ``archetype:`` + manifest metadata (cached in ``plugins.lock``). Each archetype carries an optional + ``soul`` — a base SOUL.md the persona step seeds when the operator picks it: the catalog + names a ``soul_preset`` file under ``config/soul-presets/`` (resolved here) or an inline + ``soul``; a bundle declares it inline in its manifest. The whole list is deduped by id + + bundle URL (a catalog entry for a stack never doubles up with the same installed bundle), + and ``custom`` is kept LAST. """ from graph.config_io import read_soul_preset - out = [ - { - "id": "basic", - "label": "Basic", - "icon": "Sparkles", - "bundle": None, - "blurb": "A blank-slate agent — the core loop + built-in tools, no plugins.", - "soul": read_soul_preset("base"), - }, - { - # Built-in PM archetype — installed FRESH from the git URL on each create (no pin), - # so a new PM agent always gets the latest pm-stack. - "id": "pm-stack", - "label": "Project Manager", - "icon": "LayoutGrid", - "bundle": "https://github.com/protoLabsAI/pm-stack", - "blurb": "Project-management tools + board — clones the latest pm-stack on create.", - "soul": read_soul_preset("project-manager"), - }, - ] + out: list[dict] = [] + custom: dict | None = None + seen_ids: set[str] = set() + seen_urls: set[str] = set() + + for entry in _load_archetype_catalog(): + aid = str(entry.get("id") or "").strip() + if not aid or aid in seen_ids: + continue + soul = entry.get("soul") or (read_soul_preset(str(entry["soul_preset"])) if entry.get("soul_preset") else "") + bundle = entry.get("bundle") or None + rec = { + "id": aid, + "label": entry.get("label", aid), + "icon": entry.get("icon", "Package"), + "bundle": bundle, + "blurb": entry.get("blurb", ""), + "soul": soul, + } + seen_ids.add(aid) + if bundle: + seen_urls.add(_norm_url(bundle)) + if aid == "custom": + custom = rec # hold it back so it stays last after bundle archetypes append + else: + out.append(rec) + + # Installed bundles that declare `archetype:` metadata self-register as starter types — + # appended after the catalog, deduped by id + normalized bundle URL so a catalog entry + # for the same stack (or a bundle listed twice) never produces a duplicate RadioCard. try: from graph.plugins.installer import _read_lock for b in _read_lock().get("bundles") or []: arch = b.get("archetype") or {} - if arch.get("label"): - out.append( - { - "id": b.get("id"), - "label": arch.get("label"), - "icon": arch.get("icon", "Package"), - "blurb": arch.get("blurb", ""), - "bundle": b.get("source_url"), - "soul": arch.get("soul", ""), - } - ) + bid = str(b.get("id") or "").strip() + url = b.get("source_url") or "" + if not arch.get("label") or not bid: + continue + if bid in seen_ids or (url and _norm_url(url) in seen_urls): + continue + seen_ids.add(bid) + if url: + seen_urls.add(_norm_url(url)) + out.append( + { + "id": bid, + "label": arch.get("label"), + "icon": arch.get("icon", "Package"), + "blurb": arch.get("blurb", ""), + "bundle": url or None, + "soul": arch.get("soul", ""), + } + ) except Exception: # noqa: BLE001 — archetype discovery is best-effort log.warning("[fleet] archetype discovery failed", exc_info=True) - # "Custom" is the catch-all, kept LAST as more archetypes land above it — a - # blank-slate persona the operator writes themselves. Like Basic it carries no - # bundle, but it seeds the editor with the fill-in-the-blanks SOUL scaffold - # rather than a ready-to-use base prompt. - out.append( - { - "id": "custom", - "label": "Custom", - "icon": "PenLine", - "bundle": None, - "blurb": "Write your own — start from a SOUL template and fill it in.", - "soul": read_soul_preset("blank"), - } - ) + + if custom is not None: + out.append(custom) # the catch-all write-your-own persona, always LAST return out diff --git a/tests/test_fleet_routes.py b/tests/test_fleet_routes.py index bc312d58..92b4395c 100644 --- a/tests/test_fleet_routes.py +++ b/tests/test_fleet_routes.py @@ -39,17 +39,62 @@ def test_archetypes_include_basic(client): def test_archetypes_carry_base_soul(client): - # Each archetype seeds the wizard's persona step with a base SOUL (ADR 0042) — - # the built-in Basic + PM read theirs from config/soul-presets/. + # Each archetype seeds the wizard's persona step with a base SOUL (ADR 0042) — the + # catalog names a soul_preset file under config/soul-presets/, resolved server-side. arr = client.get("/api/archetypes").json()["archetypes"] by_id = {a["id"]: a for a in arr} assert "soul" in by_id["basic"] and by_id["basic"]["soul"].strip() - assert by_id["pm-stack"]["soul"].strip() # "Custom" is the catch-all write-your-own archetype, kept last with the # fill-in template SOUL. assert arr[-1]["id"] == "custom" and by_id["custom"]["soul"].strip() +def test_archetypes_fall_back_when_catalog_missing(client, monkeypatch): + # A missing/unreadable archetype-catalog.json must still yield the two code-free + # personas (Basic + Custom) so the picker never comes up empty (ADR 0042). + from operator_api import fleet_routes + + monkeypatch.setattr(fleet_routes, "_load_archetype_catalog", lambda: fleet_routes._FALLBACK_ARCHETYPES) + arr = client.get("/api/archetypes").json()["archetypes"] + ids = [a["id"] for a in arr] + assert ids[0] == "basic" and ids[-1] == "custom" + assert all(a["soul"].strip() for a in arr) # soul_preset resolved to real content + + +def test_archetypes_dedupe_installed_bundle_against_catalog(client, monkeypatch): + # An installed bundle whose id/URL already appears in the catalog must NOT produce a + # duplicate RadioCard (duplicate React key + ambiguous radio value). Catalog wins. + from operator_api import fleet_routes + + monkeypatch.setattr( + fleet_routes, + "_load_archetype_catalog", + lambda: [ + {"id": "basic", "label": "Basic", "bundle": None, "soul_preset": "base"}, + {"id": "acme", "label": "Acme", "bundle": "https://github.com/acme/stack.git", "soul": "x"}, + {"id": "custom", "label": "Custom", "bundle": None, "soul_preset": "blank"}, + ], + ) + + def fake_lock(): + return { + "bundles": [ + # same id as a catalog entry + {"id": "acme", "source_url": "https://other/url", "archetype": {"label": "Dup id"}}, + # same URL (differing suffix) as the catalog's acme entry + {"id": "acme2", "source_url": "https://github.com/acme/stack", "archetype": {"label": "Dup url"}}, + # genuinely new → appended + {"id": "fresh", "source_url": "https://github.com/x/y", "archetype": {"label": "Fresh"}}, + ] + } + + monkeypatch.setattr("graph.plugins.installer._read_lock", fake_lock) + ids = [a["id"] for a in client.get("/api/archetypes").json()["archetypes"]] + assert ids.count("acme") == 1 and "acme2" not in ids # both duplicates dropped + assert "fresh" in ids + assert ids[-1] == "custom" # custom stays last even after bundle archetypes append + + def test_create_list_start_stop_remove(client): # create (no bundle = Basic) + auto-start r = client.post("/api/fleet", json={"name": "alpha", "port": 7890}) @@ -67,6 +112,30 @@ def test_create_list_start_stop_remove(client): assert not [a for a in client.get("/api/fleet").json()["agents"] if not a.get("host")] +def test_create_writes_archetype_soul(client): + # The picked archetype's persona is written into the workspace SOUL.md (ADR 0042), + # so a created agent arrives WITH its persona, not just its tools. + from pathlib import Path + + from graph.workspaces import manager + + r = client.post("/api/fleet", json={"name": "persona", "start": False, "soul": "# Persona\nBe bold."}) + assert r.status_code == 200 + ws = next(w for w in manager.list_workspaces() if w["name"] == "persona") + assert (Path(ws["path"]) / "config" / "SOUL.md").read_text().startswith("# Persona") + + +def test_create_without_soul_leaves_default(client): + # No/blank soul → no SOUL.md written, so the agent stays on the default persona. + from pathlib import Path + + from graph.workspaces import manager + + client.post("/api/fleet", json={"name": "plain", "start": False}) + ws = next(w for w in manager.list_workspaces() if w["name"] == "plain") + assert not (Path(ws["path"]) / "config" / "SOUL.md").exists() + + def test_create_bad_name_is_400(client): assert client.post("/api/fleet", json={"name": "bad name"}).status_code == 400 From ee70a715922e53b436d7951d00e120a23395daa4 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:28:42 -0700 Subject: [PATCH 25/81] fix(paths): colocation warning keys on instance_root, not the shared box root (#1552) (#1567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot "another instance can clobber your chat/knowledge/stores" warning (infra/paths.colocation_warning) fired whenever ANY other protoAgent process was live on the machine — because heartbeats live at the box tier and the check keyed on the shared box_root. So a `dev` instance (~/.protoagent/dev, wholly separate data) tripped a data-loss warning against the desktop/default instance, and the suggested remedy ("give each its own PROTOAGENT_INSTANCE id") was a no-op since they already had distinct ids. Now each heartbeat records its instance_root, and colocation_warning warns only when another live process shares THIS instance's instance_root (a genuine clobber — e.g. the same instance run twice). Box-only co-residents with distinct instance ids are detected but not flagged. A co-resident whose heartbeat predates the field is treated as distinct (favours no false alarm). Tests: same-instance_root → warns; box-only (distinct instance_root) → silent. Closes #1552. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++++++ infra/paths.py | 46 ++++++++++++++++++++++++++++-------- tests/test_instance_scope.py | 37 +++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2151bb39..3d42c9d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `portfolio-manager-stack`); the stale URL no longer installed what the card promised. Those stacks self-register as archetypes when installed, and the URL is now a data edit rather than a code constant. +- **Instance collision warning no longer false-alarms on a shared box root** (#1552). The boot + "another instance can clobber your chat/knowledge/stores" warning keyed on the shared + `box_root`, so it fired for any second process on the machine — including a `dev` instance + (`~/.protoagent/dev`) that keeps entirely separate data. It now keys on **`instance_root`**: + it warns only when another live process shares *this* instance's data root (a genuine + clobber, e.g. the same instance run twice), and stays silent for box-only co-residents with + distinct `PROTOAGENT_INSTANCE` ids. ## [0.78.0] - 2026-07-01 diff --git a/infra/paths.py b/infra/paths.py index f7d27c43..4015dd7b 100644 --- a/infra/paths.py +++ b/infra/paths.py @@ -321,7 +321,18 @@ def register_instance(port: int | None = None, identity: str = "") -> None: try: d = _instances_dir() d.mkdir(parents=True, exist_ok=True) - (d / f"{os.getpid()}.json").write_text(json.dumps({"pid": os.getpid(), "port": port, "identity": identity})) + # Record the instance_root so colocation_warning can distinguish a genuine + # same-instance clobber from a harmless box-only co-resident (#1552). + (d / f"{os.getpid()}.json").write_text( + json.dumps( + { + "pid": os.getpid(), + "port": port, + "identity": identity, + "instance_root": str(instance_paths().instance_root), + } + ) + ) except Exception: # noqa: BLE001 pass @@ -357,26 +368,41 @@ def colocated_instances() -> list[dict]: rec = json.loads(f.read_text()) except (OSError, ValueError): rec = {} - out.append({"pid": pid, "port": rec.get("port"), "identity": rec.get("identity") or ""}) + out.append( + { + "pid": pid, + "port": rec.get("port"), + "identity": rec.get("identity") or "", + "instance_root": rec.get("instance_root") or "", + } + ) except Exception: # noqa: BLE001 return out return out def colocation_warning() -> str | None: - """A user-facing warning when another live instance shares this data root, else None.""" - others = colocated_instances() - if not others: + """A user-facing warning when another live process shares THIS instance's data root + — i.e. the same ``instance_root`` (every store lives there, so they WOULD clobber each + other's chat / knowledge / checkpoints). Returns None otherwise. + + Two instances that merely share the ``box_root`` (distinct ``PROTOAGENT_INSTANCE`` ids — + e.g. the desktop app on ``~/.protoagent`` plus a ``dev`` instance on ``~/.protoagent/dev``) + keep entirely separate stores and are NOT a clobber risk, so they are not flagged (#1552). + A co-resident whose heartbeat predates this field (no ``instance_root`` recorded) can't be + confirmed same-instance, so it's treated as distinct — favouring no false alarm.""" + mine = str(instance_paths().instance_root) + same = [o for o in colocated_instances() if o.get("instance_root") == mine] + if not same: return None who = ", ".join( f"{o['identity'] or 'unknown'} (pid {o['pid']}" + (f", port {o['port']})" if o.get("port") else ")") - for o in others + for o in same ) - root = instance_paths().box_root return ( - f"Another running instance shares this machine's data root ({root}): {who}. " - "They can clobber each other's chat history, knowledge and stores — give each " - "instance its own PROTOAGENT_INSTANCE id (or stop the extra one)." + f"Another running process shares THIS instance's data root ({mine}): {who}. " + "They will clobber each other's chat history, knowledge and stores — stop one, or run " + "them as separate instances (a distinct PROTOAGENT_INSTANCE id, each on its own port)." ) diff --git a/tests/test_instance_scope.py b/tests/test_instance_scope.py index 62eecf4d..2739b24d 100644 --- a/tests/test_instance_scope.py +++ b/tests/test_instance_scope.py @@ -105,17 +105,46 @@ def test_heartbeats_live_at_box_root(monkeypatch, tmp_path): paths.unregister_instance() -def test_colocated_sibling_detected_and_warned(monkeypatch, tmp_path): +def test_colocated_sibling_same_instance_is_warned(monkeypatch, tmp_path): + """A sibling on the SAME instance_root (here: the unscoped default = box_root) shares + every store, so it IS a clobber warning.""" + import json + home = _home(monkeypatch, tmp_path) + mine = str(paths.instance_paths().instance_root) # this process's instance_root d = home / ".instances" d.mkdir() - (d / "12345.json").write_text('{"pid": 12345, "port": 7871, "identity": "roxy"}') + (d / "12345.json").write_text( + json.dumps({"pid": 12345, "port": 7871, "identity": "roxy", "instance_root": mine}) + ) monkeypatch.setattr(paths, "_pid_alive", lambda pid: True) monkeypatch.setattr(paths, "_is_protoagent_pid", lambda pid: True) sibs = paths.colocated_instances() - assert sibs == [{"pid": 12345, "port": 7871, "identity": "roxy"}] + assert sibs == [{"pid": 12345, "port": 7871, "identity": "roxy", "instance_root": mine}] w = paths.colocation_warning() - assert "roxy" in w and "PROTOAGENT_INSTANCE" in w and str(home) in w + assert w and "roxy" in w and mine in w + + +def test_box_only_coresident_is_not_warned(monkeypatch, tmp_path): + """#1552: a sibling that shares only the box_root — a DIFFERENT instance_root (e.g. a + ``dev`` instance under ``box_root/dev``) — keeps entirely separate stores, so it's + detected on the box but is NOT a clobber warning (the old false positive).""" + import json + + home = _home(monkeypatch, tmp_path) + other = str(home / "a-different-instance") # a distinct instance_root under the same box + assert other != str(paths.instance_paths().instance_root) + d = home / ".instances" + d.mkdir() + (d / "12345.json").write_text( + json.dumps({"pid": 12345, "port": 7871, "identity": "dev", "instance_root": other}) + ) + monkeypatch.setattr(paths, "_pid_alive", lambda pid: True) + monkeypatch.setattr(paths, "_is_protoagent_pid", lambda pid: True) + # Still discoverable on the box… + assert paths.colocated_instances()[0]["identity"] == "dev" + # …but a DISTINCT instance_root is not a data-loss warning. + assert paths.colocation_warning() is None def test_stale_heartbeats_pruned(monkeypatch, tmp_path): From 8985374786f2105760afab59ade235e904086ec7 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:32:35 -0700 Subject: [PATCH 26/81] fix(setup-wizard): surface bundle-install result via toast + fall back persona soul (ADR 0042) (#1568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audit follow-ups on the archetype flow: - The wizard's post-finish bundle-install outcome ("tools ready" / "couldn't auto-enable — turn them on in Settings ▸ Plugins" / install failed) was set as an in-wizard Callout, but onFinished() flips setup_complete and unmounts the wizard immediately — so the message, and the actionable enable/failure guidance, was never actually read. Surface it as a toast, which lives in the top-right stack and survives the unmount. The in-progress "Setting up…" hint stays inline (the wizard is still open during the install await). - Picking a bundle archetype whose manifest declares no inline `soul:` blanked the persona editor (soul === ""). Fall back to the base SOUL (the "basic" archetype's persona) via a new pure `personaSoul()` helper, used by both pickArchetype and the initial seed. Unit-tested. Verified: web unit suite 245 passed (incl. 4 new persona tests); tsc clean for the touched files (the 2 pre-existing DS-skew errors in App/ChatSurface are unrelated and clear under CI's npm ci). Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 +++++ apps/web/src/setup/SetupWizard.tsx | 42 +++++++++++++++++++++--------- apps/web/src/setup/persona.test.ts | 35 +++++++++++++++++++++++++ apps/web/src/setup/persona.ts | 10 +++++++ 4 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/setup/persona.test.ts create mode 100644 apps/web/src/setup/persona.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d42c9d6..4b51eefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it warns only when another live process shares *this* instance's data root (a genuine clobber, e.g. the same instance run twice), and stays silent for box-only co-residents with distinct `PROTOAGENT_INSTANCE` ids. +- **Setup wizard: the archetype bundle-install result is no longer swallowed.** Finishing setup + unmounts the wizard, so the "tools are ready" / "couldn't auto-enable — turn them on in + Settings ▸ Plugins" outcome now shows as a **toast** (survives the unmount) instead of an + in-wizard message the user never saw. +- **Setup wizard: picking a persona-less archetype no longer blanks the editor.** A bundle + archetype whose manifest declares no inline `soul:` now seeds the persona step with the base + SOUL as a fallback, rather than clearing the SOUL textarea. ## [0.78.0] - 2026-07-01 diff --git a/apps/web/src/setup/SetupWizard.tsx b/apps/web/src/setup/SetupWizard.tsx index eb690e4f..98ee8c93 100644 --- a/apps/web/src/setup/SetupWizard.tsx +++ b/apps/web/src/setup/SetupWizard.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { DropdownSelect, Field, FormField, Input, RadioCard, RadioCardGroup, SecretInput, Textarea } from "@protolabsai/ui/forms"; import { Button, Callout } from "@protolabsai/ui/primitives"; import { Alert, Spinner } from "@protolabsai/ui/data"; +import { useToast } from "@protolabsai/ui/overlays"; import { Bot, Check, @@ -23,6 +24,7 @@ import { errMsg } from "../lib/format"; import { lucideIcon } from "../lib/lucideIcon"; import { acpAgentsQuery, archetypesQuery } from "../lib/queries"; import type { AgentConfig, Archetype, ConfigPayload } from "../lib/types"; +import { personaSoul } from "./persona"; // Four steps: intro, then "who the agent is" (name + persona), then "how it thinks" // (the model/coding-agent runtime), then a summary. Identity + persona are one step. @@ -168,6 +170,9 @@ export function SetupWizard({ // Result of the last "Test connection" probe (a real completion). null = not // yet tested; invalidated whenever the key/base/model changes. const [tested, setTested] = useState(null); + // The bundle-install outcome is surfaced as a toast (not an in-wizard Callout): finishing + // setup unmounts the wizard, so a Callout would vanish before it's read. + const toast = useToast(); const index = steps.indexOf(step); @@ -271,9 +276,11 @@ export function SetupWizard({ // Picking an archetype card seeds the editor with that archetype's base SOUL // — the same archetypes the fleet new-agent picker offers. The textarea stays - // freely editable below; this is an explicit "load this persona" action. + // freely editable below; this is an explicit "load this persona" action. A bundle + // archetype may ship no inline persona, so fall back to the base SOUL rather than + // blanking the editor (see personaSoul). function pickArchetype(a: Archetype) { - update({ archetype: a.id, soul: a.soul }); + update({ archetype: a.id, soul: personaSoul(a, archetypeList) }); } // Pre-fill the editor once with the default archetype's base SOUL so the persona @@ -287,7 +294,7 @@ export function SetupWizard({ if (!loaded || seededSoul.current || !archetypeList.length || state.soul.trim()) return; const a = archetypeList.find((x) => x.id === state.archetype) ?? archetypeList[0]; seededSoul.current = true; - update({ archetype: a.id, soul: a.soul }); + update({ archetype: a.id, soul: personaSoul(a, archetypeList) }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loaded, archetypeList, state.soul, state.archetype]); @@ -382,22 +389,33 @@ export function SetupWizard({ // installPlugin auto-enables + hot-reloads the bundle's plugins (no restart). // A failure is non-fatal: setup is already written, so we finish anyway and // point the user at Settings ▸ Plugins. + // Surface the outcome as a TOAST, not an in-wizard Callout: onFinished() below + // unmounts the wizard (setup_complete flips true), so a Callout would vanish before + // it's read — especially the enable/failure guidance the user needs to act on. The + // in-progress "Setting up…" message stays inline (the wizard is still open during the + // install await). if (pickedBundle) { setMessage(`Setting up the ${personaLabel} tools — this can take a few seconds…`); try { const r = await api.installPlugin(pickedBundle); - setMessage( - r.enable_error - ? `Setup complete. The ${personaLabel} tools installed but couldn't auto-enable (${r.enable_error}) — turn them on in Settings ▸ Plugins.` - : `Setup complete — ${personaLabel} tools are ready.`, - ); + if (r.enable_error) { + toast({ + tone: "info", + title: "Setup complete", + message: `${personaLabel} tools installed but couldn't auto-enable (${r.enable_error}) — turn them on in Settings ▸ Plugins.`, + }); + } else { + toast({ tone: "success", title: "Setup complete", message: `${personaLabel} tools are ready.` }); + } } catch (exc) { - setMessage( - `Setup complete, but installing the ${personaLabel} tools failed (${errMsg(exc)}). You can add them later in Settings ▸ Plugins.`, - ); + toast({ + tone: "error", + title: "Setup complete", + message: `Installing the ${personaLabel} tools failed (${errMsg(exc)}). Add them later in Settings ▸ Plugins.`, + }); } } else { - setMessage(response.message); + toast({ tone: "success", title: "Setup complete", message: response.message || "Your agent is ready." }); } onFinished(); } catch (exc) { diff --git a/apps/web/src/setup/persona.test.ts b/apps/web/src/setup/persona.test.ts new file mode 100644 index 00000000..b7839864 --- /dev/null +++ b/apps/web/src/setup/persona.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; + +import { personaSoul } from "./persona"; +import type { Archetype } from "../lib/types"; + +const arch = (id: string, soul: string): Archetype => ({ + id, + label: id, + icon: "Package", + blurb: "", + bundle: null, + soul, +}); + +const LIST: Archetype[] = [arch("basic", "# Base persona"), arch("custom", "# Fill me in")]; + +describe("personaSoul", () => { + it("returns the archetype's own soul when it has one", () => { + expect(personaSoul(arch("basic", "# Base persona"), LIST)).toBe("# Base persona"); + }); + + it("falls back to the basic archetype's soul for a bundle archetype with no inline persona", () => { + // A bundle archetype whose manifest omits `soul:` — must not blank the editor. + const bundle = { ...arch("product-stack", ""), bundle: "https://example/x" }; + expect(personaSoul(bundle, LIST)).toBe("# Base persona"); + }); + + it("treats a whitespace-only soul as empty and falls back", () => { + expect(personaSoul(arch("x", " \n "), LIST)).toBe("# Base persona"); + }); + + it("returns empty string when there is no soul and no basic archetype to borrow from", () => { + expect(personaSoul(arch("x", ""), [arch("custom", "# c")])).toBe(""); + }); +}); diff --git a/apps/web/src/setup/persona.ts b/apps/web/src/setup/persona.ts new file mode 100644 index 00000000..ba0cada3 --- /dev/null +++ b/apps/web/src/setup/persona.ts @@ -0,0 +1,10 @@ +import type { Archetype } from "../lib/types"; + +// The base SOUL an archetype seeds into the persona editor (ADR 0042). A bundle archetype +// may declare no inline `soul:` in its manifest (soul === ""), so picking it must not blank +// the editor — fall back to the base persona (the "basic" archetype's SOUL), leaving a +// sensible, editable starting point rather than an empty textarea. +export function personaSoul(a: Archetype, archetypes: Archetype[]): string { + if (a.soul?.trim()) return a.soul; + return archetypes.find((x) => x.id === "basic")?.soul ?? ""; +} From c4798547deb4fb4d399e5a2307b00183c603adb1 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:56:28 -0700 Subject: [PATCH 27/81] =?UTF-8?q?feat(plugins):=20rail=20context=20menu=20?= =?UTF-8?q?=E2=80=94=20version,=20update-if-available,=20uninstall=20(#152?= =?UTF-8?q?1,=20#1522)=20(#1569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plugins settings panel already had version/update/uninstall; this adds the RAIL context menu path and DRYs the shared logic. Right-click a plugin rail icon → a disabled "Version vX.Y.Z" header, an "Update available" action when the freshness poll reports it's behind, and a destructive "Uninstall…" (confirm: "Uninstall ? This cannot be undone."). Update + Uninstall are gated so built-in / bundled-in-tree plugins never offer them (server independently refuses too). Shared usePluginManage() hook (update+uninstall mutations, DS toast, query refresh) consumed by both the panel and the new PluginRailManage root host; rail actions route through ephemeral uiStore triggers so the context-menu registration stays a pure function (mirrors configurePlugin). Closes #1521, closes #1522. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/app/App.tsx | 38 +++++++++++-- .../web/src/contextMenu/registrations.test.ts | 48 +++++++++++++++++ apps/web/src/contextMenu/registrations.tsx | 45 +++++++++++++++- apps/web/src/plugins/PluginRailManage.tsx | 47 ++++++++++++++++ apps/web/src/plugins/PluginsSurface.tsx | 30 +++-------- apps/web/src/plugins/usePluginManage.ts | 54 +++++++++++++++++++ apps/web/src/state/uiStore.test.ts | 21 ++++++++ apps/web/src/state/uiStore.ts | 21 +++++++- 8 files changed, 273 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/contextMenu/registrations.test.ts create mode 100644 apps/web/src/plugins/PluginRailManage.tsx create mode 100644 apps/web/src/plugins/usePluginManage.ts diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 55efc3d5..e4266575 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -79,6 +79,7 @@ import { chatStore, useAnyChatStreaming } from "../chat/chat-store"; import { KnowledgeStore } from "../knowledge/KnowledgeStore"; import { SettingsOverlay } from "../settings/SettingsOverlay"; import { PluginSettingsDialog } from "../plugins/PluginSettingsDialog"; +import { PluginRailManage } from "../plugins/PluginRailManage"; import { AppDrawer } from "./AppDrawer"; import { HamburgerMenu } from "./HamburgerMenu"; import { FleetSwitcher } from "./FleetSwitcher"; @@ -107,7 +108,7 @@ import { useToast } from "@protolabsai/ui/overlays"; import { StatusPill } from "./StatusPill"; import { WorkPanel } from "./WorkPanel"; import { SetupWizard } from "../setup/SetupWizard"; -import { hostRuntimeStatusQuery, runtimeStatusQuery } from "../lib/queries"; +import { hostRuntimeStatusQuery, installedPluginsQuery, pluginUpdatesQuery, runtimeStatusQuery } from "../lib/queries"; import { buildViews } from "../lib/viewRegistry"; import { applyNavIntent, usePaletteRegistry } from "./usePaletteRegistry"; import type { NavIntent } from "./usePaletteRegistry"; @@ -277,6 +278,14 @@ export function App() { }); const runtime = runtimeQ.data ?? null; + // Installed inventory + freshness — feed the rail context-menu plugin actions + // (#1521 / #1522) so a plugin icon's menu can show its version and offer Update / + // Uninstall. Lightweight + cached (the freshness poll is TTL-cached server-side); + // both degrade gracefully (retry:false), so a missing/erroring API just hides the + // extra menu items rather than blocking the menu. + const installedPluginsQ = useQuery(installedPluginsQuery()); + const pluginUpdatesQ = useQuery(pluginUpdatesQuery()); + // Tenant uid is the HUB's, never the focused agent's (which changes on every fleet // swap and would wrongly wipe the chat view). Host-pinned, stable, low-churn. const hostUidQ = useQuery({ @@ -884,10 +893,26 @@ export function App() { // A plugin view's rail id is `plugin::` — resolve the owning // plugin's id + display name so the menu can offer "Configure…" (ADR 0036/0059). const pluginId = id.startsWith("plugin:") ? id.split(":")[1] : undefined; - const pluginName = pluginId - ? (runtime?.plugins?.find((p) => p.id === pluginId)?.name ?? pluginId) - : undefined; - openContextMenu("rail-surface", e, { id, side, pluginId, pluginName }); + const rec = pluginId ? runtime?.plugins?.find((p) => p.id === pluginId) : undefined; + const pluginName = pluginId ? (rec?.name ?? pluginId) : undefined; + // Version + lifecycle affordances (#1521 / #1522): removable = tracked in the + // writable plugins dir (git-installed / local copy; in-tree built-ins aren't in + // this list and are refused server-side); updatable = the freshness poll says + // this plugin is behind its ref. Both feed the Update / Uninstall menu gating. + const removable = pluginId ? (installedPluginsQ.data?.plugins ?? []).some((pl) => pl.id === pluginId) : false; + const updatable = pluginId + ? Boolean((pluginUpdatesQ.data?.plugins ?? []).find((u) => u.id === pluginId)?.behind) + : false; + openContextMenu("rail-surface", e, { + id, + side, + pluginId, + pluginName, + pluginVersion: rec?.version, + pluginBuiltin: rec?.builtin, + pluginRemovable: removable, + pluginUpdatable: updatable, + }); }} onRailReorder={(next) => { // Chat can dock anywhere now — left, right, or the bottom dock. Its slot mounts @@ -1147,6 +1172,9 @@ export function App() { onClose={closePluginConfig} /> )} + {/* Rail context-menu plugin actions (#1521 / #1522) — fires an Update, or renders the + Uninstall confirm, for a plugin right-clicked on its rail icon. One root mount. */} + ); } diff --git a/apps/web/src/contextMenu/registrations.test.ts b/apps/web/src/contextMenu/registrations.test.ts new file mode 100644 index 00000000..977027d3 --- /dev/null +++ b/apps/web/src/contextMenu/registrations.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import "./registrations"; // side-effect: registers the core menus (rail-surface, …) +import { resolveMenu } from "./registry"; +import { useUI } from "../state/uiStore"; + +type Item = { id: string; label?: unknown; danger?: boolean; disabled?: boolean }; +const ids = (entries: unknown[]) => (entries as Item[]).map((e) => e.id); + +// The rail-surface menu's plugin lifecycle affordances (#1521 / #1522): the App-side +// trigger resolves a plugin's version / removable / updatable into `ctx`, and the menu +// turns those into a version header, an "Update available" action, and a destructive +// "Uninstall…" — each gated so an in-tree built-in never offers update/uninstall. +describe("rail-surface plugin lifecycle menu (#1521 / #1522)", () => { + const baseCtx = { id: "plugin:board:board", side: "left" as const, pluginId: "board", pluginName: "Board" }; + + beforeEach(() => { + // The menu early-returns [] unless the surface id is a tracked member of its dock. + useUI.setState({ railOrder: { left: ["chat", "plugin:board:board"], right: [], bottom: [], hidden: [] } }); + }); + + it("shows version, Update, and Uninstall for a removable, behind plugin", () => { + const entries = resolveMenu("rail-surface", { ...baseCtx, pluginVersion: "1.2.3", pluginRemovable: true, pluginUpdatable: true }); + const items = entries as Item[]; + expect(ids(items)).toEqual(expect.arrayContaining(["plugin-version", "update", "uninstall"])); + expect(items.find((i) => i.id === "plugin-version")?.label).toContain("1.2.3"); + expect(items.find((i) => i.id === "uninstall")?.danger).toBe(true); + }); + + it("hides Update when up to date and Uninstall when not removable", () => { + const got = ids(resolveMenu("rail-surface", { ...baseCtx, pluginVersion: "1.2.3", pluginRemovable: false, pluginUpdatable: false })); + expect(got).toContain("plugin-version"); + expect(got).not.toContain("update"); + expect(got).not.toContain("uninstall"); + }); + + it("never offers Update/Uninstall for an in-tree built-in", () => { + const got = ids(resolveMenu("rail-surface", { ...baseCtx, pluginBuiltin: true, pluginRemovable: true, pluginUpdatable: true })); + expect(got).not.toContain("update"); + expect(got).not.toContain("uninstall"); + }); + + it("omits the version header when the version is unknown", () => { + const got = ids(resolveMenu("rail-surface", { ...baseCtx, pluginRemovable: true, pluginUpdatable: false })); + expect(got).not.toContain("plugin-version"); + expect(got).toContain("uninstall"); + }); +}); diff --git a/apps/web/src/contextMenu/registrations.tsx b/apps/web/src/contextMenu/registrations.tsx index c0006de9..babba232 100644 --- a/apps/web/src/contextMenu/registrations.tsx +++ b/apps/web/src/contextMenu/registrations.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftRight, ChevronDown, ChevronUp, Eye, EyeOff, Pencil, Plus, Puzzle, SlidersHorizontal, X } from "lucide-react"; +import { ArrowLeftRight, ChevronDown, ChevronUp, Eye, EyeOff, Pencil, Plus, Puzzle, RefreshCw, SlidersHorizontal, Trash2, X } from "lucide-react"; import { openView } from "../app/usePaletteRegistry"; import { useUI } from "../state/uiStore"; @@ -10,6 +10,12 @@ import type { MenuEntry } from "./types"; // rails (without disabling the plugin). Chat is movable across all three docks like any other // surface; plugin views carry their owning plugin's id/name in `ctx` (resolved by the App-side // trigger) so Configure can open that plugin's settings dialog. +// +// Plugin lifecycle (#1521 / #1522): the App-side trigger also resolves the plugin's installed +// version, whether the freshness poll says it's behind (`pluginUpdatable`), and whether it lives +// in the writable plugins dir (`pluginRemovable`). So the menu shows the version, an "Update +// available" action when behind, and a destructive "Uninstall…" — both gated so an in-tree +// built-in (which the server refuses to update/uninstall) never offers them. registerContextMenu({ type: "rail-surface", items: (ctx: { @@ -17,6 +23,10 @@ registerContextMenu({ side: "left" | "right" | "bottom"; pluginId?: string; pluginName?: string; + pluginVersion?: string; + pluginBuiltin?: boolean; + pluginRemovable?: boolean; + pluginUpdatable?: boolean; }): MenuEntry[] => { if (!ctx) return []; const ui = useUI.getState(); @@ -41,16 +51,33 @@ registerContextMenu({ // is always present; Configure (plugin views only) opens the owning plugin's settings dialog; // Hide moves the surface to railOrder.hidden (restore from ⌘K or "Move to …"). Chat is never // hidden — it mounts unconditionally on its dock, so a hidden chat would render with no rail icon. + // Built-in / uninstall / update, all gated on the plugin's origin, cluster under Configure. const manage: MenuEntry[] = []; if (ctx.pluginId) { const pid = ctx.pluginId; const pname = ctx.pluginName ?? ctx.pluginId; + // Installed version — informational (a disabled, non-clickable header), so the menu + // answers "which version am I on?" without opening the manager. + if (ctx.pluginVersion) { + manage.push({ id: "plugin-version", label: `Version v${ctx.pluginVersion}`, disabled: true, run: () => {} }); + } manage.push({ id: "configure", label: "Configure…", icon: , run: () => useUI.getState().openPluginConfig(pid, pname), }); + // Update — only when the freshness poll says this plugin is behind its ref AND it's + // not an in-tree built-in (the server refuses to update those). Fires via the store; + // a root PluginRailManage runs the mutation + toast (up-to-date/pinned → no item). + if (ctx.pluginUpdatable && !ctx.pluginBuiltin) { + manage.push({ + id: "update", + label: "Update available", + icon: , + run: () => useUI.getState().requestPluginUpdate(pid, pname), + }); + } } // A rail-wide escape hatch on every icon: the all-plugins counterpart to the per-plugin // "Configure…" above — opens Settings ▸ Integrations. @@ -68,6 +95,22 @@ registerContextMenu({ run: () => useUI.getState().hideSurface(ctx.id), }); } + // Uninstall — a destructive action set off by its own divider, offered only for a + // writable-dir plugin (git-installed / local copy) that isn't an in-tree built-in + // (the server refuses those, so they only get Disable in the manager). The store + // trigger opens a "This cannot be undone." confirm rendered by PluginRailManage. + if (ctx.pluginId && ctx.pluginRemovable && !ctx.pluginBuiltin) { + const pid = ctx.pluginId; + const pname = ctx.pluginName ?? ctx.pluginId; + manage.push({ id: "uninstall-div", divider: true }); + manage.push({ + id: "uninstall", + label: "Uninstall…", + icon: , + danger: true, + run: () => useUI.getState().requestPluginUninstall(pid, pname), + }); + } // Any surface — core, plugin, or chat — reorders within its dock and moves across, including // chat to the bottom dock (its slot mounts unconditionally there too). Moving chat across docks // remounts it: a brief blip on an in-flight stream; a deliberate action. diff --git a/apps/web/src/plugins/PluginRailManage.tsx b/apps/web/src/plugins/PluginRailManage.tsx new file mode 100644 index 00000000..33589a6a --- /dev/null +++ b/apps/web/src/plugins/PluginRailManage.tsx @@ -0,0 +1,47 @@ +import { ConfirmDialog } from "@protolabsai/ui/overlays"; +import { useEffect } from "react"; + +import { useUI } from "../state/uiStore"; +import { usePluginManage } from "./usePluginManage"; + +// Root-mounted host for the rail context-menu plugin actions (#1521 / #1522, ADR 0036). +// A right-click "Update available" / "Uninstall…" on a plugin's rail icon records the +// pending action in the UI store; this component fires the update mutation (no confirm — +// an update is non-destructive and reversible by a re-install) or renders the uninstall +// confirm. Mounted once in App so the actions work regardless of whether the Plugins +// settings panel is open. Success/failure surface via the shared toast, and the rail + +// installed list refresh via the mutation's query invalidation. +export function PluginRailManage() { + const pluginUpdate = useUI((s) => s.pluginUpdate); + const clearPluginUpdate = useUI((s) => s.clearPluginUpdate); + const pluginUninstall = useUI((s) => s.pluginUninstall); + const clearPluginUninstall = useUI((s) => s.clearPluginUninstall); + const { update, remove } = usePluginManage(); + + // Fire the requested update, consuming the trigger first so it runs exactly once + // (the next render sees `pluginUpdate` cleared and early-returns). The toast reports + // the outcome; no modal — an update doesn't need a confirm. + useEffect(() => { + if (!pluginUpdate) return; + const target = pluginUpdate; + clearPluginUpdate(); + update.mutate(target); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pluginUpdate]); + + return ( + { + if (pluginUninstall) remove.mutate(pluginUninstall); + clearPluginUninstall(); + }} + onClose={clearPluginUninstall} + > + {pluginUninstall ? `Uninstall ${pluginUninstall.name}? This cannot be undone.` : undefined} + + ); +} diff --git a/apps/web/src/plugins/PluginsSurface.tsx b/apps/web/src/plugins/PluginsSurface.tsx index 4fc595d3..5a37eb31 100644 --- a/apps/web/src/plugins/PluginsSurface.tsx +++ b/apps/web/src/plugins/PluginsSurface.tsx @@ -17,6 +17,7 @@ import { StatusPill } from "../app/StatusPill"; import { InstallPluginDialog } from "./InstallPluginDialog"; import { PluginSettingsDialog } from "./PluginSettingsDialog"; import { PluginFreshness } from "./PluginFreshness"; +import { usePluginManage } from "./usePluginManage"; import { catalogCategories, filterCatalog } from "./catalog"; import { api } from "../lib/api"; import type { CatalogPlugin, PluginUpdate, RuntimeStatus } from "../lib/types"; @@ -166,6 +167,9 @@ function LocalTab() { const [installOpen, setInstallOpen] = useState(false); const [uninstallPending, setUninstallPending] = useState(null); const [restartPending, setRestartPending] = useState(false); + // Update + uninstall mutations (toast + query-refresh) shared with the rail context + // menu (#1521 / #1522), so both entry points behave identically. + const { update, remove } = usePluginManage(); const refreshAll = () => { qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); @@ -197,33 +201,13 @@ function LocalTab() { const onToggle = (p: Plugin) => toggle.mutate(p); const pendingId = toggle.isPending ? toggle.variables?.id : undefined; - const update = useMutation({ - mutationFn: (p: Plugin) => api.updatePlugin(p.id), - onSuccess: (res, p) => { - qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); - qc.invalidateQueries({ queryKey: queryKeys.pluginUpdates }); - // A new version may declare new/changed config fields — refetch the schema (#1423). - qc.invalidateQueries({ queryKey: queryKeys.settings }); - toast( - res.restart_recommended - ? { tone: "info", title: "Plugin updated", message: `${p.name}${res.version ? ` to v${res.version}` : ""} — restart to fully load its console view or background surface.` } - : { tone: "success", title: "Plugin updated", message: `${p.name}${res.version ? ` to v${res.version}` : ""}${res.reloaded ? " (hot-reloaded)" : ""}.` }, - ); - }, - onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't update plugin", message: `${p.name}: ${errMsg(err)}` }), - }); - const onUpdate = (p: Plugin) => update.mutate(p); + const onUpdate = (p: Plugin) => update.mutate({ id: p.id, name: p.name }); const updatingId = update.isPending ? update.variables?.id : undefined; const updateById = new Map((updates.data?.plugins ?? []).map((u) => [u.id, u])); // Uninstall (DELETE /api/plugins/{id}) — removes the code + plugins.lock / enabled refs. // Refused server-side for in-tree built-ins, so it's only offered for plugins in the - // lock-backed inventory. - const remove = useMutation({ - mutationFn: (p: Plugin) => api.uninstallPlugin(p.id), - onSuccess: (_res, p) => { refreshAll(); toast({ tone: "success", title: "Plugin uninstalled", message: `${p.name} removed.` }); }, - onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't uninstall plugin", message: `${p.name}: ${errMsg(err)}` }), - }); + // lock-backed inventory. The confirm gates the shared `remove` mutation. const onRemove = (p: Plugin) => setUninstallPending(p); const removingId = remove.isPending ? remove.variables?.id : undefined; @@ -354,7 +338,7 @@ function LocalTab() { title="Uninstall plugin?" confirmLabel="Uninstall" destructive - onConfirm={() => { if (uninstallPending) remove.mutate(uninstallPending); setUninstallPending(null); }} + onConfirm={() => { if (uninstallPending) remove.mutate({ id: uninstallPending.id, name: uninstallPending.name }); setUninstallPending(null); }} onClose={() => setUninstallPending(null)} > {uninstallPending diff --git a/apps/web/src/plugins/usePluginManage.ts b/apps/web/src/plugins/usePluginManage.ts new file mode 100644 index 00000000..a0a9a35d --- /dev/null +++ b/apps/web/src/plugins/usePluginManage.ts @@ -0,0 +1,54 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useToast } from "@protolabsai/ui/overlays"; + +import { api } from "../lib/api"; +import { errMsg } from "../lib/format"; +import { queryKeys, runtimeStatusQuery } from "../lib/queries"; + +// A plugin the actions target — just its id (for the API) + name (for the toast). +export type PluginRef = { id: string; name: string }; + +// Shared update + uninstall mutations for a single plugin (#1521 / #1522, ADR 0027). +// Used by BOTH the Plugins manager rows (PluginsSurface) and the rail context-menu +// actions (PluginRailManage), so the toast copy + query-refresh are identical wherever +// a plugin is updated/removed. On success we refresh: runtime (the rail icons + the +// loaded set), the installed inventory (removable list), the freshness poll, and the +// settings schema (a new/removed plugin changes which config fields exist, #1423). +export function usePluginManage() { + const qc = useQueryClient(); + const toast = useToast(); + + const refreshAll = () => { + qc.invalidateQueries({ queryKey: runtimeStatusQuery().queryKey }); + qc.invalidateQueries({ queryKey: queryKeys.installedPlugins }); + qc.invalidateQueries({ queryKey: queryKeys.pluginUpdates }); + qc.invalidateQueries({ queryKey: queryKeys.settings }); + }; + + // Pull the latest code at the plugin's recorded ref + hot-reload (same path as enable). + const update = useMutation({ + mutationFn: (p: PluginRef) => api.updatePlugin(p.id), + onSuccess: (res, p) => { + refreshAll(); + toast( + res.restart_recommended + ? { tone: "info", title: "Plugin updated", message: `${p.name}${res.version ? ` to v${res.version}` : ""} — restart to fully load its console view or background surface.` } + : { tone: "success", title: "Plugin updated", message: `${p.name}${res.version ? ` to v${res.version}` : ""}${res.reloaded ? " (hot-reloaded)" : ""}.` }, + ); + }, + onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't update plugin", message: `${p.name}: ${errMsg(err)}` }), + }); + + // Uninstall (DELETE) — removes the code + plugins.lock / enabled refs. Refused + // server-side for in-tree built-ins, so callers only offer it for writable-dir plugins. + const remove = useMutation({ + mutationFn: (p: PluginRef) => api.uninstallPlugin(p.id), + onSuccess: (_res, p) => { + refreshAll(); + toast({ tone: "success", title: "Plugin uninstalled", message: `${p.name} removed.` }); + }, + onError: (err: unknown, p) => toast({ tone: "error", title: "Couldn't uninstall plugin", message: `${p.name}: ${errMsg(err)}` }), + }); + + return { update, remove }; +} diff --git a/apps/web/src/state/uiStore.test.ts b/apps/web/src/state/uiStore.test.ts index de9a4ebd..5366a5a1 100644 --- a/apps/web/src/state/uiStore.test.ts +++ b/apps/web/src/state/uiStore.test.ts @@ -306,6 +306,27 @@ describe("migrateUiState — v14 domain-first settings IA", () => { }); }); +// Rail context-menu plugin actions (#1521 / #1522): request/clear the pending Update / +// Uninstall a right-clicked plugin icon triggers; PluginRailManage reads + consumes them. +describe("plugin rail actions", () => { + beforeEach(() => useUI.setState({ pluginUpdate: undefined, pluginUninstall: undefined })); + + it("records and clears a pending update", () => { + useUI.getState().requestPluginUpdate("board", "Board"); + expect(useUI.getState().pluginUpdate).toEqual({ id: "board", name: "Board" }); + useUI.getState().clearPluginUpdate(); + expect(useUI.getState().pluginUpdate).toBeUndefined(); + }); + + it("records and clears a pending uninstall independently of update", () => { + useUI.getState().requestPluginUninstall("doom", "Doom"); + expect(useUI.getState().pluginUninstall).toEqual({ id: "doom", name: "Doom" }); + expect(useUI.getState().pluginUpdate).toBeUndefined(); + useUI.getState().clearPluginUninstall(); + expect(useUI.getState().pluginUninstall).toBeUndefined(); + }); +}); + // The v13 migration adds the `hidden` bucket to a persisted railOrder that predates it, so the // shape is complete (actions also fall back to [] defensively). describe("migrateUiState — v13 hidden bucket", () => { diff --git a/apps/web/src/state/uiStore.ts b/apps/web/src/state/uiStore.ts index cfd8220d..ab041c0b 100644 --- a/apps/web/src/state/uiStore.ts +++ b/apps/web/src/state/uiStore.ts @@ -73,6 +73,16 @@ type UIState = { configurePlugin?: { id: string; name: string }; openPluginConfig: (id: string, name: string) => void; closePluginConfig: () => void; + // Rail context-menu plugin actions (#1521 / #1522, ADR 0036). A right-click "Update + // available" / "Uninstall…" on a plugin's rail icon records the target here; a root + // PluginRailManage mount fires the update mutation or renders the uninstall confirm. + // EPHEMERAL — partialized out of persistence so a refresh never re-triggers one. + pluginUpdate?: { id: string; name: string }; + requestPluginUpdate: (id: string, name: string) => void; + clearPluginUpdate: () => void; + pluginUninstall?: { id: string; name: string }; + requestPluginUninstall: (id: string, name: string) => void; + clearPluginUninstall: () => void; rightCollapsed: boolean; leftCollapsed: boolean; rightWidth: number; @@ -306,6 +316,12 @@ export const useUI = create()( configurePlugin: undefined, openPluginConfig: (id, name) => set({ configurePlugin: { id, name } }), closePluginConfig: () => set({ configurePlugin: undefined }), + pluginUpdate: undefined, + requestPluginUpdate: (id, name) => set({ pluginUpdate: { id, name } }), + clearPluginUpdate: () => set({ pluginUpdate: undefined }), + pluginUninstall: undefined, + requestPluginUninstall: (id, name) => set({ pluginUninstall: { id, name } }), + clearPluginUninstall: () => set({ pluginUninstall: undefined }), rightCollapsed: false, leftCollapsed: false, rightWidth: 360, @@ -454,8 +470,9 @@ export const useUI = create()( version: 14, // …v12 Settings→utility pill · v13 railOrder.hidden bucket · v14 drop dead settingsScope (domain-first IA, ADR 0048) migrate: (persisted: unknown) => migrateUiState(persisted) as never, // Ephemeral overlay state — dropped from persistence so a refresh never reopens it - // (the Global settings overlay + the per-plugin Configure dialog). - partialize: ({ globalSettingsOpen: _o, globalSettingsSection: _s, configurePlugin: _c, ...rest }) => rest, + // (the Global settings overlay, the per-plugin Configure dialog, and the pending + // rail-menu Update/Uninstall action). + partialize: ({ globalSettingsOpen: _o, globalSettingsSection: _s, configurePlugin: _c, pluginUpdate: _pu, pluginUninstall: _pun, ...rest }) => rest, }, ), ); From 44a4d75aa32c4104510e12a5e846b5ebd4279d4f Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:57:11 -0700 Subject: [PATCH 28/81] feat(marketing): data-driven /roadmap page from ROADMAP.md (#1532) (#1570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the changelog pipeline exactly: ROADMAP.md (## Planned / In progress / Shipped, bulleted) → scripts/roadmap.py (build → sites/marketing/data/roadmap.json; check = drift guard) → sites/marketing/src/pages/roadmap.astro (same BaseLayout + DS tokens as changelog.astro, grouped by status, issue/release-tag ref chips) + a /roadmap nav+footer link before Changelog. Seeded with real open-issue items (#1520/#1515/#1514/#1504/#1535/#1522/#1521/#1537) + v0.78.0 shipped highlights. Closes #1532. Co-authored-by: Claude Opus 4.8 (1M context) --- ROADMAP.md | 27 ++++ scripts/roadmap.py | 131 ++++++++++++++++++++ sites/marketing/data/roadmap.json | 94 ++++++++++++++ sites/marketing/src/components/Footer.astro | 1 + sites/marketing/src/components/Nav.astro | 1 + sites/marketing/src/pages/roadmap.astro | 104 ++++++++++++++++ 6 files changed, 358 insertions(+) create mode 100644 ROADMAP.md create mode 100644 scripts/roadmap.py create mode 100644 sites/marketing/data/roadmap.json create mode 100644 sites/marketing/src/pages/roadmap.astro diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..b7819994 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,27 @@ +# Roadmap + +Where protoAgent is headed, kept honest and light. This is the source of truth for the +marketing site's `/roadmap` page — `scripts/roadmap.py build` parses it into +`sites/marketing/data/roadmap.json`. Group items under `## Planned`, `## In progress`, or +`## Shipped`; each bullet is a short **title** — one-line detail with an optional `(#issue)` +or `(vX.Y.Z)` reference. + +## Planned + +- **One-command install** — a `curl | sh` bootstrap with an interactive CLI config wizard. (#1520) +- **Migrate from Hermes** — a script that imports an existing Hermes agent into protoAgent. (#1515) +- **Migrate from OpenClaw** — a script that imports an existing OpenClaw agent into protoAgent. (#1514) +- **Federation token follow-ups** — management UI, peer rotation, and fleet integration for ADR 0066 tokens. (#1504) +- **Rewind a chat thread** — jump a conversation back to an earlier message and branch from there. (#1535) + +## In progress + +- **Plugin management from the rail** — uninstall a plugin from the rail context menu and a plugin-management settings panel. (#1522) +- **Plugin version + update** — show the installed version inline with an "update if available" action. (#1521) +- **Live Work panel** — reflect goal, task, and schedule changes as they happen, without a manual refresh. (#1537) + +## Shipped + +- **/compact** — summarize and archive a long chat thread in a single command. (v0.78.0) +- **Developer flags** — gate pre-release work behind local feature flags, with a Settings ▸ Developer panel. (v0.78.0) +- **Watches** — supervise many external conditions at once as a first-class primitive. (v0.78.0) diff --git a/scripts/roadmap.py b/scripts/roadmap.py new file mode 100644 index 00000000..21594a5f --- /dev/null +++ b/scripts/roadmap.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Derive the marketing site's /roadmap data from ROADMAP.md. + +ROADMAP.md at the repo root is the human-owned source of truth: status sections +(``## Planned`` / ``## In progress`` / ``## Shipped``) each holding a bullet list of +items — a **bold title**, an em-dash one-line detail, and an optional ``(#issue)`` or +``(vX.Y.Z)`` reference. This mirrors the CHANGELOG.md → changelog.json pipeline +(see scripts/changelog.py): the markdown is the thing you edit; the JSON is derived so +the Astro page stays a dumb renderer. + + python scripts/roadmap.py build # ROADMAP.md → sites/marketing/data/roadmap.json + python scripts/roadmap.py check # fail if roadmap.json is stale (CI guard) + +Unlike changelog.json (a *curated* subset), roadmap.json is a faithful projection of +ROADMAP.md — so ``build`` fully rewrites it and ``check`` fails when it drifts. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +ROADMAP = Path(__file__).parent.parent / "ROADMAP.md" +MARKETING_JSON = Path(__file__).parent.parent / "sites" / "marketing" / "data" / "roadmap.json" + +# Issue / release references carried in a trailing ``(...)`` — e.g. ``(#1520)`` or +# ``(v0.78.0)``. The Astro page turns ``#N`` into an issue link and ``vX.Y.Z`` into a +# release-tag link. +_REF = r"#\d+|v\d+\.\d+\.\d+" + + +def _strip_md(s: str) -> str: + """Markdown → plain text (the roadmap page renders plain text); drop ADR refs.""" + s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) # **bold** → bold + s = re.sub(r"`([^`]+)`", r"\1", s) # `code` → code + s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s) # [text](url) → text + s = re.sub(r"\s*\(ADR[^)]*\)", "", s) # drop "(ADR 0026)" + return re.sub(r"\s+", " ", s).strip() + + +def _item(raw: str) -> dict[str, object]: + """Parse one folded bullet into ``{title, detail, refs}``. + + ``- **Title** — detail sentence. (#1520)`` → title/detail split on the bold lead + (falling back to the first em-dash clause), with the trailing reference parenthetical + peeled off into ``refs``. + """ + refs = re.findall(_REF, raw) + # Peel the trailing "(…#1520…)" / "(…v0.78.0…)" reference group off the detail. + body = re.sub(rf"\s*\(([^)]*(?:{_REF})[^)]*)\)\s*$", "", raw).strip() + + bold = re.match(r"\*\*(.+?)\*\*\s*", body) + if bold: + title, detail = bold.group(1), body[bold.end() :] + else: # no bold lead → split on the first em-dash / hyphen separator + parts = re.split(r"\s+[—–-]\s+", body, maxsplit=1) + title, detail = parts[0], (parts[1] if len(parts) > 1 else "") + + detail = re.sub(r"^[—–-]\s*", "", detail).strip() + return {"title": _strip_md(title), "detail": _strip_md(detail), "refs": refs} + + +def parse(text: str) -> list[dict[str, object]]: + """ROADMAP.md → ``[{status, items: [{title, detail, refs}]}]`` in document order. + + Only ``## `` (level-2) headings open a status group; a ``# `` title and any intro + prose above the first group are ignored. Empty groups are dropped. + """ + groups: list[dict[str, object]] = [] + current: dict[str, object] | None = None + chunk: list[str] | None = None # the current bullet's lines (lead + continuations) + + def flush() -> None: + nonlocal chunk + if current is not None and chunk: + text = re.sub(r"\s+", " ", " ".join(chunk)).strip() + current["items"].append(_item(text)) # type: ignore[attr-defined] + chunk = None + + for line in text.splitlines(): + heading = re.match(r"^##\s+(.*\S)\s*$", line) + if heading: + flush() + current = {"status": heading.group(1).strip(), "items": []} + groups.append(current) + continue + bullet = re.match(r"^-\s+(.*)$", line) + if bullet: + flush() + chunk = [bullet.group(1)] + elif chunk is not None: + s = line.strip() + if not s or s.startswith("- ") or line.startswith("#"): + flush() # blank / next bullet / heading ends the current bullet + else: + chunk.append(s) # an indented continuation line + flush() + return [g for g in groups if g["items"]] + + +def render(text: str) -> str: + """ROADMAP.md text → the exact JSON string written to roadmap.json.""" + return json.dumps(parse(text), indent=2, ensure_ascii=False) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Derive roadmap.json from ROADMAP.md") + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("build", help="parse ROADMAP.md → sites/marketing/data/roadmap.json") + sub.add_parser("check", help="fail if roadmap.json is out of date vs ROADMAP.md") + args = parser.parse_args() + + out = render(ROADMAP.read_text(encoding="utf-8")) + + if args.cmd == "build": + MARKETING_JSON.write_text(out, encoding="utf-8") + n = sum(len(g["items"]) for g in parse(ROADMAP.read_text(encoding="utf-8"))) + print(f"roadmap: wrote {n} items to {MARKETING_JSON.name}") + elif args.cmd == "check": + current = MARKETING_JSON.read_text(encoding="utf-8") if MARKETING_JSON.exists() else "" + if current != out: + raise SystemExit( + f"{MARKETING_JSON.name} is out of date — run `python scripts/roadmap.py build`" + ) + print("roadmap: roadmap.json is in sync with ROADMAP.md") + + +if __name__ == "__main__": + main() diff --git a/sites/marketing/data/roadmap.json b/sites/marketing/data/roadmap.json new file mode 100644 index 00000000..66c8987a --- /dev/null +++ b/sites/marketing/data/roadmap.json @@ -0,0 +1,94 @@ +[ + { + "status": "Planned", + "items": [ + { + "title": "One-command install", + "detail": "a curl | sh bootstrap with an interactive CLI config wizard.", + "refs": [ + "#1520" + ] + }, + { + "title": "Migrate from Hermes", + "detail": "a script that imports an existing Hermes agent into protoAgent.", + "refs": [ + "#1515" + ] + }, + { + "title": "Migrate from OpenClaw", + "detail": "a script that imports an existing OpenClaw agent into protoAgent.", + "refs": [ + "#1514" + ] + }, + { + "title": "Federation token follow-ups", + "detail": "management UI, peer rotation, and fleet integration for ADR 0066 tokens.", + "refs": [ + "#1504" + ] + }, + { + "title": "Rewind a chat thread", + "detail": "jump a conversation back to an earlier message and branch from there.", + "refs": [ + "#1535" + ] + } + ] + }, + { + "status": "In progress", + "items": [ + { + "title": "Plugin management from the rail", + "detail": "uninstall a plugin from the rail context menu and a plugin-management settings panel.", + "refs": [ + "#1522" + ] + }, + { + "title": "Plugin version + update", + "detail": "show the installed version inline with an \"update if available\" action.", + "refs": [ + "#1521" + ] + }, + { + "title": "Live Work panel", + "detail": "reflect goal, task, and schedule changes as they happen, without a manual refresh.", + "refs": [ + "#1537" + ] + } + ] + }, + { + "status": "Shipped", + "items": [ + { + "title": "/compact", + "detail": "summarize and archive a long chat thread in a single command.", + "refs": [ + "v0.78.0" + ] + }, + { + "title": "Developer flags", + "detail": "gate pre-release work behind local feature flags, with a Settings ▸ Developer panel.", + "refs": [ + "v0.78.0" + ] + }, + { + "title": "Watches", + "detail": "supervise many external conditions at once as a first-class primitive.", + "refs": [ + "v0.78.0" + ] + } + ] + } +] diff --git a/sites/marketing/src/components/Footer.astro b/sites/marketing/src/components/Footer.astro index def92434..08a868f3 100644 --- a/sites/marketing/src/components/Footer.astro +++ b/sites/marketing/src/components/Footer.astro @@ -12,6 +12,7 @@ const year = 2026; Features Docs Plugins + Roadmap Changelog GitHub
diff --git a/sites/marketing/src/components/Nav.astro b/sites/marketing/src/components/Nav.astro index 801e6a18..36cf3a34 100644 --- a/sites/marketing/src/components/Nav.astro +++ b/sites/marketing/src/components/Nav.astro @@ -14,6 +14,7 @@ const repo = 'https://github.com/protoLabsAI/protoAgent'; Docs + GitHub Download diff --git a/sites/marketing/src/pages/roadmap.astro b/sites/marketing/src/pages/roadmap.astro new file mode 100644 index 00000000..ef3326a8 --- /dev/null +++ b/sites/marketing/src/pages/roadmap.astro @@ -0,0 +1,104 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import roadmap from '../../data/roadmap.json'; + +const repo = 'https://github.com/protoLabsAI/protoAgent'; +const issues = `${repo}/issues`; +const releases = `${repo}/releases`; + +type Accent = { ring: string; fill: string; dot: string; text: string }; + +// Per-status accent — brand lavender for active work, green for shipped, muted for the rest. +const accents: Record = { + 'In progress': { + ring: 'color-mix(in srgb, var(--pl-color-brand-lavender) 50%, transparent)', + fill: 'color-mix(in srgb, var(--pl-color-brand-lavender) 15%, transparent)', + dot: 'var(--pl-color-brand-lavender)', + text: 'var(--pl-color-brand-lavender)', + }, + Shipped: { + ring: 'color-mix(in srgb, #4ade80 45%, transparent)', + fill: 'color-mix(in srgb, #4ade80 12%, transparent)', + dot: '#4ade80', + text: '#4ade80', + }, + Planned: { + ring: 'var(--pl-color-border)', + fill: 'var(--pl-color-bg-raised)', + dot: '#52525b', + text: 'var(--pl-color-fg-muted)', + }, +}; +const fallback = accents['Planned']; + +const refHref = (ref: string) => + ref.startsWith('#') ? `${issues}/${ref.slice(1)}` : `${releases}/tag/${ref}`; +--- + + +
+

Roadmap

+

Where protoAgent is headed — kept honest and light.

+ +
+ {roadmap.map(({ status, items }) => { + const a = accents[status] ?? fallback; + return ( +
+
+ +

{status}

+ + {items.length} + +
+ +
    + {items.map(({ title, detail, refs }) => ( +
  • + +
    + {title} + {refs.map((ref) => ( + + {ref} + + ))} +
    + {detail &&

    {detail}

    } +
  • + ))} +
+
+ ); + })} +
+ +
+

+ Plans shift — this tracks{' '} + open issues. + Shipped work lands in the{' '} + changelog. +

+
+
+
From a6d578717c0977ccd33172528532ffd0887b332e Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:02:42 -0700 Subject: [PATCH 29/81] =?UTF-8?q?feat(chat):=20"Rewind=20to=20here"=20?= =?UTF-8?q?=E2=80=94=20truncate=20a=20thread=20at=20a=20message,=20rewrite?= =?UTF-8?q?=20the=20checkpoint=20(#1535)=20(#1572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A destructive "Rewind to here" action on assistant messages: discards everything after the chosen message and, critically, rewrites the a2a: LangGraph checkpoint (the agent's real context) so a client-only trim can't leave the agent still remembering discarded turns. Mirrors the /compact plumbing: graph/rewind_op.py (host-free) → server.chat.rewind_session (under _thread_lock) → POST /api/chat/sessions/{id}/rewind; confirm dialog + client mirror on found. Boundary integrity: _safe_cut_end never orphans a tool_call from its ToolMessage. Review fix (was: silent divergence on duplicate assistant content — the server resolved content to the LAST match while the client truncated at the clicked bubble). The client now sends WHICH occurrence of that text it clicked, and the server picks the same occurrence from the start (falls back to last-match only when unaligned). Tested. Closes #1535. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/chat/ChatMessageView.tsx | 6 +- apps/web/src/chat/ChatSurface.tsx | 62 ++++++++ apps/web/src/lib/api.ts | 20 +++ graph/rewind_op.py | 179 ++++++++++++++++++++++ operator_api/chat_routes.py | 25 ++- server/chat.py | 54 +++++++ tests/test_chat_routes.py | 32 ++++ tests/test_rewind_op.py | 213 ++++++++++++++++++++++++++ 8 files changed, 589 insertions(+), 2 deletions(-) create mode 100644 graph/rewind_op.py create mode 100644 tests/test_rewind_op.py diff --git a/apps/web/src/chat/ChatMessageView.tsx b/apps/web/src/chat/ChatMessageView.tsx index 5e2511ae..02b19b32 100644 --- a/apps/web/src/chat/ChatMessageView.tsx +++ b/apps/web/src/chat/ChatMessageView.tsx @@ -2,7 +2,7 @@ import { Button } from "@protolabsai/ui/primitives"; import { Message, MessageAction, MessageActions } from "@protolabsai/ui/ai"; import { Tooltip } from "@protolabsai/ui/overlays"; import { Spinner } from "@protolabsai/ui/data"; -import { ArrowDownToLine, Check, Clock, Coins, Copy, GitBranch, Gauge, Maximize2, RotateCcw } from "lucide-react"; +import { ArrowDownToLine, Check, Clock, Coins, Copy, GitBranch, Gauge, History, Maximize2, RotateCcw } from "lucide-react"; import { openDocument } from "../docviewer"; import { slashCommandName } from "../ext/slashRegistry"; @@ -22,6 +22,7 @@ export type ChatMessageActions = { copiedId?: string | null; onCopy?: (m: ChatMessage) => void; onFork?: (m: ChatMessage) => void; + onRewind?: (m: ChatMessage) => void; onRegenerate?: (id: string) => void; lastAssistantId?: string; regenDisabled?: boolean; @@ -200,6 +201,9 @@ export function ChatMessageView({ {actions.onFork ? ( } onClick={() => actions.onFork!(message)} /> ) : null} + {actions.onRewind ? ( + } onClick={() => actions.onRewind!(message)} /> + ) : null} {actions.onRegenerate && message.id === actions.lastAssistantId ? ( (null); // Transient "copied ✓" feedback on a message's copy action. const [copiedId, setCopiedId] = useState(null); + // The message a "Rewind to here" is pending confirmation on (null = dialog closed). + // Rewind is destructive (discards everything below), so it goes through a confirm. + const [pendingRewind, setPendingRewind] = useState(null); // Mid-turn steering: user messages queued WHILE a turn streams (optimistic), // reconciled at turn-end. The ref mirrors the state so the post-stream reconcile // (a stale render closure) reads the live queue. @@ -887,6 +890,48 @@ function ChatSessionSlot({ chatStore.renameSession(created.id, `${baseTitle} (fork)`); } + // Rewind the conversation to a message IN PLACE (vs fork's new tab): discard + // everything below it. Destructive + irreversible, so it's gated behind a confirm + // (pendingRewind opens the dialog); confirmRewind does the work. The server rewrite + // is the point — the LangGraph checkpoint is the agent's real context, so a + // client-only trim would leave the agent still "remembering" the discarded turns. + function rewindAtMessage(message: ChatMessage) { + if (!session || status === "streaming") return; + setPendingRewind(message); + } + + async function confirmRewind(message: ChatMessage) { + if (!session) return; + const i = session.messages.findIndex((m) => m.id === message.id); + if (i < 0) return; + // WHICH occurrence of this exact text the clicked bubble is — client message ids never + // appear in the checkpoint, so the server resolves by content; identical replies can + // repeat, and this makes it pick the SAME one we clicked (not a later duplicate). + const want = (message.content || "").trim(); + const occurrence = session.messages.slice(0, i).filter((m) => (m.content || "").trim() === want).length; + let found: boolean; + try { + // Roll the agent's live context back on the server FIRST (the checkpoint is + // the real memory); the client truncate below just mirrors the result. + found = (await api.rewindChatSession(session.id, message.id ?? "", message.content, occurrence)).found; + } catch (e) { + onError(`Couldn't rewind: ${errMsg(e)}`); + return; + } + // The server couldn't locate the message in the live checkpoint — leave the + // client thread intact rather than diverge (the agent would still "remember" + // turns the UI had dropped). + if (!found) { + onError("Couldn't rewind — that message is no longer in the agent's live context."); + return; + } + // Keep the prefix through the selected message; drop everything after it. + const snap = chatStore.getSnapshot().sessions.find((s) => s.id === session.id); + const base = snap?.messages ?? session.messages; + const at = base.findIndex((m) => m.id === message.id); + chatStore.updateMessages(session.id, base.slice(0, (at < 0 ? i : at) + 1)); + } + // Resume a paused (input-required) turn: submitting the HITL form/question // sends the response as a follow-up on the same session — the server feeds it // to the agent via Command(resume=…). A form response is serialized to JSON. @@ -1254,6 +1299,7 @@ function ChatSessionSlot({ copiedId, onCopy: copyMessage, onFork: forkAtMessage, + onRewind: rewindAtMessage, onRegenerate: regenerate, lastAssistantId, regenDisabled: status === "streaming", @@ -1444,6 +1490,22 @@ function ChatSessionSlot({ }} />
+ + { + if (pendingRewind) void confirmRewind(pendingRewind); + setPendingRewind(null); + }} + onClose={() => setPendingRewind(null)} + > +

+ This will discard everything below this message — cannot be undone. +

+
); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 16c95e88..97c69834 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1196,6 +1196,26 @@ export const api = { }>(`/api/chat/sessions/${encodeURIComponent(sessionId)}/compact`, { method: "POST", body: {} }); }, + // Rewind a chat session server-side (#1535): discard every message AFTER the + // target and rewrite the LangGraph checkpoint in place, rolling the agent's live + // context back to that point. The checkpoint is the agent's REAL context, so this + // must be server-side — a client-only truncate would leave the agent's memory + // intact. Intentionally DESTRUCTIVE (no archive) but never corrupting. `content` + // is the visible bubble's text: the console's client-side message ids never appear + // in the checkpoint, so the server locates the message by its rendered content. + rewindChatSession(sessionId: string, messageId: string, content?: string, occurrence?: number) { + return request<{ + found: boolean; + kept: number; + removed: number; + reason: string; + message: string; + }>(`/api/chat/sessions/${encodeURIComponent(sessionId)}/rewind`, { + method: "POST", + body: { message_id: messageId, content, occurrence }, + }); + }, + async streamChat( message: string, sessionId: string, diff --git a/graph/rewind_op.py b/graph/rewind_op.py new file mode 100644 index 00000000..4f834da6 --- /dev/null +++ b/graph/rewind_op.py @@ -0,0 +1,179 @@ +"""On-demand conversation rewind — the "Rewind to here" operator gesture (#1535). + +The destructive sibling of ``compaction_op`` (the ``/compact`` gesture): instead +of summarizing older history to save tokens, the operator points at a message and +says "discard everything after this." The live LangGraph checkpoint is the agent's +*real* context, so a client-only truncate would leave the agent's memory intact — +this runs SERVER-SIDE against the checkpointer and rewrites the message log. + +The pass, for one thread: + +1. ``aget_state`` the current messages off the checkpoint. +2. Locate the target message (by raw index, by message ``id``, or — the console + path — by matching its rendered ``content``, since the client's message ids are + client-generated and never appear in the checkpoint). +3. Keep the prefix THROUGH the target, then rewrite the checkpoint to + ``[RemoveMessage(REMOVE_ALL_MESSAGES), *kept_prefix]`` via ``aupdate_state``. + +**Rewind is intentionally destructive** — unlike compaction it is NOT never-lossy: +the messages after the cut are meant to be thrown away (there is no archive). What +it must NOT do is *corrupt* the log. + +**Message-boundary integrity (hard invariant).** The kept prefix must never end on +an ``AIMessage(tool_calls=…)`` whose ``ToolMessage`` responses fall past the cut — +that leaves an orphaned tool_call and the next model call errors ("tool_call +without response"). We reuse the same safe-cut idea as the auto-summarizer / +``compaction_op._safe_cut_index``: if the naive cut lands inside a tool-call block, +extend FORWARD to pull the answering ``ToolMessage``\\s back in, and only if that +can't balance, fall BACK to before the requesting ``AIMessage``. + +Host-free and unit-testable: it takes the graph + checkpointer + thread id as +arguments (no ``STATE`` import), mirroring ``compaction_op.compact_thread``. +""" + +from __future__ import annotations + +import logging + +from langchain_core.messages import AIMessage, ToolMessage + +log = logging.getLogger(__name__) + + +def _open_tool_calls(messages: list) -> set[str]: + """The tool_call ids REQUESTED by ``AIMessage.tool_calls`` in ``messages`` that + have no answering ``ToolMessage`` in the same slice — i.e. the tool calls a + kept prefix would orphan.""" + opened: set[str] = set() + answered: set[str] = set() + for m in messages: + if isinstance(m, AIMessage) and getattr(m, "tool_calls", None): + for tc in m.tool_calls: + tid = tc.get("id") + if tid: + opened.add(tid) + elif isinstance(m, ToolMessage): + tcid = getattr(m, "tool_call_id", None) + if tcid: + answered.add(tcid) + return opened - answered + + +def _safe_cut_end(messages: list, end: int) -> int: + """Adjust ``end`` (an EXCLUSIVE cut — the kept prefix is ``messages[:end]``) so + it never orphans a ``ToolMessage`` from the ``AIMessage(tool_calls=…)`` that + spawned it. + + The prefix-side analogue of ``compaction_op._safe_cut_index``: if the naive cut + would leave a tool-call block half-kept, first extend FORWARD over the answering + ``ToolMessage``\\s (keeping the request/response pair together, discarding + slightly less), and only if that still can't balance, fall BACK before the + requesting ``AIMessage`` (dropping the unanswered request). Returns ``end`` + unchanged when the prefix is already balanced — including the keep-nothing / + keep-everything ends, which can't orphan anything. + """ + n = len(messages) + end = max(0, min(end, n)) + if end == 0 or end == n: + return end + # Forward: while the kept prefix has unanswered tool calls and the very next + # message is a ToolMessage answering the block, pull it in. + while end < n and isinstance(messages[end], ToolMessage) and _open_tool_calls(messages[:end]): + end += 1 + # Back: if the prefix is still unbalanced (couldn't answer forward), retreat + # before the requesting AIMessage(s) until nothing is orphaned. + while end > 0 and _open_tool_calls(messages[:end]): + end -= 1 + return end + + +def _resolve_end(messages: list, *, target_index, target_id, target_content, occurrence=None) -> int | None: + """Index of the message JUST PAST the target (the naive, pre-safe-cut prefix + length), or ``None`` if the target can't be located. + + Precedence: an explicit ``target_index`` wins; then a matching message ``id``; + then the LAST message whose ``content`` equals ``target_content`` (the console + path — the visible assistant bubble's text matches its final ``AIMessage``). + Last-occurrence is the conservative pick when identical replies repeat. + """ + n = len(messages) + if target_index is not None: + idx = int(target_index) + if idx < 0: + idx += n # allow -1 = last + if 0 <= idx < n: + return idx + 1 + return None + if target_id is not None: + for i, m in enumerate(messages): + if getattr(m, "id", None) == target_id: + return i + 1 + # fall through to content matching if an id was given but not found + if target_content is not None: + want = str(target_content).strip() + if want: + matches = [i for i in range(n) if str(getattr(messages[i], "content", "") or "").strip() == want] + if matches: + # The client sends WHICH occurrence of this content it clicked — identical + # replies can repeat, and picking the last match would silently keep a LATER + # duplicate the user meant to discard (defeating the whole point of rewind). + # Pick that same occurrence from the start; fall back to the last match + # (conservative — discards less, never corrupts) only when unaligned. + if occurrence is not None and 0 <= int(occurrence) < len(matches): + return matches[int(occurrence)] + 1 + return matches[-1] + 1 + return None + + +async def rewind_thread( + graph, + checkpointer, + thread_id: str, + *, + target_index: int | None = None, + target_id: str | None = None, + target_content: str | None = None, + occurrence: int | None = None, +) -> dict: + """Rewind ``thread_id``'s live context to the target message: keep the prefix + through it and discard everything after, rewriting the checkpoint in place. + + Returns ``{found, kept, removed, reason}``. ``found`` is false (with no rewrite) + when there's no checkpointer or the target can't be located; ``removed == 0`` is + a benign no-op (the target was already the last message). Boundary integrity is + enforced via ``_safe_cut_end`` — a rewind never leaves an orphaned tool_call. + """ + if graph is None or checkpointer is None: + return {"found": False, "kept": 0, "removed": 0, "reason": "no_checkpointer"} + + lg_config = {"configurable": {"thread_id": thread_id}} + snapshot = await graph.aget_state(lg_config) + messages = list((getattr(snapshot, "values", None) or {}).get("messages") or []) + + raw_end = _resolve_end( + messages, + target_index=target_index, + target_id=target_id, + target_content=target_content, + occurrence=occurrence, + ) + if raw_end is None: + return {"found": False, "kept": len(messages), "removed": 0, "reason": "not_found"} + + end = _safe_cut_end(messages, raw_end) + kept = messages[:end] + removed = len(messages) - end + + if removed <= 0: + # Target is already the tail — nothing to discard. Don't touch the checkpoint. + return {"found": True, "kept": len(kept), "removed": 0, "reason": "noop"} + + from langchain_core.messages import RemoveMessage + from langgraph.graph.message import REMOVE_ALL_MESSAGES + + await graph.aupdate_state( + lg_config, + {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *kept]}, + ) + log.info("[rewind] thread %s: kept %d msg(s), discarded %d", thread_id, len(kept), removed) + return {"found": True, "kept": len(kept), "removed": removed, "reason": ""} diff --git a/operator_api/chat_routes.py b/operator_api/chat_routes.py index 1aa51abe..265da814 100644 --- a/operator_api/chat_routes.py +++ b/operator_api/chat_routes.py @@ -27,7 +27,7 @@ log = logging.getLogger("protoagent.server") from server import agent_name from server.agent_init import _retire_thread -from server.chat import chat, compact_session +from server.chat import chat, compact_session, rewind_session class ChatRequest(BaseModel): @@ -88,6 +88,29 @@ async def _api_compact_session(session_id: str): keeps the full thread rather than dropping anything).""" return await compact_session(session_id) + @app.post("/api/chat/sessions/{session_id}/rewind") + async def _api_rewind_session(session_id: str, body: dict | None = None): + """Rewind a chat session to a target message (#1535): discard everything + AFTER it and rewrite the LangGraph checkpoint IN PLACE. Runs SERVER-SIDE — + the checkpoint is the agent's real context, so a client-only truncate would + leave the agent's memory intact. + + The body carries the target: ``message_id`` and/or ``content`` (the console + sends the visible bubble's text, since its client-side message ids never + appear in the checkpoint), or an explicit ``index``. Intentionally + DESTRUCTIVE (no archive) but never corrupting — the kept prefix is trimmed + to a safe tool-call boundary so no orphaned tool_call is left behind.""" + body = body or {} + idx = body.get("index") + occ = body.get("occurrence") + return await rewind_session( + session_id, + message_id=body.get("message_id"), + index=int(idx) if idx is not None else None, + content=body.get("content"), + occurrence=int(occ) if occ is not None else None, + ) + @app.post("/api/chat/sessions/{session_id}/steer") async def _api_steer(session_id: str, body: dict | None = None): """Queue a user message into a RUNNING turn (mid-turn steering). diff --git a/server/chat.py b/server/chat.py index 86c14d82..10d442b0 100644 --- a/server/chat.py +++ b/server/chat.py @@ -1218,6 +1218,60 @@ async def compact_session(session_id: str, *, request_metadata: dict | None = No return {**result, "message": _compaction_message(result)} +def _rewind_message(result: dict) -> str: + """Human-readable status line for a rewind result (surfaced to non-UI callers / + logs; the console just truncates its own thread on success).""" + reason = result.get("reason") or "" + if reason == "not_found": + return "Couldn't rewind — that message is no longer in the agent's live context." + if reason == "no_checkpointer": + return "Rewind unavailable — no conversation checkpoint to rewind." + if reason == "noop": + return "Nothing to rewind — that's already the last message." + return f"Rewound the conversation — discarded {result.get('removed', 0)} later message(s)." + + +async def rewind_session( + session_id: str, + *, + message_id: str | None = None, + index: int | None = None, + content: str | None = None, + occurrence: int | None = None, + request_metadata: dict | None = None, +) -> dict: + """Rewind a chat session's live context to a target message (the "Rewind to + here" gesture, #1535): discard everything after it and rewrite the LangGraph + checkpoint in place. + + Resolves the session's checkpointer ``thread_id`` (the A2A ``a2a:`` + thread the live streaming turns write to) and runs ``rewind_thread`` under the + per-thread lock, so a rewind can never race a live streaming turn on the same + thread (mirrors ``compact_session``). The checkpoint is the agent's REAL + context, so a client-only truncate would leave it intact — the rewrite here is + what actually rolls the agent's memory back. Returns the ``rewind_thread`` + result dict plus a human-readable ``message``. + """ + base = {"found": False, "kept": 0, "removed": 0} + if STATE.graph is None: + return {**base, "reason": "setup", "message": "Setup required — finish the setup wizard first."} + + from graph.rewind_op import rewind_thread + + tid = _resolve_thread_id(request_metadata, session_id) + async with _thread_lock(tid): + result = await rewind_thread( + STATE.graph, + STATE.checkpointer, + tid, + target_index=index, + target_id=message_id, + target_content=content, + occurrence=occurrence, + ) + return {**result, "message": _rewind_message(result)} + + async def _chat_langgraph(message: str, session_id: str, *, model: str | None = None) -> list[dict[str, Any]]: """Non-streaming LangGraph entry — used by the console + OpenAI-compat.""" from observability import tracing diff --git a/tests/test_chat_routes.py b/tests/test_chat_routes.py index 91239f5a..0a34737c 100644 --- a/tests/test_chat_routes.py +++ b/tests/test_chat_routes.py @@ -107,6 +107,38 @@ async def _fake_compact(session_id): assert body["message"] == "Compacted this conversation" +def test_rewind_session_route(monkeypatch): + # The route is a thin pass-through to server.chat.rewind_session — forwards the + # path session_id + body target (message_id / content / index) and returns the + # rewind result dict verbatim. + import operator_api.chat_routes as cr + + seen: list[dict] = [] + + async def _fake_rewind(session_id, *, message_id=None, index=None, content=None, occurrence=None): + seen.append( + { + "session_id": session_id, + "message_id": message_id, + "index": index, + "content": content, + "occurrence": occurrence, + } + ) + return {"found": True, "kept": 4, "removed": 2, "reason": "", "message": "Rewound"} + + monkeypatch.setattr(cr, "rewind_session", _fake_rewind) + c = _client(monkeypatch) + body = c.post( + "/api/chat/sessions/s1/rewind", json={"message_id": "m9", "content": "the answer"} + ).json() + assert seen == [ + {"session_id": "s1", "message_id": "m9", "index": None, "content": "the answer", "occurrence": None} + ] + assert body["removed"] == 2 and body["kept"] == 4 and body["found"] is True + assert body["message"] == "Rewound" + + def test_delete_session_cleans_ephemeral_attachments(monkeypatch): """Deleting a chat drops its session-scoped attachment chunks.""" import operator_api.chat_routes as cr diff --git a/tests/test_rewind_op.py b/tests/test_rewind_op.py new file mode 100644 index 00000000..efc6840e --- /dev/null +++ b/tests/test_rewind_op.py @@ -0,0 +1,213 @@ +"""Tests for on-demand conversation rewind (the "Rewind to here" gesture, #1535).""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.graph.message import REMOVE_ALL_MESSAGES + +from graph.checkpointer import build_sqlite_checkpointer +from graph.rewind_op import _open_tool_calls, _safe_cut_end, rewind_thread + + +class _FakeGraph: + """Records aupdate_state calls; serves seeded messages from aget_state.""" + + def __init__(self, messages): + self._messages = messages + self.updates: list = [] + + async def aget_state(self, config): + return SimpleNamespace(values={"messages": list(self._messages)}) + + async def aupdate_state(self, config, update): + self.updates.append((config, update)) + + +def _tool_thread(): + # A two-turn thread where each turn is [AI(tool_calls), Tool, AI(answer)]. + return [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="", id="a1", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", id="tm1", tool_call_id="t1"), + AIMessage(content="answer1", id="a2"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="", id="a3", tool_calls=[{"id": "t2", "name": "search", "args": {}}]), + ToolMessage(content="res2", id="tm2", tool_call_id="t2"), + AIMessage(content="answer2", id="a4"), + ] + + +def test_rewind_truncates_by_index(): + msgs = [ + HumanMessage(content="hi", id="h1"), + AIMessage(content="hello", id="a1"), + HumanMessage(content="favorite color?", id="h2"), + AIMessage(content="teal", id="a2"), + ] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_index=1)) + assert res == {"found": True, "kept": 2, "removed": 2, "reason": ""} + + # Checkpoint rewritten to [REMOVE_ALL, *kept_prefix]. + assert len(g.updates) == 1 + _config, update = g.updates[0] + out = update["messages"] + assert isinstance(out[0], RemoveMessage) and out[0].id == REMOVE_ALL_MESSAGES + assert [m.id for m in out[1:]] == ["h1", "a1"] # everything after index 1 discarded + + +def test_rewind_by_message_id(): + msgs = _tool_thread() + g = _FakeGraph(msgs) + # Rewind to the first turn's final answer — keeps the whole first turn. + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="a2")) + _config, update = g.updates[0] + assert [m.id for m in update["messages"][1:]] == ["h1", "a1", "tm1", "a2"] + assert res["removed"] == 4 and res["kept"] == 4 + + +def test_rewind_by_content_matches_last_occurrence(): + # The console path: the client sends the visible bubble's text (its client-side + # message id is NOT in the checkpoint). Last-occurrence disambiguates repeats. + msgs = [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="done", id="a1"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="done", id="a2"), + HumanMessage(content="q3", id="h3"), + AIMessage(content="final", id="a3"), + ] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="client-xyz", target_content="done")) + # "client-xyz" isn't a checkpoint id → falls through to content, last "done" = a2. + _config, update = g.updates[0] + assert [m.id for m in update["messages"][1:]] == ["h1", "a1", "h2", "a2"] + assert res["removed"] == 2 and res["kept"] == 4 + + +def test_rewind_by_content_honors_occurrence(): + # Duplicate replies: the client sends WHICH occurrence it clicked, so the server keeps + # through the RIGHT "done" — not the last one (which would silently retain turns the user + # meant to discard). Clicking the FIRST "done" (occurrence 0) keeps only [h1, a1]. + msgs = [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="done", id="a1"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="done", id="a2"), + HumanMessage(content="q3", id="h3"), + AIMessage(content="final", id="a3"), + ] + g = _FakeGraph(msgs) + res = asyncio.run( + rewind_thread(g, object(), "a2a:s1", target_content="done", occurrence=0) + ) + _config, update = g.updates[0] + assert [m.id for m in update["messages"][1:]] == ["h1", "a1"] # the FIRST "done", not a2 + assert res["removed"] == 4 and res["kept"] == 2 + + +def test_rewind_preserves_tool_call_pairing(): + # Rewind lands ON the AIMessage(tool_calls) — the safe-cut must extend FORWARD to + # pull in its ToolMessage so the request/response pair isn't orphaned. + msgs = _tool_thread() + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="a3")) + _config, update = g.updates[0] + kept = update["messages"][1:] + # Kept through a3's ToolMessage (tm2) — a4 (answer2) discarded, tm2 NOT orphaned. + assert [m.id for m in kept] == ["h1", "a1", "tm1", "a2", "h2", "a3", "tm2"] + assert isinstance(kept[-1], ToolMessage) and kept[-1].tool_call_id == "t2" + assert _open_tool_calls(kept) == set() # no orphaned tool_call + assert res["removed"] == 1 and res["kept"] == 7 + + +def test_rewind_falls_back_before_toolcall_when_response_missing(): + # Malformed/partial turn: the AIMessage(tool_calls) has NO answering ToolMessage. + # Forward can't balance, so safe-cut retreats to before the requesting AIMessage. + msgs = [ + HumanMessage(content="q1", id="h1"), + AIMessage(content="", id="a1", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", id="tm1", tool_call_id="t1"), + AIMessage(content="answer1", id="a2"), + HumanMessage(content="q2", id="h2"), + AIMessage(content="", id="a3", tool_calls=[{"id": "t2", "name": "search", "args": {}}]), + AIMessage(content="answer2", id="a4"), # no ToolMessage for t2 + ] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="a3")) + _config, update = g.updates[0] + kept = update["messages"][1:] + assert [m.id for m in kept] == ["h1", "a1", "tm1", "a2", "h2"] # a3 dropped, no orphan + assert _open_tool_calls(kept) == set() + assert res["removed"] == 2 and res["kept"] == 5 + + +def test_rewind_noop_when_target_is_last(): + msgs = [HumanMessage(content="hi", id="h1"), AIMessage(content="yo", id="a1")] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="a1")) + assert res["found"] is True and res["removed"] == 0 and res["reason"] == "noop" + assert g.updates == [] # nothing after the target — no rewrite + + +def test_rewind_not_found(): + msgs = [HumanMessage(content="hi", id="h1"), AIMessage(content="yo", id="a1")] + g = _FakeGraph(msgs) + res = asyncio.run(rewind_thread(g, object(), "a2a:s1", target_id="nope", target_content="nomatch")) + assert res["found"] is False and res["reason"] == "not_found" + assert g.updates == [] + + +def test_rewind_refuses_without_checkpointer(): + res = asyncio.run(rewind_thread(_FakeGraph([]), None, "a2a:s1", target_index=0)) + assert res["found"] is False and res["reason"] == "no_checkpointer" + + +def test_safe_cut_end_keeps_balanced_ends(): + msgs = _tool_thread() + assert _safe_cut_end(msgs, 0) == 0 # keep-nothing can't orphan + assert _safe_cut_end(msgs, len(msgs)) == len(msgs) # keep-all can't orphan + assert _safe_cut_end(msgs, 4) == 4 # already a clean turn boundary + + +def test_rewind_rewrites_real_sqlite_checkpoint(tmp_path): + """End-to-end against a real compiled graph + SQLite checkpointer: the rewrite + actually lands (REMOVE_ALL + reducer, no as_node ambiguity) and the resulting + checkpoint is the kept prefix, with tool pairing intact.""" + db = str(tmp_path / "c.db") + g = StateGraph(MessagesState) + g.add_node("n", lambda s: {"messages": []}) # no-op — we seed via the input + g.add_edge(START, "n") + g.add_edge("n", END) + saver = build_sqlite_checkpointer(db) + app = g.compile(checkpointer=saver) + cfg = {"configurable": {"thread_id": "a2a:s1"}} + seed = [ + HumanMessage(content="q1"), + AIMessage(content="", tool_calls=[{"id": "t1", "name": "search", "args": {}}]), + ToolMessage(content="res1", tool_call_id="t1"), + AIMessage(content="answer1"), + HumanMessage(content="q2"), + AIMessage(content="answer2"), + ] + + async def run(): + await app.ainvoke({"messages": seed}, cfg) + snap0 = await app.aget_state(cfg) + # Rewind to the first turn's final answer (content "answer1"). + res = await rewind_thread(app, saver, "a2a:s1", target_content="answer1") + snap = await app.aget_state(cfg) + return res, snap0.values["messages"], snap.values["messages"] + + res, before, final = asyncio.run(run()) + assert len(before) == 6 # sanity: the seed landed + assert res["found"] is True and res["removed"] == 2 and res["kept"] == 4 + # Live context rolled back to [q1, AI(tool_calls), tool, answer1] — turn 2 gone. + assert len(final) == 4 + assert final[-1].content == "answer1" + assert all("answer2" not in (m.content or "") for m in final) + assert _open_tool_calls(final) == set() # tool pairing preserved From 417141d6aa5a031ecd69046e5b61cf5fb97ee964 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:04:04 -0700 Subject: [PATCH 30/81] feat(console): live-update the Work panel Overview on goal/task/schedule changes (#1537) (#1571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview roll-up read goals/tasks/schedule via useSuspenseQuery but never subscribed to the server→client event bus — so while the Overview tab is showing (the individual panels unmounted), nothing refreshed its counts. Subscribe in WorkOverview via the existing onServerEvent SSE client, invalidating the matching query keys on the ADR-0039 topics: goal.changed + goal.iteration, task.changed, scheduler.fired. Push-based, background invalidate (no re-suspend flicker). The scheduler bus has no push on agent schedule add/cancel (mutates the store directly), so the Overview's schedule query gets a gentle per-use refetchInterval (15s) — NOT on the shared factory, to avoid regressing SchedulePanel. Closes #1537. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/web/src/app/WorkPanel.tsx | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/WorkPanel.tsx b/apps/web/src/app/WorkPanel.tsx index a5e20106..b0d3f83a 100644 --- a/apps/web/src/app/WorkPanel.tsx +++ b/apps/web/src/app/WorkPanel.tsx @@ -1,5 +1,5 @@ -import { useState, type ComponentProps, type ReactNode } from "react"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useEffect, useState, type ComponentProps, type ReactNode } from "react"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { Tabs } from "@protolabsai/ui/navigation"; import { Boxes, CalendarClock, ChevronRight, Eye, LayoutDashboard, Target } from "lucide-react"; import type { LucideIcon } from "lucide-react"; @@ -9,7 +9,8 @@ import { GoalsPanel } from "./GoalsPanel"; import { WatchesPanel } from "./WatchesPanel"; import { TasksPanel } from "./TasksPanel"; import { SchedulePanel } from "../schedule/SchedulePanel"; -import { tasksQuery, goalsQuery, schedulesQuery } from "../lib/queries"; +import { onServerEvent } from "../lib/events"; +import { tasksQuery, goalsQuery, schedulesQuery, queryKeys } from "../lib/queries"; import type { Task, GoalState, ScheduledJob } from "../lib/types"; import "./work.css"; @@ -63,7 +64,31 @@ const normStatus = (s: string | undefined) => (s ?? "").toLowerCase().replace(/[ function WorkOverview({ onJump }: { onJump: (t: WorkTab) => void }) { const goals = useSuspenseQuery(goalsQuery()).data.goals; const issues = useSuspenseQuery(tasksQuery()).data.issues; - const jobs = useSuspenseQuery(schedulesQuery()).data.jobs; + // The scheduler bus only emits `scheduler.fired` (a dispatch) — there is NO push when the + // AGENT adds/cancels a job mid-turn (schedule_task/cancel_schedule mutate the store directly). + // A gentle per-use poll (NOT on the shared schedulesQuery() factory — that would regress + // SchedulePanel and other `schedules`-key consumers) self-heals that one change-class (#1537). + const jobs = useSuspenseQuery({ ...schedulesQuery(), refetchInterval: 15_000 }).data.jobs; + const queryClient = useQueryClient(); + + // Live roll-up: the Overview is a different tab than the Goals/Tasks/Schedule panels, so + // while it's showing those panels are unmounted and their own bus subscriptions are gone. + // Subscribe here too so agent-driven changes mid-turn refresh the counts without a remount + // (#1537) — the same push pattern as the panels (invalidate the matching query key on the + // relevant ADR 0039 topic, no polling): `goal.changed`/`goal.iteration` (set/advance/clear/ + // terminal), `task.changed` (filed/closed/updated), `scheduler.fired` (a job dispatched, so + // its next_fire moved). + useEffect(() => { + const refresh = (key: readonly unknown[]) => () => + void queryClient.invalidateQueries({ queryKey: key }); + const offs = [ + onServerEvent("goal.changed", refresh(queryKeys.goals)), + onServerEvent("goal.iteration", refresh(queryKeys.goals)), + onServerEvent("task.changed", refresh(queryKeys.tasks)), + onServerEvent("scheduler.fired", refresh(queryKeys.schedules)), + ]; + return () => offs.forEach((off) => off()); + }, [queryClient]); const activeGoals = goals.filter( (g) => g.status !== "achieved" && g.status !== "failed" && !g.finished_at, From f9b79edce48acaa938f9ce83b34a7ebb054f9dba Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:09:15 -0700 Subject: [PATCH 31/81] fix(plugins): git clone --no-hardlinks so local-path installs don't flake (#1573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin installer's `git clone` hardlinks source objects when the URL is a LOCAL path (git's default local-clone optimization), which intermittently fails with `fatal: hardlink different from source at .../objects/pack/tmp_idx_*` — a known local-clone race. It also silently ignores `--depth`. This flaked CI's `test_install_deps_rejects_non_pep508_requires_pip` (and siblings) repeatedly (3× today across unrelated PRs), since the test fixtures install from a local git repo. Add `--no-hardlinks` to all three clone paths. It's a no-op for the normal remote/network clone (real plugin installs from git URLs), so it only makes local-path installs — and the test fixtures — robust. Looped the previously-flaky test 25× → 25/25. Co-authored-by: Claude Opus 4.8 (1M context) --- graph/plugins/installer.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/graph/plugins/installer.py b/graph/plugins/installer.py index 2649d40f..4653b712 100644 --- a/graph/plugins/installer.py +++ b/graph/plugins/installer.py @@ -189,17 +189,23 @@ def _summary(m: PluginManifest, *, source: str, ref: str, sha: str) -> dict: def _clone(url: str, ref: str | None, dest: Path) -> str: - """Clone ``url`` at ``ref`` into ``dest``; return the resolved commit SHA.""" + """Clone ``url`` at ``ref`` into ``dest``; return the resolved commit SHA. + + ``--no-hardlinks``: when ``url`` is a LOCAL path, ``git clone`` hardlinks the + source's object files by default, which intermittently fails with + ``fatal: hardlink different from source`` (a known local-clone race — it also + silently ignores ``--depth``). It's a no-op for the normal remote/network case, + so this only makes local-path installs (and the test fixtures) robust.""" if ref and _SHA_RE.match(ref): # A specific commit: full clone (shallow can't reliably check out an # arbitrary SHA), then check it out. - _git("clone", "--no-recurse-submodules", url, str(dest), timeout=_CLONE_TIMEOUT_S) + _git("clone", "--no-hardlinks", "--no-recurse-submodules", url, str(dest), timeout=_CLONE_TIMEOUT_S) _git("checkout", ref, cwd=dest) elif ref: # A tag or branch: shallow clone of just that ref. - _git("clone", "--depth", "1", "--no-recurse-submodules", "--branch", ref, url, str(dest), timeout=_CLONE_TIMEOUT_S) + _git("clone", "--depth", "1", "--no-hardlinks", "--no-recurse-submodules", "--branch", ref, url, str(dest), timeout=_CLONE_TIMEOUT_S) else: - _git("clone", "--depth", "1", "--no-recurse-submodules", url, str(dest), timeout=_CLONE_TIMEOUT_S) + _git("clone", "--depth", "1", "--no-hardlinks", "--no-recurse-submodules", url, str(dest), timeout=_CLONE_TIMEOUT_S) return _git("rev-parse", "HEAD", cwd=dest) From 24f443fefeda18ed7d5add0fcdee35ae82a2573a Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:15:59 -0700 Subject: [PATCH 32/81] fix(plugins): a stale untracked live copy can't shadow a newer bundled plugin (#1574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader's discover_plugins let the live/installed plugins dir override the bundled tree UNCONDITIONALLY ("live overrides bundle by id"). So a plugin that was once git-installed and later bundled in-tree — e.g. artifact @ 0.11.3 left in the instance plugins dir vs the bundled 0.14.0 — stayed stuck on the old installed copy forever: it never updated with the app, and the update mechanism (#1521) couldn't touch a bundled plugin. Now the override is track/version-aware: an UNTRACKED live copy (not in plugins.lock) only wins when it's NOT OLDER than the bundle; an intentional install/override (tracked in the lock) still wins at any version, and a same-or-newer untracked dev override still wins. discover_plugins gains an optional tracked_ids (defaults to the plugins.lock ids); _version_key does a best-effort semver compare. Tests: older untracked → bundle wins; tracked override → live wins at any version; the existing same-version live-override still wins. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 +++++++++ graph/plugins/loader.py | 49 +++++++++++++++++++++++++++++++++++++---- tests/test_plugins.py | 31 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b51eefd..2c37e2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gained a `soul` field), so a bundle agent arrives with its persona wired in. ### Fixed +- **A stale untracked plugin copy no longer shadows a newer bundled one.** The loader let the + live/installed plugins dir override the bundled tree *unconditionally*, so a plugin that was + once git-installed and later bundled in-tree (e.g. the artifact plugin @ 0.11.3 vs the bundled + 0.14.0) stayed stuck on the old installed copy forever — it never updated with the app. Now an + **untracked** live copy only wins when it's **not older** than the bundle; an intentional + install/override (tracked in `plugins.lock`) still wins at any version. +- **Plugin install `git clone --no-hardlinks`** — cloning a plugin from a local path hardlinked + source objects and intermittently failed with `fatal: hardlink different from source` (a known + local-clone race that also silently ignores `--depth`); a no-op for the normal remote/network + clone. Fixes recurring CI flakiness in the installer tests. - **Console dev server no longer defaults to the prod backend.** `apps/web/vite.config.ts` proxied `npm run dev` / `npm run preview` backend calls to `:7870` (the default/prod instance the desktop app runs) by default — so browser-based console development silently read and **wrote your real diff --git a/graph/plugins/loader.py b/graph/plugins/loader.py index 7ed4aed9..3bf43d41 100644 --- a/graph/plugins/loader.py +++ b/graph/plugins/loader.py @@ -50,9 +50,43 @@ class PluginLoadResult: meta: list[dict] = field(default_factory=list) -def discover_plugins(roots: list[Path]) -> list[PluginManifest]: - """Find plugins (dirs with a manifest) under *roots*. Live overrides bundle - by id (later root wins).""" +def _version_key(v: str) -> tuple[int, int, int]: + """Best-effort semver sort key: ``"0.14.0" → (0, 14, 0)``. A non-numeric part + sorts as ``-1`` so a malformed version can't spuriously beat a real one.""" + parts: list[int] = [] + for p in str(v or "0").split(".")[:3]: + m = re.match(r"\d+", p.strip()) + parts.append(int(m.group()) if m else -1) + while len(parts) < 3: + parts.append(0) + return (parts[0], parts[1], parts[2]) + + +def _tracked_ids() -> set[str]: + """Plugin ids recorded in ``plugins.lock`` — an INTENTIONAL install/override, vs + an untracked hand-placed/leftover copy. Best-effort (empty on any error).""" + try: + from graph.plugins import installer + + return {e.get("id") for e in installer._read_lock().get("plugins", []) if e.get("id")} + except Exception: # noqa: BLE001 + return set() + + +def discover_plugins(roots: list[Path], *, tracked_ids: set[str] | None = None) -> list[PluginManifest]: + """Find plugins (dirs with a manifest) under *roots*, later roots (the live/installed + dir) taking precedence over earlier ones (the bundled dir) by id — but ONLY when the live + copy that is UNTRACKED (not in ``plugins.lock``) only wins when it's NOT OLDER than the + bundled one. + + This stops a stale, untracked leftover from shadowing the bundled plugin: a plugin that was + once git-installed (e.g. artifact @ 0.11.3) and later bundled in-tree at a newer version + (0.14.0) would otherwise stay stuck on the old installed copy forever, never updating with + the app. An intentional install/override (tracked in the lock) still wins at ANY version; + a same-or-newer untracked copy (a dev override) still wins too. ``tracked_ids`` defaults to + the ``plugins.lock`` ids.""" + if tracked_ids is None: + tracked_ids = _tracked_ids() by_id: dict[str, PluginManifest] = {} for root in roots: if not (root and root.exists() and root.is_dir()): @@ -61,7 +95,14 @@ def discover_plugins(roots: list[Path]) -> list[PluginManifest]: if not child.is_dir(): continue manifest = load_manifest(child) - if manifest is not None: + if manifest is None: + continue + incumbent = by_id.get(manifest.id) + if ( + incumbent is None + or manifest.id in tracked_ids + or _version_key(manifest.version) >= _version_key(incumbent.version) + ): by_id[manifest.id] = manifest return list(by_id.values()) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 53ed7c40..5220d457 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -112,6 +112,37 @@ def test_discover_live_overrides_bundle(tmp_path, monkeypatch) -> None: assert found["dup"] == "from-live" +def _mk_versioned(root: Path, pid: str, version: str, desc: str) -> None: + d = root / pid + d.mkdir(parents=True, exist_ok=True) + (d / "protoagent.plugin.yaml").write_text( + f"id: {pid}\nname: {pid}\nversion: {version}\ndescription: {desc}\n", encoding="utf-8" + ) + + +def test_discover_stale_untracked_older_does_not_shadow_newer_bundle(tmp_path) -> None: + # The artifact-shadow bug: an UNTRACKED leftover from when the plugin was git-installed + # (@0.11.3) must NOT shadow the newer bundled copy (@0.14.0) — else it never updates with + # the app. Older + untracked → the bundle wins. + bundle = tmp_path / "bundle" + live = tmp_path / "live" + _mk_versioned(bundle, "dup", "0.14.0", "from-bundle") + _mk_versioned(live, "dup", "0.11.3", "from-live") + found = {m.id: (m.version, m.description) for m in discover_plugins([bundle, live], tracked_ids=set())} + assert found["dup"] == ("0.14.0", "from-bundle") + + +def test_discover_tracked_override_wins_at_any_version(tmp_path) -> None: + # An INTENTIONAL install (tracked in plugins.lock) still overrides the bundle — even when + # it's OLDER — because it's a deliberate pin, not a stale leftover. + bundle = tmp_path / "bundle" + live = tmp_path / "live" + _mk_versioned(bundle, "dup", "0.14.0", "from-bundle") + _mk_versioned(live, "dup", "0.11.3", "from-live") + found = {m.id: m.description for m in discover_plugins([bundle, live], tracked_ids={"dup"})} + assert found["dup"] == "from-live" + + def test_disabled_plugin_not_loaded(tmp_path, monkeypatch) -> None: root = tmp_path / "plugins" _make_plugin(root, "offplug", enabled=False, tool="off_tool") From e4625f2c530f65893a06de9e0e6cf380cf765dc0 Mon Sep 17 00:00:00 2001 From: Josh Mabry <31560031+mabry1985@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:31:57 -0700 Subject: [PATCH 33/81] fix(artifact): pointer-lock in nested iframe, crisp fit-to-window, persist selection (v0.15.0) (#1576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three artifact-panel bugs: 1. Pointer lock ("requires the window to have focus") — pointer-lock is a Permissions-Policy feature that must be delegated via `allow=` at EVERY iframe nesting level; only the sandbox tokens were present. Add allow="pointer-lock" to the console plugin-view iframe (PluginView.tsx) AND the nested artifact iframe (#frame). So games/canvas/3D can capture the mouse. 2. SVG/Mermaid zoom pixelated — the pan/zoom viewport CSS-transform-scaled a `will-change: transform` layer, which WKWebView (the desktop app) rasterizes at 1x then GPU-scales the bitmap → blurry on zoom-in. Replace with a CRISP fit-to-window: the scales as a vector to fit (max-width/height:100% !important, beating mermaid's inline sizing), no transform / raster layer / pan / zoom buttons. `pre.mermaid{display:contents}` so the rendered svg fits too. 3. Selected artifact + version snapped back to latest on tab-away/back — selId/ selVer/followNewest were in-memory, and switching tabs remounts the plugin iframe (fresh shell). Persist them to localStorage (SEL_KEY) + restore in boot() before the first poll; poll keeps the pin unless followNewest, and falls back to newest if the pinned artifact was deleted. Bumps the bundled artifact plugin 0.14.0 → 0.15.0. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 +++++ apps/web/src/app/PluginView.tsx | 14 ++-- plugins/artifact/__init__.py | 87 +++++++++---------------- plugins/artifact/protoagent.plugin.yaml | 2 +- tests/test_artifact_plugin.py | 32 ++++----- 5 files changed, 73 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c37e2a2..38014c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gained a `soul` field), so a bundle agent arrives with its persona wired in. ### Fixed +- **Artifact plugin (0.15.0) — pointer lock now works in games/canvas/3D artifacts.** + `requestPointerLock()` from generated code threw *"Pointer lock requires the window to have + focus"*: pointer-lock is a Permissions-Policy feature that must be delegated via `allow=` at + **every** iframe nesting level, and the policy was missing even though the `allow-pointer-lock` + sandbox token was present. The console plugin-view iframe now sends `allow="… ; pointer-lock"` + and the nested artifact iframe carries `allow="pointer-lock"` too. +- **Artifact plugin (0.15.0) — SVG / Mermaid now render crisply instead of pixelating on zoom.** + The graphic viewport CSS-transform-scaled a `will-change` raster layer, so WKWebView (the desktop + app's Safari engine) rasterized the SVG at 1× then GPU-scaled the bitmap → blurry on zoom-in. It's + replaced by a **crisp fit-to-window**: the `` scales as a vector to fit the frame (no transform, + no raster layer). Pan/zoom + the zoom buttons are intentionally dropped in favor of a sharp fit. +- **Artifact plugin (0.15.0) — the selected artifact + version now survive a tab switch.** Switching + console tabs unmounts/remounts the plugin-view iframe, reloading the shell fresh; it used to snap + back to the latest artifact. The selection (`{selId, selVer, followNewest}`) is now persisted to + `localStorage`, with a fallback to auto-follow-newest if the pinned artifact was deleted. - **A stale untracked plugin copy no longer shadows a newer bundled one.** The loader let the live/installed plugins dir override the bundled tree *unconditionally*, so a plugin that was once git-installed and later bundled in-tree (e.g. the artifact plugin @ 0.11.3 vs the bundled diff --git a/apps/web/src/app/PluginView.tsx b/apps/web/src/app/PluginView.tsx index b04da5c1..7e044438 100644 --- a/apps/web/src/app/PluginView.tsx +++ b/apps/web/src/app/PluginView.tsx @@ -257,18 +257,20 @@ export function PluginView({ view }: { view: PluginViewType }) { {reachable ? ( // sandbox: allow-popups (+ -to-escape-sandbox) so links / window.open inside // a plugin open as normal un-sandboxed pages instead of being blocked. - // allow-pointer-lock so a plugin (or a nested artifact iframe inside it) can - // capture the mouse — needed for games / canvas / 3D; pointer lock must be - // granted at EVERY nesting level, and Esc always releases it. - // allow: clipboard via Permissions-Policy (no sandbox token exists for it) so - // copy/paste works in plugin UIs. + // Pointer lock needs BOTH the allow-pointer-lock sandbox token AND the + // pointer-lock Permissions-Policy in `allow=` — and the policy has to be + // delegated at EVERY nesting level, so the nested artifact iframe (which sets + // its own allow="pointer-lock") can capture the mouse for games / canvas / 3D. + // Esc always releases it. + // allow: clipboard + pointer-lock via Permissions-Policy (no sandbox token + // exists for clipboard) so copy/paste + pointer capture work in plugin UIs.
No artifact yet. Ask the agent to render one — a chart, diagram, or widget.
- +
@@ -905,53 +905,18 @@ def register(registry) -> None: + '#md blockquote{margin:1em 0;padding-left:1em;border-left:3px solid var(--pl-color-border,rgba(255,255,255,.2));color:var(--pl-color-fg-muted,#9aa0aa)}' + '#md img{max-width:100%}#md .mermaid{background:none;border:0;padding:0}'; - // Pan/zoom viewport for the GRAPHIC kinds (svg + mermaid — #1495). Both render an - // into the sandboxed frame; wrap it in a transform-driven viewport so large diagrams can be - // explored: scroll-wheel / pinch to zoom (cursor-anchored), click-drag to pan, Reset to re-fit. + // Crisp fit-to-window viewport for the GRAPHIC kinds (svg + mermaid — #1517). Both render an + // into the sandboxed frame; scale it as a VECTOR to fit the frame (max-width/height:100%, + // !important to beat mermaid's inline max-width:Npx on its own svg). No CSS transform / raster + // layer: transform-scaling rasterized the SVG at 1x then GPU-scaled the bitmap, so zooming in + // pixelated/blurred on WKWebView. This keeps it sharp at any size (pan/zoom traded for crispness). // Self-contained (needs no DS stylesheet) — styled off the base() token carry. var VP_CSS = ''; - var ZBAR = '
' - + '' - + '
'; - // Transforms #__cv; exposes window.__artFit so an ASYNC renderer (mermaid) can re-fit once its - // exists. Cursor-anchored zoom keeps the point under the pointer fixed; fit() re-centers. - var PANZOOM = '
' + inner + '
' + ZBAR + PANZOOM; } + + '#__vp{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:12px;box-sizing:border-box}' + + '#__vp pre.mermaid{display:contents}' + + '#__vp svg{max-width:100% !important;max-height:100% !important;width:auto !important;height:auto !important;display:block}'; + // Wrap graphic content in the fit-to-window viewport (svg + mermaid share this). + function viewport(inner){ return VP_CSS + '
' + inner + '
'; } function srcdoc(kind, code) { if (kind === "html") return dsLink() + base(kind) + code; @@ -959,9 +924,7 @@ def register(registry) -> None: if (kind === "mermaid") return '' + base(kind) + viewport('
' + esc(code) + '
') + cdn("mermaid") + '