mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-05 02:07:34 +08:00
Compare commits
10 Commits
opencode-p
...
onboarding
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f230b5ad9 | ||
|
|
bdc9b07c9d | ||
|
|
6fdbf2f2d7 | ||
|
|
0a679cb7ad | ||
|
|
41b4d69167 | ||
|
|
3f343cf7cf | ||
|
|
4ae5b58cb1 | ||
|
|
2258a181f0 | ||
|
|
11b2942f16 | ||
|
|
b08cbc7a79 |
@@ -2821,6 +2821,7 @@ def _prompt_model_selection(
|
|||||||
pricing: Optional[Dict[str, Dict[str, str]]] = None,
|
pricing: Optional[Dict[str, Dict[str, str]]] = None,
|
||||||
unavailable_models: Optional[List[str]] = None,
|
unavailable_models: Optional[List[str]] = None,
|
||||||
portal_url: str = "",
|
portal_url: str = "",
|
||||||
|
allow_custom = True
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
|
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
|
||||||
|
|
||||||
@@ -2909,7 +2910,15 @@ def _prompt_model_selection(
|
|||||||
from simple_term_menu import TerminalMenu
|
from simple_term_menu import TerminalMenu
|
||||||
|
|
||||||
choices = [f" {_label(mid)}" for mid in ordered]
|
choices = [f" {_label(mid)}" for mid in ordered]
|
||||||
|
|
||||||
|
custom_idx = None
|
||||||
|
if allow_custom:
|
||||||
|
custom_idx = len(choices)
|
||||||
choices.append(" Enter custom model name")
|
choices.append(" Enter custom model name")
|
||||||
|
|
||||||
|
skip_idx = None
|
||||||
|
if current_model:
|
||||||
|
skip_idx = len(choices)
|
||||||
choices.append(" Skip (keep current)")
|
choices.append(" Skip (keep current)")
|
||||||
|
|
||||||
# Print the unavailable block BEFORE the menu via regular print().
|
# Print the unavailable block BEFORE the menu via regular print().
|
||||||
@@ -2947,21 +2956,29 @@ def _prompt_model_selection(
|
|||||||
print()
|
print()
|
||||||
if idx < len(ordered):
|
if idx < len(ordered):
|
||||||
return ordered[idx]
|
return ordered[idx]
|
||||||
elif idx == len(ordered):
|
if idx == custom_idx:
|
||||||
custom = input("Enter model name: ").strip()
|
custom = input("Enter model name: ").strip()
|
||||||
return custom if custom else None
|
return custom if custom else None
|
||||||
|
if idx == skip_idx:
|
||||||
|
return None
|
||||||
return None
|
return None
|
||||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback: numbered list
|
# Fallback: numbered list
|
||||||
print(menu_title)
|
print(menu_title)
|
||||||
num_width = len(str(len(ordered) + 2))
|
n = len(ordered)
|
||||||
|
extra = []
|
||||||
|
if allow_custom:
|
||||||
|
extra.append("Enter custom model name")
|
||||||
|
if current_model:
|
||||||
|
extra.append("Skip (keep current)")
|
||||||
|
total = n + len(extra)
|
||||||
|
num_width = len(str(total))
|
||||||
for i, mid in enumerate(ordered, 1):
|
for i, mid in enumerate(ordered, 1):
|
||||||
print(f" {i:>{num_width}}. {_label(mid)}")
|
print(f" {i:>{num_width}}. {_label(mid)}")
|
||||||
n = len(ordered)
|
for j, label in enumerate(extra, n + 1):
|
||||||
print(f" {n + 1:>{num_width}}. Enter custom model name")
|
print(f" {j:>{num_width}}. {label}")
|
||||||
print(f" {n + 2:>{num_width}}. Skip (keep current)")
|
|
||||||
|
|
||||||
if _unavailable:
|
if _unavailable:
|
||||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||||
@@ -2973,18 +2990,19 @@ def _prompt_model_selection(
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
choice = input(f"Choice [1-{n + 2}] (default: skip): ").strip()
|
choice = input(f"Choice [1-{total}]: ").strip()
|
||||||
if not choice:
|
if not choice:
|
||||||
return None
|
return None
|
||||||
idx = int(choice)
|
val = int(choice)
|
||||||
if 1 <= idx <= n:
|
if 1 <= val <= n:
|
||||||
return ordered[idx - 1]
|
return ordered[val - 1]
|
||||||
elif idx == n + 1:
|
extra_idx = val - n - 1
|
||||||
|
if 0 <= extra_idx < len(extra):
|
||||||
|
if extra[extra_idx] == "Enter custom model name":
|
||||||
custom = input("Enter model name: ").strip()
|
custom = input("Enter model name: ").strip()
|
||||||
return custom if custom else None
|
return custom if custom else None
|
||||||
elif idx == n + 2:
|
return None # skip
|
||||||
return None
|
print(f"Please enter 1-{total}")
|
||||||
print(f"Please enter 1-{n + 2}")
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("Please enter a number")
|
print("Please enter a number")
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
@@ -3260,7 +3278,6 @@ def _nous_device_code_login(
|
|||||||
open_browser = False
|
open_browser = False
|
||||||
|
|
||||||
print(f"Starting Hermes login via {pconfig.name}...")
|
print(f"Starting Hermes login via {pconfig.name}...")
|
||||||
print(f"Portal: {portal_base_url}")
|
|
||||||
if insecure:
|
if insecure:
|
||||||
print("TLS verification: disabled (--insecure)")
|
print("TLS verification: disabled (--insecure)")
|
||||||
elif ca_bundle:
|
elif ca_bundle:
|
||||||
@@ -3280,19 +3297,18 @@ def _nous_device_code_login(
|
|||||||
interval = int(device_data["interval"])
|
interval = int(device_data["interval"])
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("To continue:")
|
|
||||||
print(f" 1. Open: {verification_url}")
|
|
||||||
print(f" 2. If prompted, enter code: {user_code}")
|
|
||||||
|
|
||||||
if open_browser:
|
if open_browser:
|
||||||
opened = webbrowser.open(verification_url)
|
opened = webbrowser.open(verification_url)
|
||||||
if opened:
|
if opened:
|
||||||
print(" (Opened browser for verification)")
|
print("If you don't see a browser window open, navigate to this URL:")
|
||||||
else:
|
else:
|
||||||
print(" Could not open browser automatically — use the URL above.")
|
print("Navigate to this URL to continue:")
|
||||||
|
print(verification_url)
|
||||||
|
print(f"If you're prompted for a code, use {user_code}")
|
||||||
|
print()
|
||||||
|
|
||||||
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
|
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
|
||||||
print(f"Waiting for approval (polling every {effective_interval}s)...")
|
print(f"Waiting for approval (checking every {effective_interval}s)...")
|
||||||
|
|
||||||
token_data = _poll_for_token(
|
token_data = _poll_for_token(
|
||||||
client=client,
|
client=client,
|
||||||
@@ -3357,7 +3373,7 @@ def _nous_device_code_login(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _login_nous(args, pconfig: ProviderConfig) -> None:
|
def login_nous(args, pconfig: ProviderConfig) -> None:
|
||||||
"""Nous Portal device authorization flow."""
|
"""Nous Portal device authorization flow."""
|
||||||
timeout_seconds = getattr(args, "timeout", None) or 15.0
|
timeout_seconds = getattr(args, "timeout", None) or 15.0
|
||||||
insecure = bool(getattr(args, "insecure", False))
|
insecure = bool(getattr(args, "insecure", False))
|
||||||
@@ -3419,7 +3435,10 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
|||||||
)
|
)
|
||||||
model_ids = _PROVIDER_MODELS.get("nous", [])
|
model_ids = _PROVIDER_MODELS.get("nous", [])
|
||||||
|
|
||||||
|
_portal = auth_state.get("portal_base_url", "")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
unavailable_models: list = []
|
unavailable_models: list = []
|
||||||
if model_ids:
|
if model_ids:
|
||||||
pricing = get_pricing_for_provider("nous")
|
pricing = get_pricing_for_provider("nous")
|
||||||
@@ -3428,14 +3447,17 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
|||||||
model_ids, unavailable_models = partition_nous_models_by_tier(
|
model_ids, unavailable_models = partition_nous_models_by_tier(
|
||||||
model_ids, pricing, free_tier=True,
|
model_ids, pricing, free_tier=True,
|
||||||
)
|
)
|
||||||
_portal = auth_state.get("portal_base_url", "")
|
if not free_tier:
|
||||||
if model_ids:
|
|
||||||
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
|
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
|
||||||
|
if len(model_ids) > 1:
|
||||||
selected_model = _prompt_model_selection(
|
selected_model = _prompt_model_selection(
|
||||||
model_ids, pricing=pricing,
|
model_ids, pricing=pricing,
|
||||||
unavailable_models=unavailable_models,
|
unavailable_models=unavailable_models,
|
||||||
portal_url=_portal,
|
portal_url=_portal,
|
||||||
|
allow_custom=not free_tier
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
selected_model = model_ids[0]
|
||||||
elif unavailable_models:
|
elif unavailable_models:
|
||||||
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||||
print("No free models currently available.")
|
print("No free models currently available.")
|
||||||
|
|||||||
@@ -1085,9 +1085,6 @@ def cmd_chat(args):
|
|||||||
print(
|
print(
|
||||||
"It looks like Hermes isn't configured yet -- no API keys or providers found."
|
"It looks like Hermes isn't configured yet -- no API keys or providers found."
|
||||||
)
|
)
|
||||||
print()
|
|
||||||
print(" Run: hermes setup")
|
|
||||||
print()
|
|
||||||
|
|
||||||
from hermes_cli.setup import (
|
from hermes_cli.setup import (
|
||||||
is_interactive_stdin,
|
is_interactive_stdin,
|
||||||
@@ -1100,16 +1097,8 @@ def cmd_chat(args):
|
|||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
try:
|
|
||||||
reply = input("Run setup now? [Y/n] ").strip().lower()
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
reply = "n"
|
|
||||||
if reply in ("", "y", "yes"):
|
|
||||||
cmd_setup(args)
|
cmd_setup(args)
|
||||||
return
|
return
|
||||||
print()
|
|
||||||
print("You can run 'hermes setup' at any time to configure.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Start update check in background (runs while other init happens)
|
# Start update check in background (runs while other init happens)
|
||||||
try:
|
try:
|
||||||
@@ -2135,7 +2124,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||||||
resolve_nous_runtime_credentials,
|
resolve_nous_runtime_credentials,
|
||||||
AuthError,
|
AuthError,
|
||||||
format_auth_error,
|
format_auth_error,
|
||||||
_login_nous,
|
login_nous,
|
||||||
PROVIDER_REGISTRY,
|
PROVIDER_REGISTRY,
|
||||||
)
|
)
|
||||||
from hermes_cli.config import (
|
from hermes_cli.config import (
|
||||||
@@ -2148,8 +2137,6 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||||||
|
|
||||||
state = get_provider_auth_state("nous")
|
state = get_provider_auth_state("nous")
|
||||||
if not state or not state.get("access_token"):
|
if not state or not state.get("access_token"):
|
||||||
print("Not logged into Nous Portal. Starting login...")
|
|
||||||
print()
|
|
||||||
try:
|
try:
|
||||||
mock_args = argparse.Namespace(
|
mock_args = argparse.Namespace(
|
||||||
portal_url=getattr(args, "portal_url", None),
|
portal_url=getattr(args, "portal_url", None),
|
||||||
@@ -2161,7 +2148,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||||||
ca_bundle=getattr(args, "ca_bundle", None),
|
ca_bundle=getattr(args, "ca_bundle", None),
|
||||||
insecure=bool(getattr(args, "insecure", False)),
|
insecure=bool(getattr(args, "insecure", False)),
|
||||||
)
|
)
|
||||||
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
||||||
# Offer Tool Gateway enablement for paid subscribers
|
# Offer Tool Gateway enablement for paid subscribers
|
||||||
try:
|
try:
|
||||||
_refreshed = load_config() or {}
|
_refreshed = load_config() or {}
|
||||||
@@ -2212,7 +2199,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||||||
ca_bundle=None,
|
ca_bundle=None,
|
||||||
insecure=False,
|
insecure=False,
|
||||||
)
|
)
|
||||||
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
||||||
except Exception as login_exc:
|
except Exception as login_exc:
|
||||||
print(f"Re-login failed: {login_exc}")
|
print(f"Re-login failed: {login_exc}")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import copy
|
import copy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Literal, Optional, Dict, Any
|
||||||
|
|
||||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||||
|
from hermes_cli.main import _model_flow_nous
|
||||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
from utils import base_url_hostname
|
from utils import base_url_hostname
|
||||||
from hermes_constants import get_optional_skills_dir
|
from hermes_constants import get_optional_skills_dir
|
||||||
@@ -655,7 +656,7 @@ def _prompt_container_resources(config: dict):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def setup_model_provider(config: dict, *, quick: bool = False):
|
def setup_model_provider(config: dict, *, quick: bool | Literal["nous_portal"] = False):
|
||||||
"""Configure the inference provider and default model.
|
"""Configure the inference provider and default model.
|
||||||
|
|
||||||
Delegates to ``cmd_model()`` (the same flow used by ``hermes model``)
|
Delegates to ``cmd_model()`` (the same flow used by ``hermes model``)
|
||||||
@@ -677,6 +678,10 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
|||||||
# credential prompting, model selection, and config persistence.
|
# credential prompting, model selection, and config persistence.
|
||||||
from hermes_cli.main import select_provider_and_model
|
from hermes_cli.main import select_provider_and_model
|
||||||
try:
|
try:
|
||||||
|
if quick == "nous_portal":
|
||||||
|
config = load_config()
|
||||||
|
_model_flow_nous(config)
|
||||||
|
else:
|
||||||
select_provider_and_model()
|
select_provider_and_model()
|
||||||
except (SystemExit, KeyboardInterrupt):
|
except (SystemExit, KeyboardInterrupt):
|
||||||
print()
|
print()
|
||||||
@@ -3030,11 +3035,15 @@ def run_setup_wizard(args):
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
setup_mode = prompt_choice("How would you like to set up Hermes?", [
|
setup_mode = prompt_choice("How would you like to set up Hermes?", [
|
||||||
"Quick setup — provider, model & messaging (recommended)",
|
"Nous Account setup — model & messaging (recommended)",
|
||||||
|
"Quick setup — provider, model & messaging",
|
||||||
"Full setup — configure everything",
|
"Full setup — configure everything",
|
||||||
], 0)
|
], 0)
|
||||||
|
|
||||||
if setup_mode == 0:
|
if setup_mode == 0:
|
||||||
|
_run_first_time_quick_setup(config, hermes_home, is_existing, nous_quick=True)
|
||||||
|
return
|
||||||
|
if setup_mode == 1:
|
||||||
_run_first_time_quick_setup(config, hermes_home, is_existing)
|
_run_first_time_quick_setup(config, hermes_home, is_existing)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -3095,7 +3104,7 @@ def _resolve_hermes_chat_argv() -> Optional[list[str]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _offer_launch_chat():
|
def _offer_launch_chat(auto_launch = False):
|
||||||
"""Prompt the user to jump straight into chat after setup."""
|
"""Prompt the user to jump straight into chat after setup."""
|
||||||
print()
|
print()
|
||||||
if not prompt_yes_no("Launch hermes chat now?", True):
|
if not prompt_yes_no("Launch hermes chat now?", True):
|
||||||
@@ -3109,7 +3118,7 @@ def _offer_launch_chat():
|
|||||||
os.execvp(chat_argv[0], chat_argv)
|
os.execvp(chat_argv[0], chat_argv)
|
||||||
|
|
||||||
|
|
||||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool, nous_quick=False):
|
||||||
"""Streamlined first-time setup: provider + model only.
|
"""Streamlined first-time setup: provider + model only.
|
||||||
|
|
||||||
Applies sensible defaults for TTS (Edge), terminal (local), agent
|
Applies sensible defaults for TTS (Edge), terminal (local), agent
|
||||||
@@ -3117,7 +3126,7 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
|||||||
``hermes setup <section>``.
|
``hermes setup <section>``.
|
||||||
"""
|
"""
|
||||||
# Step 1: Model & Provider (essential — skips rotation/vision/TTS)
|
# Step 1: Model & Provider (essential — skips rotation/vision/TTS)
|
||||||
setup_model_provider(config, quick=True)
|
setup_model_provider(config, quick="nous_portal" if nous_quick else True )
|
||||||
|
|
||||||
# Step 2: Apply defaults for everything else
|
# Step 2: Apply defaults for everything else
|
||||||
_apply_default_agent_settings(config)
|
_apply_default_agent_settings(config)
|
||||||
@@ -3150,7 +3159,9 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
|||||||
|
|
||||||
_print_setup_summary(config, hermes_home)
|
_print_setup_summary(config, hermes_home)
|
||||||
|
|
||||||
_offer_launch_chat()
|
# if the user hasn't set up the gateway, assume they want to launch chat.
|
||||||
|
force_launch_chat = gateway_choice == 0
|
||||||
|
_offer_launch_chat(force_launch_chat)
|
||||||
|
|
||||||
|
|
||||||
def _run_quick_setup(config: dict, hermes_home):
|
def _run_quick_setup(config: dict, hermes_home):
|
||||||
|
|||||||
@@ -571,7 +571,7 @@ def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
|
|||||||
captured["ca_bundle"] = login_args.ca_bundle
|
captured["ca_bundle"] = login_args.ca_bundle
|
||||||
captured["insecure"] = login_args.insecure
|
captured["insecure"] = login_args.insecure
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login)
|
monkeypatch.setattr("hermes_cli.auth.login_nous", _fake_login)
|
||||||
|
|
||||||
hermes_main.cmd_model(
|
hermes_main.cmd_model(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
"""Regression tests for the TUI gateway's `complete.path` handler.
|
"""Regression tests for the TUI gateway's `complete.path` handler.
|
||||||
|
|
||||||
Reported during the TUI v2 blitz retest: typing `@folder:` (and `@folder`
|
Reported during the TUI v2 blitz retest:
|
||||||
with no colon yet) still surfaced files alongside directories in the
|
- typing `@folder:` (and `@folder` with no colon yet) surfaced files
|
||||||
TUI composer, because the gateway-side completion lives in
|
alongside directories — the gateway-side completion lives in
|
||||||
`tui_gateway/server.py` and was never touched by the earlier fix to
|
`tui_gateway/server.py` and was never touched by the earlier fix to
|
||||||
`hermes_cli/commands.py`.
|
`hermes_cli/commands.py`.
|
||||||
|
- typing `@appChrome` required the full `@ui-tui/src/components/app…`
|
||||||
|
path to find the file — users expect Cmd-P-style fuzzy basename
|
||||||
|
matching across the repo, not a strict directory prefix filter.
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
- `@folder:` only yields directories
|
- `@folder:` only yields directories
|
||||||
- `@file:` only yields regular files
|
- `@file:` only yields regular files
|
||||||
- Bare `@folder` / `@file` (no colon) lists cwd directly
|
- Bare `@folder` / `@file` (no colon) lists cwd directly
|
||||||
- Explicit prefix is preserved in the completion text
|
- Explicit prefix is preserved in the completion text
|
||||||
|
- `@<name>` with no slash fuzzy-matches basenames anywhere in the tree
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from tui_gateway import server
|
from tui_gateway import server
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +39,15 @@ def _items(word: str):
|
|||||||
return [(it["text"], it["display"], it.get("meta", "")) for it in resp["result"]["items"]]
|
return [(it["text"], it["display"], it.get("meta", "")) for it in resp["result"]["items"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_fuzzy_cache(monkeypatch):
|
||||||
|
# Each test walks a fresh tmp dir; clear the cached listing so prior
|
||||||
|
# roots can't leak through the TTL window.
|
||||||
|
server._fuzzy_cache.clear()
|
||||||
|
yield
|
||||||
|
server._fuzzy_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_at_folder_colon_only_dirs(tmp_path, monkeypatch):
|
def test_at_folder_colon_only_dirs(tmp_path, monkeypatch):
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
_fixture(tmp_path)
|
_fixture(tmp_path)
|
||||||
@@ -89,3 +104,176 @@ def test_bare_at_still_shows_static_refs(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
for expected in ("@diff", "@staged", "@file:", "@folder:", "@url:", "@git:"):
|
for expected in ("@diff", "@staged", "@file:", "@folder:", "@url:", "@git:"):
|
||||||
assert expected in texts, f"missing static ref {expected!r} in {texts!r}"
|
assert expected in texts, f"missing static ref {expected!r} in {texts!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fuzzy basename matching ──────────────────────────────────────────────
|
||||||
|
# Users shouldn't have to know the full path — typing `@appChrome` should
|
||||||
|
# find `ui-tui/src/components/appChrome.tsx`.
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_fixture(tmp_path: Path):
|
||||||
|
(tmp_path / "readme.md").write_text("x")
|
||||||
|
(tmp_path / ".env").write_text("x")
|
||||||
|
(tmp_path / "ui-tui/src/components").mkdir(parents=True)
|
||||||
|
(tmp_path / "ui-tui/src/components/appChrome.tsx").write_text("x")
|
||||||
|
(tmp_path / "ui-tui/src/components/appLayout.tsx").write_text("x")
|
||||||
|
(tmp_path / "ui-tui/src/components/thinking.tsx").write_text("x")
|
||||||
|
(tmp_path / "ui-tui/src/hooks").mkdir(parents=True)
|
||||||
|
(tmp_path / "ui-tui/src/hooks/useCompletion.ts").write_text("x")
|
||||||
|
(tmp_path / "tui_gateway").mkdir()
|
||||||
|
(tmp_path / "tui_gateway/server.py").write_text("x")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_at_finds_file_without_directory_prefix(tmp_path, monkeypatch):
|
||||||
|
"""`@appChrome` — with no slash — should surface the nested file."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_nested_fixture(tmp_path)
|
||||||
|
|
||||||
|
entries = _items("@appChrome")
|
||||||
|
texts = [t for t, _, _ in entries]
|
||||||
|
|
||||||
|
assert "@file:ui-tui/src/components/appChrome.tsx" in texts, texts
|
||||||
|
|
||||||
|
# Display is the basename, meta is the containing directory, so the
|
||||||
|
# picker can show `appChrome.tsx ui-tui/src/components` on one row.
|
||||||
|
row = next(r for r in entries if r[0] == "@file:ui-tui/src/components/appChrome.tsx")
|
||||||
|
assert row[1] == "appChrome.tsx"
|
||||||
|
assert row[2] == "ui-tui/src/components"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_ranks_exact_before_prefix_before_subseq(tmp_path, monkeypatch):
|
||||||
|
"""Better matches sort before weaker matches regardless of path depth."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_nested_fixture(tmp_path)
|
||||||
|
(tmp_path / "server.py").write_text("x") # exact basename match at root
|
||||||
|
|
||||||
|
texts = [t for t, _, _ in _items("@server")]
|
||||||
|
|
||||||
|
# Exact `server.py` beats `tui_gateway/server.py` (prefix match) — both
|
||||||
|
# rank 1 on basename but exact basename wins on the sort key; shorter
|
||||||
|
# rel path breaks ties.
|
||||||
|
assert texts[0] == "@file:server.py", texts
|
||||||
|
assert "@file:tui_gateway/server.py" in texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_camelcase_word_boundary(tmp_path, monkeypatch):
|
||||||
|
"""Mid-basename camelCase pieces match without substring scanning."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_nested_fixture(tmp_path)
|
||||||
|
|
||||||
|
texts = [t for t, _, _ in _items("@Chrome")]
|
||||||
|
|
||||||
|
# `Chrome` starts a camelCase word inside `appChrome.tsx`.
|
||||||
|
assert "@file:ui-tui/src/components/appChrome.tsx" in texts, texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_subsequence_catches_sparse_queries(tmp_path, monkeypatch):
|
||||||
|
"""`@uCo` → `useCompletion.ts` via subsequence, last-resort tier."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_nested_fixture(tmp_path)
|
||||||
|
|
||||||
|
texts = [t for t, _, _ in _items("@uCo")]
|
||||||
|
|
||||||
|
assert "@file:ui-tui/src/hooks/useCompletion.ts" in texts, texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_at_file_prefix_preserved(tmp_path, monkeypatch):
|
||||||
|
"""Explicit `@file:` prefix still wins the completion tag."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_nested_fixture(tmp_path)
|
||||||
|
|
||||||
|
texts = [t for t, _, _ in _items("@file:appChrome")]
|
||||||
|
|
||||||
|
assert "@file:ui-tui/src/components/appChrome.tsx" in texts, texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_skipped_when_path_has_slash(tmp_path, monkeypatch):
|
||||||
|
"""Any `/` in the query = user is navigating; keep directory listing."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_nested_fixture(tmp_path)
|
||||||
|
|
||||||
|
texts = [t for t, _, _ in _items("@ui-tui/src/components/app")]
|
||||||
|
|
||||||
|
# Directory-listing mode prefixes with `@file:` / `@folder:` per entry.
|
||||||
|
# It should only surface direct children of the named dir — not the
|
||||||
|
# nested `useCompletion.ts`.
|
||||||
|
assert any("appChrome.tsx" in t for t in texts), texts
|
||||||
|
assert not any("useCompletion.ts" in t for t in texts), texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_skipped_when_folder_tag(tmp_path, monkeypatch):
|
||||||
|
"""`@folder:<name>` still lists directories — fuzzy scanner only walks
|
||||||
|
files (git-tracked + untracked), so defer to the dir-listing path."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_nested_fixture(tmp_path)
|
||||||
|
|
||||||
|
texts = [t for t, _, _ in _items("@folder:ui")]
|
||||||
|
|
||||||
|
# Root has `ui-tui/` as a directory; the listing branch should surface it.
|
||||||
|
assert any(t.startswith("@folder:ui-tui") for t in texts), texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_hides_dotfiles_unless_asked(tmp_path, monkeypatch):
|
||||||
|
"""`.env` doesn't leak into `@env` but does show for `@.env`."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_nested_fixture(tmp_path)
|
||||||
|
|
||||||
|
assert not any(".env" in t for t, _, _ in _items("@env"))
|
||||||
|
assert any(t.endswith(".env") for t, _, _ in _items("@.env"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_caps_results(tmp_path, monkeypatch):
|
||||||
|
"""The 30-item cap survives a big tree."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
for i in range(60):
|
||||||
|
(tmp_path / f"mod_{i:03d}.py").write_text("x")
|
||||||
|
|
||||||
|
items = _items("@mod")
|
||||||
|
|
||||||
|
assert len(items) == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_paths_relative_to_cwd_inside_subdir(tmp_path, monkeypatch):
|
||||||
|
"""When the gateway runs from a subdirectory of a git repo, fuzzy
|
||||||
|
completion paths must resolve under that cwd — not under the repo root.
|
||||||
|
|
||||||
|
Without this, `@appChrome` from inside `apps/web/` would suggest
|
||||||
|
`@file:apps/web/src/foo.tsx` but the agent (resolving from cwd) would
|
||||||
|
look for `apps/web/apps/web/src/foo.tsx` and fail. We translate every
|
||||||
|
`git ls-files` result back to a `relpath(root)` and drop anything
|
||||||
|
outside `root` so the completion contract stays "paths are cwd-relative".
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, check=True)
|
||||||
|
|
||||||
|
(tmp_path / "apps" / "web" / "src").mkdir(parents=True)
|
||||||
|
(tmp_path / "apps" / "web" / "src" / "appChrome.tsx").write_text("x")
|
||||||
|
(tmp_path / "apps" / "api" / "src").mkdir(parents=True)
|
||||||
|
(tmp_path / "apps" / "api" / "src" / "server.ts").write_text("x")
|
||||||
|
(tmp_path / "README.md").write_text("x")
|
||||||
|
|
||||||
|
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
|
||||||
|
|
||||||
|
# Run from `apps/web/` — completions should be relative to here, and
|
||||||
|
# files outside this subtree (apps/api, README.md at root) shouldn't
|
||||||
|
# appear at all.
|
||||||
|
monkeypatch.chdir(tmp_path / "apps" / "web")
|
||||||
|
|
||||||
|
texts = [t for t, _, _ in _items("@appChrome")]
|
||||||
|
|
||||||
|
assert "@file:src/appChrome.tsx" in texts, texts
|
||||||
|
assert not any("apps/web/" in t for t in texts), texts
|
||||||
|
|
||||||
|
server._fuzzy_cache.clear()
|
||||||
|
other_texts = [t for t, _, _ in _items("@server")]
|
||||||
|
|
||||||
|
assert not any("server.ts" in t for t in other_texts), other_texts
|
||||||
|
|
||||||
|
server._fuzzy_cache.clear()
|
||||||
|
readme_texts = [t for t, _, _ in _items("@README")]
|
||||||
|
|
||||||
|
assert not any("README.md" in t for t in readme_texts), readme_texts
|
||||||
|
|||||||
@@ -3256,6 +3256,162 @@ def _(rid, params: dict) -> dict:
|
|||||||
|
|
||||||
# ── Methods: complete ─────────────────────────────────────────────────
|
# ── Methods: complete ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_FUZZY_CACHE_TTL_S = 5.0
|
||||||
|
_FUZZY_CACHE_MAX_FILES = 20000
|
||||||
|
_FUZZY_FALLBACK_EXCLUDES = frozenset(
|
||||||
|
{
|
||||||
|
".git",
|
||||||
|
".hg",
|
||||||
|
".svn",
|
||||||
|
".next",
|
||||||
|
".cache",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"target",
|
||||||
|
".mypy_cache",
|
||||||
|
".pytest_cache",
|
||||||
|
".ruff_cache",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_fuzzy_cache_lock = threading.Lock()
|
||||||
|
_fuzzy_cache: dict[str, tuple[float, list[str]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_repo_files(root: str) -> list[str]:
|
||||||
|
"""Return file paths relative to ``root``.
|
||||||
|
|
||||||
|
Uses ``git ls-files`` from the repo top (resolved via
|
||||||
|
``rev-parse --show-toplevel``) so the listing covers tracked + untracked
|
||||||
|
files anywhere in the repo, then converts each path back to be relative
|
||||||
|
to ``root``. Files outside ``root`` (parent directories of cwd, sibling
|
||||||
|
subtrees) are excluded so the picker stays scoped to what's reachable
|
||||||
|
from the gateway's cwd. Falls back to a bounded ``os.walk(root)`` when
|
||||||
|
``root`` isn't inside a git repo. Result cached per-root for
|
||||||
|
``_FUZZY_CACHE_TTL_S`` so rapid keystrokes don't respawn git processes.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
with _fuzzy_cache_lock:
|
||||||
|
cached = _fuzzy_cache.get(root)
|
||||||
|
if cached and now - cached[0] < _FUZZY_CACHE_TTL_S:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
files: list[str] = []
|
||||||
|
try:
|
||||||
|
top_result = subprocess.run(
|
||||||
|
["git", "-C", root, "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=2.0,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if top_result.returncode == 0:
|
||||||
|
top = top_result.stdout.decode("utf-8", "replace").strip()
|
||||||
|
list_result = subprocess.run(
|
||||||
|
["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=2.0,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if list_result.returncode == 0:
|
||||||
|
for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/")
|
||||||
|
# Skip parents/siblings of cwd — keep the picker scoped
|
||||||
|
# to root-and-below, matching Cmd-P workspace semantics.
|
||||||
|
if rel.startswith("../"):
|
||||||
|
continue
|
||||||
|
files.append(rel)
|
||||||
|
if len(files) >= _FUZZY_CACHE_MAX_FILES:
|
||||||
|
break
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
# Fallback walk: skip vendor/build dirs + dot-dirs so the walk stays
|
||||||
|
# tractable. Dotfiles themselves survive — the ranker decides based
|
||||||
|
# on whether the query starts with `.`.
|
||||||
|
try:
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||||
|
dirnames[:] = [
|
||||||
|
d
|
||||||
|
for d in dirnames
|
||||||
|
if d not in _FUZZY_FALLBACK_EXCLUDES and not d.startswith(".")
|
||||||
|
]
|
||||||
|
rel_dir = os.path.relpath(dirpath, root)
|
||||||
|
for f in filenames:
|
||||||
|
rel = f if rel_dir == "." else f"{rel_dir}/{f}"
|
||||||
|
files.append(rel.replace(os.sep, "/"))
|
||||||
|
if len(files) >= _FUZZY_CACHE_MAX_FILES:
|
||||||
|
break
|
||||||
|
if len(files) >= _FUZZY_CACHE_MAX_FILES:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with _fuzzy_cache_lock:
|
||||||
|
_fuzzy_cache[root] = (now, files)
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _fuzzy_basename_rank(name: str, query: str) -> tuple[int, int] | None:
|
||||||
|
"""Rank ``name`` against ``query``; lower is better. Returns None to reject.
|
||||||
|
|
||||||
|
Tiers (kind):
|
||||||
|
0 — exact basename
|
||||||
|
1 — basename prefix (e.g. `app` → `appChrome.tsx`)
|
||||||
|
2 — word-boundary / camelCase hit (e.g. `chrome` → `appChrome.tsx`)
|
||||||
|
3 — substring anywhere in basename
|
||||||
|
4 — subsequence match (every query char appears in order)
|
||||||
|
|
||||||
|
Secondary key is `len(name)` so shorter names win ties.
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
return (3, len(name))
|
||||||
|
|
||||||
|
nl = name.lower()
|
||||||
|
ql = query.lower()
|
||||||
|
|
||||||
|
if nl == ql:
|
||||||
|
return (0, len(name))
|
||||||
|
|
||||||
|
if nl.startswith(ql):
|
||||||
|
return (1, len(name))
|
||||||
|
|
||||||
|
# Word-boundary split: `foo-bar_baz.qux` → ["foo","bar","baz","qux"].
|
||||||
|
# camelCase split: `appChrome` → ["app","Chrome"]. Cheap approximation;
|
||||||
|
# falls through to substring/subsequence if it misses.
|
||||||
|
parts: list[str] = []
|
||||||
|
buf = ""
|
||||||
|
for ch in name:
|
||||||
|
if ch in "-_." or (ch.isupper() and buf and not buf[-1].isupper()):
|
||||||
|
if buf:
|
||||||
|
parts.append(buf)
|
||||||
|
buf = ch if ch not in "-_." else ""
|
||||||
|
else:
|
||||||
|
buf += ch
|
||||||
|
if buf:
|
||||||
|
parts.append(buf)
|
||||||
|
for p in parts:
|
||||||
|
if p.lower().startswith(ql):
|
||||||
|
return (2, len(name))
|
||||||
|
|
||||||
|
if ql in nl:
|
||||||
|
return (3, len(name))
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
for ch in nl:
|
||||||
|
if ch == ql[i]:
|
||||||
|
i += 1
|
||||||
|
if i == len(ql):
|
||||||
|
return (4, len(name))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@method("complete.path")
|
@method("complete.path")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
@@ -3291,6 +3447,42 @@ def _(rid, params: dict) -> dict:
|
|||||||
prefix_tag = ""
|
prefix_tag = ""
|
||||||
path_part = query if is_context else query
|
path_part = query if is_context else query
|
||||||
|
|
||||||
|
# Fuzzy basename search across the repo when the user types a bare
|
||||||
|
# name with no path separator — `@appChrome` surfaces every file
|
||||||
|
# whose basename matches, regardless of directory depth. Matches what
|
||||||
|
# editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with
|
||||||
|
# `/`, `./`, `~/`, `/abs`) fall through to the directory-listing
|
||||||
|
# path so explicit navigation intent is preserved.
|
||||||
|
if (
|
||||||
|
is_context
|
||||||
|
and path_part
|
||||||
|
and "/" not in path_part
|
||||||
|
and prefix_tag != "folder"
|
||||||
|
):
|
||||||
|
root = os.getcwd()
|
||||||
|
ranked: list[tuple[tuple[int, int], str, str]] = []
|
||||||
|
for rel in _list_repo_files(root):
|
||||||
|
basename = os.path.basename(rel)
|
||||||
|
if basename.startswith(".") and not path_part.startswith("."):
|
||||||
|
continue
|
||||||
|
rank = _fuzzy_basename_rank(basename, path_part)
|
||||||
|
if rank is None:
|
||||||
|
continue
|
||||||
|
ranked.append((rank, rel, basename))
|
||||||
|
|
||||||
|
ranked.sort(key=lambda r: (r[0], len(r[1]), r[1]))
|
||||||
|
tag = prefix_tag or "file"
|
||||||
|
for _, rel, basename in ranked[:30]:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"text": f"@{tag}:{rel}",
|
||||||
|
"display": basename,
|
||||||
|
"meta": os.path.dirname(rel),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return _ok(rid, {"items": items})
|
||||||
|
|
||||||
expanded = _normalize_completion_path(path_part) if path_part else "."
|
expanded = _normalize_completion_path(path_part) if path_part else "."
|
||||||
if expanded == "." or not expanded:
|
if expanded == "." or not expanded:
|
||||||
search_dir, match = ".", ""
|
search_dir, match = ".", ""
|
||||||
|
|||||||
@@ -152,91 +152,79 @@ describe('createGatewayEventHandler', () => {
|
|||||||
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
|
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('attaches inline_diff to the assistant completion body', () => {
|
it('anchors inline_diff as its own segment where the edit happened', () => {
|
||||||
const appended: Msg[] = []
|
const appended: Msg[] = []
|
||||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||||
const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new'
|
const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new'
|
||||||
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||||
|
const block = `\`\`\`diff\n${cleaned}\n\`\`\``
|
||||||
|
|
||||||
onEvent({
|
// Narration → tool → tool-complete → more narration → message-complete.
|
||||||
payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' },
|
// The diff MUST land between the two narration segments, not tacked
|
||||||
type: 'tool.start'
|
// onto the final one.
|
||||||
} as any)
|
onEvent({ payload: { text: 'Editing the file' }, type: 'message.delta' } as any)
|
||||||
onEvent({
|
onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any)
|
||||||
payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' },
|
onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
|
||||||
type: 'tool.complete'
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
// Diff is buffered for message.complete and sanitized (ANSI stripped).
|
// Diff is already committed to segmentMessages as its own segment.
|
||||||
expect(appended).toHaveLength(0)
|
expect(appended).toHaveLength(0)
|
||||||
expect(turnController.pendingInlineDiffs).toEqual([cleaned])
|
expect(turnController.segmentMessages).toEqual([
|
||||||
|
{ role: 'assistant', text: 'Editing the file' },
|
||||||
|
{ kind: 'diff', role: 'assistant', text: block }
|
||||||
|
])
|
||||||
|
|
||||||
onEvent({
|
onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)
|
||||||
payload: { text: 'patch applied' },
|
|
||||||
type: 'message.complete'
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
// Diff is rendered in the same assistant message body as the completion.
|
// Three transcript messages: pre-tool narration → diff (kind='diff',
|
||||||
expect(appended).toHaveLength(1)
|
// so MessageLine gives it blank-line breathing room) → post-tool
|
||||||
expect(appended[0]).toMatchObject({ role: 'assistant' })
|
// narration. The final message does NOT contain a diff.
|
||||||
expect(appended[0]?.text).toContain('patch applied')
|
expect(appended).toHaveLength(3)
|
||||||
expect(appended[0]?.text).toContain('```diff')
|
expect(appended[0]?.text).toBe('Editing the file')
|
||||||
expect(appended[0]?.text).toContain(cleaned)
|
expect(appended[1]).toMatchObject({ kind: 'diff', text: block })
|
||||||
|
expect(appended[2]?.text).toBe('patch applied')
|
||||||
|
expect(appended[2]?.text).not.toContain('```diff')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not append inline_diff twice when assistant text already contains it', () => {
|
it('drops the diff segment when the final assistant text narrates the same diff', () => {
|
||||||
const appended: Msg[] = []
|
const appended: Msg[] = []
|
||||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||||
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||||
const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\``
|
const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\``
|
||||||
|
|
||||||
onEvent({
|
onEvent({ payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
|
||||||
payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' },
|
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
|
||||||
type: 'tool.complete'
|
|
||||||
} as any)
|
|
||||||
onEvent({
|
|
||||||
payload: { text: assistantText },
|
|
||||||
type: 'message.complete'
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
|
// Only the final message — diff-only segment dropped so we don't
|
||||||
|
// render two stacked copies of the same patch.
|
||||||
expect(appended).toHaveLength(1)
|
expect(appended).toHaveLength(1)
|
||||||
expect(appended[0]?.text).toBe(assistantText)
|
expect(appended[0]?.text).toBe(assistantText)
|
||||||
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
|
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('strips the CLI "┊ review diff" header from queued inline diffs', () => {
|
it('strips the CLI "┊ review diff" header from inline diff segments', () => {
|
||||||
const appended: Msg[] = []
|
const appended: Msg[] = []
|
||||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||||
const raw = ' \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
const raw = ' \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||||
|
|
||||||
onEvent({
|
onEvent({ payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
|
||||||
payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' },
|
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
|
||||||
type: 'tool.complete'
|
|
||||||
} as any)
|
|
||||||
onEvent({
|
|
||||||
payload: { text: 'done' },
|
|
||||||
type: 'message.complete'
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
expect(appended).toHaveLength(1)
|
// diff segment first (kind='diff'), final narration second
|
||||||
|
expect(appended).toHaveLength(2)
|
||||||
|
expect(appended[0]?.kind).toBe('diff')
|
||||||
expect(appended[0]?.text).not.toContain('┊ review diff')
|
expect(appended[0]?.text).not.toContain('┊ review diff')
|
||||||
expect(appended[0]?.text).toContain('--- a/foo.ts')
|
expect(appended[0]?.text).toContain('--- a/foo.ts')
|
||||||
|
expect(appended[1]?.text).toBe('done')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('suppresses inline_diff when assistant already wrote a diff fence', () => {
|
it('drops the diff segment when assistant writes its own ```diff fence', () => {
|
||||||
const appended: Msg[] = []
|
const appended: Msg[] = []
|
||||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||||
const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||||
const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```'
|
const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```'
|
||||||
|
|
||||||
onEvent({
|
onEvent({ payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
|
||||||
payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' },
|
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
|
||||||
type: 'tool.complete'
|
|
||||||
} as any)
|
|
||||||
onEvent({
|
|
||||||
payload: { text: assistantText },
|
|
||||||
type: 'message.complete'
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
expect(appended).toHaveLength(1)
|
expect(appended).toHaveLength(1)
|
||||||
expect(appended[0]?.text).toBe(assistantText)
|
expect(appended[0]?.text).toBe(assistantText)
|
||||||
@@ -252,15 +240,18 @@ describe('createGatewayEventHandler', () => {
|
|||||||
payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' },
|
payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' },
|
||||||
type: 'tool.complete'
|
type: 'tool.complete'
|
||||||
} as any)
|
} as any)
|
||||||
onEvent({
|
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
|
||||||
payload: { text: 'done' },
|
|
||||||
type: 'message.complete'
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
expect(appended).toHaveLength(1)
|
// Two segments: the diff block (kind='diff', no tool row) and the final
|
||||||
expect(appended[0]?.tools?.[0]).toContain('Review Diff')
|
// narration (tool row belongs here since pendingSegmentTools carries
|
||||||
expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts')
|
// across the flushStreamingSegment call).
|
||||||
|
expect(appended).toHaveLength(2)
|
||||||
|
expect(appended[0]?.kind).toBe('diff')
|
||||||
expect(appended[0]?.text).toContain('```diff')
|
expect(appended[0]?.text).toContain('```diff')
|
||||||
|
expect(appended[0]?.tools ?? []).toEqual([])
|
||||||
|
expect(appended[1]?.text).toBe('done')
|
||||||
|
expect(appended[1]?.tools?.[0]).toContain('Review Diff')
|
||||||
|
expect(appended[1]?.tools?.[0]).not.toContain('--- a/foo.ts')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows setup panel for missing provider startup error', () => {
|
it('shows setup panel for missing provider startup error', () => {
|
||||||
|
|||||||
@@ -385,10 +385,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep inline diffs attached to the assistant completion body so
|
// Anchor the diff to where the edit happened in the turn — between
|
||||||
// they render in the same message flow, not as a standalone system
|
// the narration that preceded the tool call and whatever the agent
|
||||||
// artifact that can look out-of-place around tool rows.
|
// streams afterwards. The previous end-merge put the diff at the
|
||||||
turnController.queueInlineDiff(inlineDiffText)
|
// bottom of the final message even when the edit fired mid-turn,
|
||||||
|
// which read as "the agent wrote this after saying that".
|
||||||
|
turnController.pushInlineDiffSegment(inlineDiffText)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ const INTERRUPT_COOLDOWN_MS = 1500
|
|||||||
const ACTIVITY_LIMIT = 8
|
const ACTIVITY_LIMIT = 8
|
||||||
const TRAIL_LIMIT = 8
|
const TRAIL_LIMIT = 8
|
||||||
|
|
||||||
|
// Extracts the raw patch from a diff-only segment produced by
|
||||||
|
// pushInlineDiffSegment. Used at message.complete to dedupe against final
|
||||||
|
// assistant text that narrates the same patch. Returns null for anything
|
||||||
|
// else so real assistant narration never gets touched.
|
||||||
|
const diffSegmentBody = (msg: Msg): null | string => {
|
||||||
|
if (msg.kind !== 'diff') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = msg.text.match(/^```diff\n([\s\S]*?)\n```$/)
|
||||||
|
|
||||||
|
return m ? m[1]! : null
|
||||||
|
}
|
||||||
|
|
||||||
export interface InterruptDeps {
|
export interface InterruptDeps {
|
||||||
appendMessage: (msg: Msg) => void
|
appendMessage: (msg: Msg) => void
|
||||||
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
|
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
|
||||||
@@ -40,7 +54,6 @@ class TurnController {
|
|||||||
bufRef = ''
|
bufRef = ''
|
||||||
interrupted = false
|
interrupted = false
|
||||||
lastStatusNote = ''
|
lastStatusNote = ''
|
||||||
pendingInlineDiffs: string[] = []
|
|
||||||
persistedToolLabels = new Set<string>()
|
persistedToolLabels = new Set<string>()
|
||||||
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
|
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
|
||||||
protocolWarned = false
|
protocolWarned = false
|
||||||
@@ -79,7 +92,6 @@ class TurnController {
|
|||||||
this.activeTools = []
|
this.activeTools = []
|
||||||
this.streamTimer = clear(this.streamTimer)
|
this.streamTimer = clear(this.streamTimer)
|
||||||
this.bufRef = ''
|
this.bufRef = ''
|
||||||
this.pendingInlineDiffs = []
|
|
||||||
this.pendingSegmentTools = []
|
this.pendingSegmentTools = []
|
||||||
this.segmentMessages = []
|
this.segmentMessages = []
|
||||||
|
|
||||||
@@ -186,18 +198,35 @@ class TurnController {
|
|||||||
}, REASONING_PULSE_MS)
|
}, REASONING_PULSE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
queueInlineDiff(diffText: string) {
|
pushInlineDiffSegment(diffText: string) {
|
||||||
// Strip CLI chrome the gateway emits before the unified diff (e.g. a
|
// Strip CLI chrome the gateway emits before the unified diff (e.g. a
|
||||||
// leading "┊ review diff" header written by `_emit_inline_diff` for the
|
// leading "┊ review diff" header written by `_emit_inline_diff` for the
|
||||||
// terminal printer). That header only makes sense as stdout dressing,
|
// terminal printer). That header only makes sense as stdout dressing,
|
||||||
// not inside a markdown ```diff block.
|
// not inside a markdown ```diff block.
|
||||||
const text = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
|
const stripped = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
|
||||||
|
|
||||||
if (!text || this.pendingInlineDiffs.includes(text)) {
|
if (!stripped) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingInlineDiffs = [...this.pendingInlineDiffs, text]
|
// Flush any in-progress streaming text as its own segment first, so the
|
||||||
|
// diff lands BETWEEN the assistant narration that preceded the edit and
|
||||||
|
// whatever the agent streams afterwards — not glued onto the final
|
||||||
|
// message. This is the whole point of segment-anchored diffs: the diff
|
||||||
|
// renders where the edit actually happened.
|
||||||
|
this.flushStreamingSegment()
|
||||||
|
|
||||||
|
const block = `\`\`\`diff\n${stripped}\n\`\`\``
|
||||||
|
|
||||||
|
// Skip consecutive duplicates (same tool firing tool.complete twice, or
|
||||||
|
// two edits producing the same patch). Keeping this cheap — deeper
|
||||||
|
// dedupe against the final assistant text happens at message.complete.
|
||||||
|
if (this.segmentMessages.at(-1)?.text === block) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.segmentMessages = [...this.segmentMessages, { kind: 'diff', role: 'assistant', text: block }]
|
||||||
|
patchTurnState({ streamSegments: this.segmentMessages })
|
||||||
}
|
}
|
||||||
|
|
||||||
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
|
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
|
||||||
@@ -234,7 +263,6 @@ class TurnController {
|
|||||||
this.idle()
|
this.idle()
|
||||||
this.clearReasoning()
|
this.clearReasoning()
|
||||||
this.clearStatusTimer()
|
this.clearStatusTimer()
|
||||||
this.pendingInlineDiffs = []
|
|
||||||
this.pendingSegmentTools = []
|
this.pendingSegmentTools = []
|
||||||
this.segmentMessages = []
|
this.segmentMessages = []
|
||||||
this.turnTools = []
|
this.turnTools = []
|
||||||
@@ -245,31 +273,31 @@ class TurnController {
|
|||||||
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
|
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
|
||||||
const split = splitReasoning(rawText)
|
const split = splitReasoning(rawText)
|
||||||
const finalText = split.text
|
const finalText = split.text
|
||||||
// Skip appending if the assistant already narrated the diff inside a
|
|
||||||
// markdown fence of its own — otherwise we render two stacked diff
|
|
||||||
// blocks for the same edit.
|
|
||||||
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
|
|
||||||
|
|
||||||
const remainingInlineDiffs = assistantAlreadyHasDiff
|
|
||||||
? []
|
|
||||||
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
|
|
||||||
|
|
||||||
const inlineDiffBlock = remainingInlineDiffs.length
|
|
||||||
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
|
|
||||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||||
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
||||||
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
|
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
|
||||||
const savedToolTokens = this.toolTokenAcc
|
const savedToolTokens = this.toolTokenAcc
|
||||||
const tools = this.pendingSegmentTools
|
const tools = this.pendingSegmentTools
|
||||||
const finalMessages = [...this.segmentMessages]
|
|
||||||
|
|
||||||
if (mergedText) {
|
// Drop diff-only segments the agent is about to narrate in the final
|
||||||
|
// reply. Without this, a closing "here's the diff …" message would
|
||||||
|
// render two stacked copies of the same patch. Only touches segments
|
||||||
|
// with `kind: 'diff'` emitted by pushInlineDiffSegment — real
|
||||||
|
// assistant narration stays put.
|
||||||
|
const finalHasOwnDiffFence = /```(?:diff|patch)\b/i.test(finalText)
|
||||||
|
|
||||||
|
const segments = this.segmentMessages.filter(msg => {
|
||||||
|
const body = diffSegmentBody(msg)
|
||||||
|
|
||||||
|
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalMessages = [...segments]
|
||||||
|
|
||||||
|
if (finalText) {
|
||||||
finalMessages.push({
|
finalMessages.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
text: mergedText,
|
text: finalText,
|
||||||
thinking: savedReasoning || undefined,
|
thinking: savedReasoning || undefined,
|
||||||
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
|
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
|
||||||
toolTokens: savedToolTokens || undefined,
|
toolTokens: savedToolTokens || undefined,
|
||||||
@@ -300,7 +328,7 @@ class TurnController {
|
|||||||
this.bufRef = ''
|
this.bufRef = ''
|
||||||
patchTurnState({ activity: [], outcome: '' })
|
patchTurnState({ activity: [], outcome: '' })
|
||||||
|
|
||||||
return { finalMessages, finalText: mergedText, wasInterrupted }
|
return { finalMessages, finalText, wasInterrupted }
|
||||||
}
|
}
|
||||||
|
|
||||||
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
|
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
|
||||||
@@ -406,7 +434,6 @@ class TurnController {
|
|||||||
this.bufRef = ''
|
this.bufRef = ''
|
||||||
this.interrupted = false
|
this.interrupted = false
|
||||||
this.lastStatusNote = ''
|
this.lastStatusNote = ''
|
||||||
this.pendingInlineDiffs = []
|
|
||||||
this.pendingSegmentTools = []
|
this.pendingSegmentTools = []
|
||||||
this.protocolWarned = false
|
this.protocolWarned = false
|
||||||
this.segmentMessages = []
|
this.segmentMessages = []
|
||||||
@@ -452,7 +479,6 @@ class TurnController {
|
|||||||
this.endReasoningPhase()
|
this.endReasoningPhase()
|
||||||
this.clearReasoning()
|
this.clearReasoning()
|
||||||
this.activeTools = []
|
this.activeTools = []
|
||||||
this.pendingInlineDiffs = []
|
|
||||||
this.turnTools = []
|
this.turnTools = []
|
||||||
this.toolTokenAcc = 0
|
this.toolTokenAcc = 0
|
||||||
this.persistedToolLabels.clear()
|
this.persistedToolLabels.clear()
|
||||||
|
|||||||
@@ -81,11 +81,16 @@ export const MessageLine = memo(function MessageLine({
|
|||||||
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
|
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
// Diff segments (emitted by pushInlineDiffSegment between narration
|
||||||
|
// segments) need a blank line on both sides so the patch doesn't butt up
|
||||||
|
// against the prose around it.
|
||||||
|
const isDiffSegment = msg.kind === 'diff'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
marginBottom={msg.role === 'user' ? 1 : 0}
|
marginBottom={msg.role === 'user' || isDiffSegment ? 1 : 0}
|
||||||
marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0}
|
marginTop={msg.role === 'user' || msg.kind === 'slash' || isDiffSegment ? 1 : 0}
|
||||||
>
|
>
|
||||||
{showDetails && (
|
{showDetails && (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export interface ClarifyReq {
|
|||||||
|
|
||||||
export interface Msg {
|
export interface Msg {
|
||||||
info?: SessionInfo
|
info?: SessionInfo
|
||||||
kind?: 'intro' | 'panel' | 'slash' | 'trail'
|
kind?: 'diff' | 'intro' | 'panel' | 'slash' | 'trail'
|
||||||
panelData?: PanelData
|
panelData?: PanelData
|
||||||
role: Role
|
role: Role
|
||||||
text: string
|
text: string
|
||||||
|
|||||||
Reference in New Issue
Block a user