From 6cd1790ca1bdfef449b90bc7c0a918d7d86c51b1 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 10:44:50 -0700 Subject: [PATCH 001/168] test(frontend): add SideChatPanel unit tests --- .../__tests__/SideChatPanel.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 frontend/src/components/__tests__/SideChatPanel.test.ts diff --git a/frontend/src/components/__tests__/SideChatPanel.test.ts b/frontend/src/components/__tests__/SideChatPanel.test.ts new file mode 100644 index 00000000..7ae72d20 --- /dev/null +++ b/frontend/src/components/__tests__/SideChatPanel.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ path: '/chat' }), + RouterLink: { props: ['to', 'activeClass'], template: '' }, + RouterView: { template: '
' }, +})) + +vi.mock('../../lib/api', () => ({ + api: vi.fn(() => Promise.resolve({ ok: true, json: async () => [] })), +})) + +describe('SideChatPanel', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('mounts without error', async () => { + const { default: SideChatPanel } = await import('../SideChatPanel.vue') + const wrapper = mount(SideChatPanel) + expect(wrapper.exists()).toBe(true) + }) + + it('emits close when close button is clicked', async () => { + const { default: SideChatPanel } = await import('../SideChatPanel.vue') + const wrapper = mount(SideChatPanel) + await wrapper.find('button[aria-label="Close chat panel"]').trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + expect(wrapper.emitted('close')!.length).toBe(1) + }) + + it('emits close when Escape key is pressed', async () => { + const { default: SideChatPanel } = await import('../SideChatPanel.vue') + const wrapper = mount(SideChatPanel) + await wrapper.trigger('keydown', { key: 'Escape' }) + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('renders ConversationList component', async () => { + const { default: SideChatPanel } = await import('../SideChatPanel.vue') + const wrapper = mount(SideChatPanel) + expect(wrapper.findComponent({ name: 'ConversationList' }).exists()).toBe(true) + }) + + it('renders ConversationView component', async () => { + const { default: SideChatPanel } = await import('../SideChatPanel.vue') + const wrapper = mount(SideChatPanel) + expect(wrapper.findComponent({ name: 'ConversationView' }).exists()).toBe(true) + }) + + it('shows the "Chat" heading', async () => { + const { default: SideChatPanel } = await import('../SideChatPanel.vue') + const wrapper = mount(SideChatPanel) + expect(wrapper.text()).toContain('Chat') + }) +}) From 88646bf7d5459440315389ce25dc2574de772bea Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 10:48:42 -0700 Subject: [PATCH 002/168] feat(frontend): add SideChatPanel component --- frontend/src/components/SideChatPanel.vue | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 frontend/src/components/SideChatPanel.vue diff --git a/frontend/src/components/SideChatPanel.vue b/frontend/src/components/SideChatPanel.vue new file mode 100644 index 00000000..661fe26d --- /dev/null +++ b/frontend/src/components/SideChatPanel.vue @@ -0,0 +1,42 @@ + + + From c065ab4df1edb490d578974a65ef2cc209807b7a Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 10:48:51 -0700 Subject: [PATCH 003/168] feat(bot): wire tether-agent-2.0 to interactive-agent-layer real pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 2.0 stub+1.0-fallback pattern with a real LayerClient pipeline that starts a layer session, runs a single turn (haiku-4.5, max_turns=2, permission_mode=auto, basic tether MCP tools only), forwards status and agent_action events to the WS client via status_fn, and delivers the final response on turn_complete. tether-agent-2.5 remains stubbed — premium-pipeline-migrator owns that path. Fallback behaviour (no user-visible stub): - agent_layer.enabled=false in config → silently delegates to 1.0 pipeline - httpx error (layer unavailable) → silently delegates to 1.0 pipeline - asyncio.CancelledError → calls interrupt() then re-raises Tests: 11 unit tests in tests/bot/test_agent_dispatch.py; 5 WS integration tests in tests/api/test_agent_dispatch_ws.py updated to cover new 2.0 behaviour (layer happy path + silent fallback). 603 total passing. Note: test_ws_stub_prepended_for_2x[tether-agent-2.0] is intentionally replaced — 2.0 no longer sends a user-visible stub. 2.5 stub test preserved. --- bot/agent_dispatch.py | 164 +++++++++++++-- tests/api/test_agent_dispatch_ws.py | 129 +++++++++--- tests/bot/conftest.py | 5 + tests/bot/test_agent_dispatch.py | 298 ++++++++++++++++++++++++---- 4 files changed, 516 insertions(+), 80 deletions(-) diff --git a/bot/agent_dispatch.py b/bot/agent_dispatch.py index 196ca7a8..b2af84f4 100644 --- a/bot/agent_dispatch.py +++ b/bot/agent_dispatch.py @@ -4,31 +4,66 @@ requested agent_version: tether-agent-1.0 → existing JSON-mutation pipeline (handle_message) - tether-agent-2.0 → stub notice + 1.0 fallback (not yet wired) - tether-agent-2.5 → stub notice + 1.0 fallback (not yet wired) + tether-agent-2.0 → real LayerClient pipeline; 1.0 fallback on error/disabled + tether-agent-2.5 → stub notice + 1.0 fallback (premium-pipeline-migrator owns this) unknown / None → treated as tether-agent-2.0 (picker default) -The stub-then-fallback pattern ensures users selecting 2.0 or 2.5 still get -a response while the real pipelines are built out. The stub is delivered via -send_fn so it joins the 1.0 response in a single chunk frame on the client -(the WS handler accumulates all send_fn calls and joins them with "\\n\\n"). +The 2.0 pipeline calls the interactive-agent-layer service, which handles +streaming, tool-use translation, and permission gating. SSE events from the +layer are forwarded to the WS client via status_fn; the final response is +delivered via send_fn when turn_complete arrives. + +Fallback behaviour: if the layer is disabled (config) or unreachable (HTTP +error), dispatch silently falls back to handle_message so users always get +a response. Note: The Telegram polling path (bot/message_handler.py) calls handle_message directly — it bypasses this dispatcher (Telegram has no picker UI). """ from __future__ import annotations +import asyncio +import contextlib import logging from collections.abc import Callable from typing import Any +import httpx + from bot.message_handler import handle_message +from config.loader import config +from interactive_agent_layer.client import LayerClient logger = logging.getLogger(__name__) _DEFAULT_VERSION = "tether-agent-2.0" -_STUB_VERSIONS: frozenset[str] = frozenset({"tether-agent-2.0", "tether-agent-2.5"}) -_KNOWN_VERSIONS: frozenset[str] = _STUB_VERSIONS | {"tether-agent-1.0"} +_KNOWN_VERSIONS: frozenset[str] = frozenset( + {"tether-agent-1.0", "tether-agent-2.0", "tether-agent-2.5"} +) + +# MCP tools available to tether-agent-2.0 (basic tether MCP only, no premium). +_V2_0_OPTIONS: dict[str, Any] = { + "model": "haiku-4.5", + "allowed_tools": [ + "upsert_tasks", + "upsert_context", + "delete_tasks", + "delete_context", + "read_context", + "read_tasks", + "get_plan", + "get_anchors", + "search", + ], + "max_turns": 2, + "permission_mode": "auto", + "mcp_servers": ["tether"], # basic tether MCP only; no premium tools +} + + +def _layer_enabled() -> bool: + """Return True if the interactive-agent-layer is enabled in config.""" + return config.get_bool("agent_layer.enabled", True) def _stub_message(version: str) -> str: @@ -38,6 +73,89 @@ def _stub_message(version: str) -> str: ) +async def _dispatch_v2_0( + text: str, + send_fn: Callable[[str], None], + pool: Any, + user_id: str, + vault: Any = None, + status_fn: Any = None, +) -> None: + """Run the tether-agent-2.0 pipeline via the interactive-agent-layer. + + Starts a layer session, runs one turn, forwards status/action events to the + WS client via status_fn, and delivers the final response via send_fn when + turn_complete arrives. + + Falls back to handle_message (1.0 pipeline) when: + - agent_layer.enabled is false in config + - the layer service is unreachable or returns an HTTP error + + Session cleanup (end_session) is always attempted in the finally block so + the layer doesn't hold dangling sessions on error. On asyncio cancellation, + interrupt() is signalled before end_session so the pool can reclaim the + subprocess quickly. + """ + if not _layer_enabled(): + logger.info( + "dispatch_v2_0: agent_layer disabled, falling back to 1.0 user_id=%s", + user_id, + ) + await handle_message(text, send_fn, pool, user_id, vault=vault, status_fn=status_fn) + return + + base_url: str = config.get("agent_layer.base_url", "http://127.0.0.1:5003") + layer = LayerClient(base_url) + session_id: str | None = None + + try: + session_id = await layer.start_session( + user_id=user_id, + user_ws_id=user_id, # proxy: use user_id until per-connection IDs land + agent_version="tether-agent-2.0", + options=_V2_0_OPTIONS, + user_message=text, + ) + + async for event in layer.turn(session_id, text): + etype = event.get("type") + + if etype == "turn_complete": + send_fn(event.get("final_text", "")) + break + + if status_fn is not None: + if etype == "status": + msg = event.get("message", "") + if msg: + await status_fn(msg) + elif etype == "agent_action": + action = event.get("action", "") + if action: + await status_fn(action) + + except asyncio.CancelledError: + if session_id is not None: + with contextlib.suppress(Exception): + await layer.interrupt(session_id) + raise + + except httpx.HTTPError as exc: + # Covers both start_session and turn() failures. If session_id is set, + # the finally block cleans it up; otherwise there is nothing to end. + logger.warning( + "dispatch_v2_0: layer unavailable, falling back to 1.0 user_id=%s: %s", + user_id, + exc, + ) + await handle_message(text, send_fn, pool, user_id, vault=vault, status_fn=status_fn) + + finally: + if session_id is not None: + with contextlib.suppress(Exception): + await layer.end_session(session_id) + + async def dispatch_message( agent_version: str | None, text: str, @@ -50,13 +168,12 @@ async def dispatch_message( """Dispatch a user message to the correct pipeline based on agent_version. For tether-agent-1.0, delegates directly to handle_message with no stub. - For tether-agent-2.0/2.5 (not yet wired), sends a stub notice via send_fn - and then falls back to the 1.0 pipeline so the user still gets a response. + For tether-agent-2.0, calls the interactive-agent-layer real pipeline with + a silent fallback to 1.0 on error or when the layer is disabled. + For tether-agent-2.5 (not yet wired), sends a stub notice and falls back + to the 1.0 pipeline so the user still gets a response. Unknown or None versions default to tether-agent-2.0 and log a warning. - Both the stub and the 1.0 response are accumulated by the WS handler's - capture_send_fn and joined into a single chunk frame on the client. - Args: agent_version: The version string from the WS message, or None if absent. text: The user message text. @@ -75,12 +192,19 @@ async def dispatch_message( user_id, ) - if version in _STUB_VERSIONS: - send_fn(_stub_message(version)) - logger.warning( - "dispatch_message: %s not yet wired — stub sent, falling back to 1.0 user_id=%s", - version, - user_id, - ) + if version == "tether-agent-1.0": + await handle_message(text, send_fn, pool, user_id, vault=vault, status_fn=status_fn) + return + + if version == "tether-agent-2.0": + await _dispatch_v2_0(text, send_fn, pool, user_id, vault=vault, status_fn=status_fn) + return + # tether-agent-2.5 — stub until premium-pipeline-migrator wires the real path + send_fn(_stub_message(version)) + logger.warning( + "dispatch_message: %s not yet wired — stub sent, falling back to 1.0 user_id=%s", + version, + user_id, + ) await handle_message(text, send_fn, pool, user_id, vault=vault, status_fn=status_fn) diff --git a/tests/api/test_agent_dispatch_ws.py b/tests/api/test_agent_dispatch_ws.py index 6e57f948..31e48174 100644 --- a/tests/api/test_agent_dispatch_ws.py +++ b/tests/api/test_agent_dispatch_ws.py @@ -2,17 +2,23 @@ Verifies the bot_chat WebSocket handler routes messages based on agent_version: - tether-agent-1.0 → no stub, chunk contains only the 1.0 response -- tether-agent-2.0/2.5 → chunk contains stub + 1.0 response (joined by \\n\\n) -- agent_version missing → defaults to 2.0 path (stub + 1.0 response) +- tether-agent-2.0 → real layer pipeline; falls back silently to 1.0 on error +- tether-agent-2.5 → chunk contains stub + 1.0 response (joined by \\n\\n) +- agent_version missing → defaults to 2.0 path (layer pipeline / fallback) Note: The Telegram path (_process_telegram_update) calls handle_message directly and is intentionally excluded from dispatch routing — it has no picker UI. + +Deliberate behaviour change (M3): tether-agent-2.0 no longer sends a user- +visible stub. It runs the real layer pipeline, falling back silently to 1.0 +when the layer is unavailable. This is tested both with a mocked successful +layer session and with a mocked HTTP failure. """ from __future__ import annotations import os from contextlib import asynccontextmanager -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -51,14 +57,53 @@ async def _fake_handle_message(text, send_fn, pool, user_id, vault=None, status_ send_fn("1.0-response") -def _dispatch_via_ws(user_message: dict) -> tuple[dict, dict]: - """Run a single user message through bot_chat and return (chunk, done) frames.""" +def _make_layer_constructor(*, events=None, raise_on_start=None): + """Return a LayerClient constructor mock yielding the given events on turn().""" + if events is None: + events = [ + { + "type": "turn_complete", + "session_id": "sid-ws", + "final_text": "Layer WS response", + "tokens_used": 5, + } + ] + + async def _turn_gen(session_id, prompt): + for event in events: + yield event + + client = MagicMock() + if raise_on_start: + client.start_session = AsyncMock(side_effect=raise_on_start) + else: + client.start_session = AsyncMock(return_value="sid-ws") + client.end_session = AsyncMock() + client.interrupt = AsyncMock() + client.turn = _turn_gen + + return MagicMock(return_value=client), client + + +def _dispatch_via_ws(user_message: dict, extra_patches=None) -> tuple[dict, dict]: + """Run a single user message through bot_chat and return (chunk, done) frames. + + extra_patches: list of (target, mock) tuples for additional patch.object calls. + """ from starlette.testclient import TestClient app = _make_app() token = _valid_token() + patches = extra_patches or [] + + ctx = [patch("bot.agent_dispatch.handle_message", new=_fake_handle_message)] + for target, new in patches: + ctx.append(patch(target, new)) - with patch("bot.agent_dispatch.handle_message", new=_fake_handle_message): + import contextlib + with contextlib.ExitStack() as stack: + for p in ctx: + stack.enter_context(p) with TestClient(app, raise_server_exceptions=False) as client: with client.websocket_connect( "/api/bot/chat", @@ -98,16 +143,52 @@ def test_ws_agent_1_0_no_stub(): # --------------------------------------------------------------------------- -# tether-agent-2.0 / 2.5 — stub + 1.0 response in chunk +# tether-agent-2.5 — stub + 1.0 response in chunk (still wired to stub path) # --------------------------------------------------------------------------- -@pytest.mark.parametrize("version", ["tether-agent-2.0", "tether-agent-2.5"]) -def test_ws_stub_prepended_for_2x(version): - """2.0/2.5 must include a stub notice before the 1.0 response in a single chunk frame.""" - chunk, done = _dispatch_via_ws({"agent_version": version}) +def test_ws_2_5_stub_prepended(): + """2.5 must include a stub notice before the 1.0 response in a single chunk frame.""" + chunk, done = _dispatch_via_ws({"agent_version": "tether-agent-2.5"}) assert chunk["type"] == "chunk" - _assert_stub_present(chunk["content"], version.removeprefix("tether-agent-")) + _assert_stub_present(chunk["content"], "2.5") + assert done["type"] == "done" + + +# --------------------------------------------------------------------------- +# tether-agent-2.0 — real layer pipeline +# --------------------------------------------------------------------------- + +def test_ws_2_0_layer_delivers_response(): + """2.0 real pipeline: layer turn_complete final_text must arrive as the chunk.""" + constructor, client = _make_layer_constructor() + chunk, done = _dispatch_via_ws( + {"agent_version": "tether-agent-2.0"}, + extra_patches=[("bot.agent_dispatch.LayerClient", constructor)], + ) + + assert chunk["type"] == "chunk" + assert chunk["content"] == "Layer WS response" + assert done["type"] == "done" + + +def test_ws_2_0_layer_unavailable_falls_back_silently(): + """2.0 path: when layer is unavailable, fall back to 1.0 — no stub in chunk.""" + import httpx + constructor, _client = _make_layer_constructor( + raise_on_start=httpx.ConnectError("refused") + ) + chunk, done = _dispatch_via_ws( + {"agent_version": "tether-agent-2.0"}, + extra_patches=[("bot.agent_dispatch.LayerClient", constructor)], + ) + + assert chunk["type"] == "chunk" + # No stub — silent fallback, user just gets the 1.0 response + assert chunk["content"] == "1.0-response" + content_lower = chunk["content"].lower() + assert "coming soon" not in content_lower + assert "not yet" not in content_lower assert done["type"] == "done" @@ -115,17 +196,19 @@ def test_ws_stub_prepended_for_2x(version): # Missing agent_version — defaults to 2.0 path # --------------------------------------------------------------------------- -def test_ws_missing_agent_version_defaults_to_2_0(): - """Omitting agent_version must follow the 2.0 path (stub + 1.0 response).""" - chunk, done = _dispatch_via_ws({}) # agent_version intentionally omitted +def test_ws_missing_agent_version_defaults_to_2_0_layer_path(): + """Omitting agent_version defaults to 2.0 (real layer pipeline / silent fallback).""" + import httpx + constructor, _client = _make_layer_constructor( + raise_on_start=httpx.ConnectError("refused") + ) + chunk, done = _dispatch_via_ws( + {}, # agent_version intentionally omitted + extra_patches=[("bot.agent_dispatch.LayerClient", constructor)], + ) assert chunk["type"] == "chunk" - content = chunk["content"] - assert "1.0-response" in content - content_lower = content.lower() - assert ( - "coming soon" in content_lower - or "not yet" in content_lower - or "falling back" in content_lower - ), f"missing version must follow 2.0 path (stub present), got chunk: {content!r}" + # 2.0 fallback: 1.0-response, no stub + assert chunk["content"] == "1.0-response" + assert "coming soon" not in chunk["content"].lower() assert done["type"] == "done" diff --git a/tests/bot/conftest.py b/tests/bot/conftest.py index 0f9731a4..9f521496 100644 --- a/tests/bot/conftest.py +++ b/tests/bot/conftest.py @@ -2,6 +2,11 @@ from __future__ import annotations import os + +# Set required config values before any app imports so the config loader +# singleton resolves successfully in test environments (jwt.secret is required). +os.environ.setdefault("TETHER_JWT_SECRET", "test-secret-for-bot-tests") + import pytest import asyncpg diff --git a/tests/bot/test_agent_dispatch.py b/tests/bot/test_agent_dispatch.py index 0e2b3b0f..921e5f20 100644 --- a/tests/bot/test_agent_dispatch.py +++ b/tests/bot/test_agent_dispatch.py @@ -2,39 +2,28 @@ Tests the dispatch matrix: - tether-agent-1.0 → handle_message called, no stub injected -- tether-agent-2.0/2.5 → stub sent via send_fn first, then handle_message called -- unknown / None version → defaults to tether-agent-2.0 path (stub + 1.0 fallback) -- vault/status_fn → forwarded transparently to handle_message +- tether-agent-2.0 → real LayerClient pipeline; falls back to 1.0 on error or disabled +- tether-agent-2.5 → stub notice + 1.0 fallback (not yet wired) +- unknown / None version → treated as tether-agent-2.0 (picker default) +- vault/status_fn → forwarded transparently to handle_message (1.0 path) """ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch +import httpx import pytest +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + async def _fake_handle_1_0_response(text, send_fn, pool, user_id, vault=None, status_fn=None): """Fake 1.0 pipeline: delivers a fixed response via send_fn.""" send_fn("1.0-response") -async def _dispatch(version, **kwargs) -> list[str]: - """Run dispatch_message under the fake 1.0 pipeline and return captured send_fn parts.""" - with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response): - from bot.agent_dispatch import dispatch_message # import after patch - - sent_parts: list[str] = [] - await dispatch_message( - version, - "hello", - send_fn=sent_parts.append, - pool=None, - user_id="user1", - **kwargs, - ) - return sent_parts - - def _assert_not_wired_stub(stub: str) -> None: """Stub must communicate not-wired status (matches the wording in agent_dispatch).""" stub_lower = stub.lower() @@ -45,35 +34,86 @@ def _assert_not_wired_stub(stub: str) -> None: ), f"stub must communicate not-wired status, got: {stub!r}" +def _make_layer_client(*, events=None, raise_on_start=None): + """Return a mock LayerClient instance and its constructor mock. + + The constructor mock, when called with any args, returns the same instance, + so patch("bot.agent_dispatch.LayerClient", constructor) works. + """ + if events is None: + events = [ + { + "type": "turn_complete", + "session_id": "sid-1", + "final_text": "Layer response", + "tokens_used": 5, + } + ] + + async def _turn_gen(session_id, prompt): + for event in events: + yield event + + client = MagicMock() + client.start_session = AsyncMock(return_value="sid-1") + client.end_session = AsyncMock() + client.interrupt = AsyncMock() + client.turn = _turn_gen + + if raise_on_start is not None: + client.start_session = AsyncMock(side_effect=raise_on_start) + + constructor = MagicMock(return_value=client) + return constructor, client + + # --------------------------------------------------------------------------- # 1.0 path — no stub # --------------------------------------------------------------------------- async def test_dispatch_1_0_no_stub(): """tether-agent-1.0 must call handle_message without any stub prepended.""" - sent_parts = await _dispatch("tether-agent-1.0") + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response): + from bot.agent_dispatch import dispatch_message + + sent_parts: list[str] = [] + await dispatch_message( + "tether-agent-1.0", + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + ) assert sent_parts == ["1.0-response"], "1.0 path must not inject any stub message" # --------------------------------------------------------------------------- -# 2.0 / 2.5 paths — stub + 1.0 fallback +# 2.5 path — still stubbed (premium-pipeline-migrator owns this) # --------------------------------------------------------------------------- -@pytest.mark.parametrize("version", ["tether-agent-2.0", "tether-agent-2.5"]) -async def test_dispatch_stub_then_calls_1_0(version): - """2.0/2.5 must prepend a stub mentioning the version, then fall back to 1.0.""" - sent_parts = await _dispatch(version) +async def test_dispatch_2_5_stub_then_calls_1_0(): + """tether-agent-2.5 must prepend a stub mentioning 2.5, then fall back to 1.0.""" + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response): + from bot.agent_dispatch import dispatch_message - assert len(sent_parts) == 2, f"{version} path must produce stub + 1.0 response" + sent_parts: list[str] = [] + await dispatch_message( + "tether-agent-2.5", + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + ) + + assert len(sent_parts) == 2, "2.5 path must produce stub + 1.0 response" stub, response = sent_parts - version_suffix = version.removeprefix("tether-agent-") - assert version_suffix in stub, f"stub must mention {version_suffix}, got: {stub!r}" + assert "2.5" in stub, f"stub must mention 2.5, got: {stub!r}" _assert_not_wired_stub(stub) assert response == "1.0-response" # --------------------------------------------------------------------------- -# Unknown / None version → defaults to 2.0 path +# Unknown / None version → defaults to 2.0 path (layer pipeline) # --------------------------------------------------------------------------- @pytest.mark.parametrize( @@ -82,19 +122,203 @@ async def test_dispatch_stub_then_calls_1_0(version): ids=["unknown_version", "none_version"], ) async def test_dispatch_unknown_or_none_defaults_to_2_0_path(version): - """Unknown or None agent_version must default to tether-agent-2.0 (stub + 1.0 fallback).""" - sent_parts = await _dispatch(version) - assert len(sent_parts) == 2, ( - f"{version!r} must follow 2.0 path (stub + 1.0 fallback)" + """Unknown or None agent_version must default to tether-agent-2.0 (layer pipeline).""" + constructor, client = _make_layer_client() + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + sent_parts: list[str] = [] + await dispatch_message( + version, + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + ) + # On success, the layer delivers the final text — no stub, no 1.0 fallback + assert sent_parts == ["Layer response"] + + +# --------------------------------------------------------------------------- +# 2.0 real pipeline — happy path +# --------------------------------------------------------------------------- + +async def test_dispatch_2_0_creates_layer_session_with_correct_options(): + """2.0 dispatch must call start_session with the correct ClaudeAgentOptions.""" + constructor, client = _make_layer_client() + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message, _V2_0_OPTIONS + + await dispatch_message( + "tether-agent-2.0", + "do something", + send_fn=lambda m: None, + pool=None, + user_id="user42", + ) + + constructor.assert_called_once() # LayerClient instantiated + client.start_session.assert_awaited_once() + call_kwargs = client.start_session.call_args + + assert call_kwargs.kwargs.get("user_id") == "user42" + assert call_kwargs.kwargs.get("agent_version") == "tether-agent-2.0" + + opts = call_kwargs.kwargs.get("options", {}) + assert opts.get("model") == "haiku-4.5" + assert opts.get("max_turns") == 2 + assert opts.get("permission_mode") == "auto" + expected_tools = [ + "upsert_tasks", "upsert_context", "delete_tasks", "delete_context", + "read_context", "read_tasks", "get_plan", "get_anchors", "search", + ] + assert set(opts.get("allowed_tools", [])) == set(expected_tools) + + +async def test_dispatch_2_0_turn_complete_sends_final_text_and_ends_session(): + """On turn_complete, send_fn must receive final_text and end_session must be called.""" + constructor, client = _make_layer_client(events=[ + {"type": "turn_complete", "session_id": "sid-1", "final_text": "Done!", "tokens_used": 3}, + ]) + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + sent_parts: list[str] = [] + await dispatch_message( + "tether-agent-2.0", + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + ) + + assert sent_parts == ["Done!"] + client.end_session.assert_awaited_once_with("sid-1") + + +async def test_dispatch_2_0_status_events_forwarded_via_status_fn(): + """status and agent_action events must be forwarded to status_fn.""" + events = [ + {"type": "status", "session_id": "sid-1", "message": "Thinking..."}, + {"type": "agent_action", "session_id": "sid-1", "action": "Reading your schedule"}, + {"type": "turn_complete", "session_id": "sid-1", "final_text": "Here you go", "tokens_used": 8}, + ] + constructor, client = _make_layer_client(events=events) + status_calls: list[str] = [] + + async def fake_status_fn(msg: str) -> None: + status_calls.append(msg) + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + await dispatch_message( + "tether-agent-2.0", + "hello", + send_fn=lambda m: None, + pool=None, + user_id="user1", + status_fn=fake_status_fn, + ) + + assert "Thinking..." in status_calls + assert "Reading your schedule" in status_calls + + +# --------------------------------------------------------------------------- +# 2.0 fallback paths +# --------------------------------------------------------------------------- + +async def test_dispatch_2_0_layer_disabled_falls_back_to_1_0(): + """When agent_layer.enabled=false, 2.0 must fall back to 1.0 without a stub message.""" + constructor, client = _make_layer_client() + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + patch("bot.agent_dispatch._layer_enabled", return_value=False), + ): + from bot.agent_dispatch import dispatch_message + + sent_parts: list[str] = [] + await dispatch_message( + "tether-agent-2.0", + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + ) + + # Layer not used + client.start_session.assert_not_awaited() + # 1.0 pipeline delivers response silently (no stub prefix) + assert sent_parts == ["1.0-response"] + + +async def test_dispatch_2_0_layer_http_error_falls_back_to_1_0(): + """When LayerClient raises an httpx error, 2.0 must silently fall back to 1.0.""" + constructor, client = _make_layer_client( + raise_on_start=httpx.ConnectError("refused") + ) + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + sent_parts: list[str] = [] + await dispatch_message( + "tether-agent-2.0", + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + ) + + # 1.0 pipeline must have fired, and no stub message + assert sent_parts == ["1.0-response"] + + +async def test_dispatch_2_0_end_session_called_on_http_error(): + """end_session must NOT be called when start_session fails (no session to end).""" + constructor, client = _make_layer_client( + raise_on_start=httpx.ConnectError("refused") ) + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + await dispatch_message( + "tether-agent-2.0", "hi", send_fn=lambda m: None, pool=None, user_id="u1" + ) + + client.end_session.assert_not_awaited() + # --------------------------------------------------------------------------- -# vault and status_fn are forwarded transparently +# Existing 1.0 vault/status_fn forwarding test — unchanged # --------------------------------------------------------------------------- async def test_dispatch_forwards_vault_and_status_fn(): - """dispatch_message must pass vault and status_fn through to handle_message.""" + """dispatch_message must pass vault and status_fn through to handle_message (1.0 path).""" received: dict = {} async def capture_kwargs(text, send_fn, pool, user_id, vault=None, status_fn=None): From 891fa300035d95c422bdd4633e54cb720bd56f55 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 11:17:30 -0700 Subject: [PATCH 004/168] test(frontend): add AppNav nav link and keyboard shortcut tests --- frontend/src/views/__tests__/AppNav.test.ts | 164 ++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 frontend/src/views/__tests__/AppNav.test.ts diff --git a/frontend/src/views/__tests__/AppNav.test.ts b/frontend/src/views/__tests__/AppNav.test.ts new file mode 100644 index 00000000..e25e53b8 --- /dev/null +++ b/frontend/src/views/__tests__/AppNav.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ path: '/dashboard' }), + RouterLink: { props: ['to', 'activeClass'], template: '' }, + RouterView: { template: '
' }, +})) + +vi.mock('../../lib/api', () => ({ + api: vi.fn(() => Promise.resolve({ ok: true, json: async () => [] })), +})) + +// Stub out heavy child components to keep App.vue mount lightweight +vi.mock('../../components/SlideOverStack.vue', () => ({ + default: { template: '
' }, +})) +vi.mock('../../components/ThemeDrawer.vue', () => ({ + default: { template: '
', props: ['modelValue'] }, +})) +vi.mock('../../components/SideChatPanel.vue', () => ({ + default: { template: '
', emits: ['close'] }, +})) +vi.mock('../../components/PermissionModal.vue', () => ({ + default: { template: '
' }, +})) + +// RouterLink stub that renders href so we can find links by path +const RouterLinkStub = { props: ['to', 'activeClass'], template: '' } + +// Global route mock — App.vue uses $route.path in template +const globalMountOptions = { + global: { + mocks: { + $route: { path: '/dashboard' }, + }, + components: { + RouterLink: RouterLinkStub, + 'router-link': RouterLinkStub, + }, + }, +} + +describe('AppNav - /chat router-link', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('renders a router-link to /chat when authenticated', async () => { + const { default: App } = await import('../../App.vue') + const { useAuthStore } = await import('../../stores/auth') + const wrapper = mount(App, globalMountOptions) + const authStore = useAuthStore() + authStore.user = { user_id: '1', username: 'test', is_admin: false } + await flushPromises() + + const links = wrapper.findAll('a') + const chatLink = links.find(l => l.attributes('href') === '/chat') + expect(chatLink).toBeDefined() + expect(chatLink!.text()).toContain('Chat') + }) + + it('does not render nav when not authenticated', async () => { + const { default: App } = await import('../../App.vue') + const wrapper = mount(App, globalMountOptions) + await flushPromises() + + const nav = wrapper.find('nav') + expect(nav.exists()).toBe(false) + }) +}) + +describe('AppNav - Ctrl+/ keyboard shortcut', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetModules() + }) + + it('toggles chat panel open on Ctrl+/', async () => { + const { default: App } = await import('../../App.vue') + const { useAuthStore } = await import('../../stores/auth') + const wrapper = mount(App, globalMountOptions) + const authStore = useAuthStore() + authStore.user = { user_id: '1', username: 'test', is_admin: false } + await flushPromises() + + // Panel initially closed + expect(wrapper.find('[data-testid="side-chat-panel-stub"]').exists()).toBe(false) + + // Dispatch Ctrl+/ on window + window.dispatchEvent(new KeyboardEvent('keydown', { ctrlKey: true, key: '/', bubbles: true })) + await flushPromises() + + // Panel should now be open + expect(wrapper.find('[data-testid="side-chat-panel-stub"]').exists()).toBe(true) + }) + + it('toggles chat panel closed again on second Ctrl+/', async () => { + const { default: App } = await import('../../App.vue') + const { useAuthStore } = await import('../../stores/auth') + const wrapper = mount(App, globalMountOptions) + const authStore = useAuthStore() + authStore.user = { user_id: '1', username: 'test', is_admin: false } + await flushPromises() + + // Open + window.dispatchEvent(new KeyboardEvent('keydown', { ctrlKey: true, key: '/', bubbles: true })) + await flushPromises() + expect(wrapper.find('[data-testid="side-chat-panel-stub"]').exists()).toBe(true) + + // Close + window.dispatchEvent(new KeyboardEvent('keydown', { ctrlKey: true, key: '/', bubbles: true })) + await flushPromises() + expect(wrapper.find('[data-testid="side-chat-panel-stub"]').exists()).toBe(false) + + wrapper.unmount() + }) +}) + +describe('AppNav - panel persistence across route navigation', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetModules() + }) + + it('panel stays open after simulated route change', async () => { + const { default: App } = await import('../../App.vue') + const { useAuthStore } = await import('../../stores/auth') + const wrapper = mount(App, globalMountOptions) + const authStore = useAuthStore() + authStore.user = { user_id: '1', username: 'test', is_admin: false } + await flushPromises() + + // Open panel via toggle button click + const chatToggleBtn = wrapper.find('button[title="Toggle chat (Ctrl+/)"]') + expect(chatToggleBtn.exists()).toBe(true) + await chatToggleBtn.trigger('click') + await flushPromises() + + // Panel is open and SideChatPanel stub is rendered + expect(wrapper.find('[data-testid="side-chat-panel-stub"]').exists()).toBe(true) + + // The panel persists because it's in App.vue outside + // Simulate route change via user object update (doesn't affect chatOpen) + authStore.user = { user_id: '1', username: 'test', is_admin: false } + await flushPromises() + + // Panel still open + expect(wrapper.find('[data-testid="side-chat-panel-stub"]').exists()).toBe(true) + + wrapper.unmount() + }) +}) From 1766b18be6ae9b2b251ba228ac44b8ac68c184db Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 11:17:30 -0700 Subject: [PATCH 005/168] feat(frontend): wire trial counter, BYOK gate, and agent settings toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 — Live trial counter: - agentPicker store: add trialMessagesRemaining (null until first WS event) and setTrialRemaining() action - chat store: trial_usage_update WS event now calls setTrialRemaining instead of silently ignoring - AgentPicker.vue: reads live count from store (removing trialMessagesLeft prop); shows "Upgrade to continue" overlay and locks 2.5 selection when count reaches 0 Part 2 — BYOK leakage gate UX: - constants/agentProvider.ts: LEAKY_PROVIDERS = ['openrouter', 'openai'], DEFAULT_PROVIDER, isLeakyProvider() helper - agentPicker store: currentProvider ref + isLeakyProvider computed; setAgent() silently blocks 2.5 selection when provider is leaky - AgentPicker.vue: shows "Unavailable on current provider" explanation line and disables the 2.5 button when isLeakyProvider Part 3 — Settings toggles: - stores/agentSettings.ts: new store for auto_approve_user_actions and dev_mode_show_raw_tools; optimistic PUT with rollback via shared commitToggle() - components/AgentBehaviorSection.vue: two toggle UI buttons wired to store actions - SettingsView.vue: includes AgentBehaviorSection above LLM Configuration Tests: 74 files, 761 tests all passing; zero type errors (npm run build clean). --- .../src/components/AgentBehaviorSection.vue | 73 ++++++++ frontend/src/components/AgentPicker.vue | 111 ++++++++---- .../__tests__/AgentBehaviorSection.test.ts | 99 +++++++++++ .../components/__tests__/AgentPicker.test.ts | 20 ++- .../__tests__/AgentPickerTrialByok.test.ts | 162 ++++++++++++++++++ frontend/src/constants/agentProvider.ts | 15 ++ .../__tests__/agentPickerTrialByok.test.ts | 137 +++++++++++++++ .../stores/__tests__/agentSettings.test.ts | 146 ++++++++++++++++ frontend/src/stores/agentPicker.ts | 36 +++- frontend/src/stores/agentSettings.ts | 63 +++++++ frontend/src/stores/chat.ts | 2 +- frontend/src/views/SettingsView.vue | 4 + 12 files changed, 823 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/AgentBehaviorSection.vue create mode 100644 frontend/src/components/__tests__/AgentBehaviorSection.test.ts create mode 100644 frontend/src/components/__tests__/AgentPickerTrialByok.test.ts create mode 100644 frontend/src/constants/agentProvider.ts create mode 100644 frontend/src/stores/__tests__/agentPickerTrialByok.test.ts create mode 100644 frontend/src/stores/__tests__/agentSettings.test.ts create mode 100644 frontend/src/stores/agentSettings.ts diff --git a/frontend/src/components/AgentBehaviorSection.vue b/frontend/src/components/AgentBehaviorSection.vue new file mode 100644 index 00000000..e4fee88f --- /dev/null +++ b/frontend/src/components/AgentBehaviorSection.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/components/AgentPicker.vue b/frontend/src/components/AgentPicker.vue index 3fb8e519..80acfd5c 100644 --- a/frontend/src/components/AgentPicker.vue +++ b/frontend/src/components/AgentPicker.vue @@ -4,12 +4,6 @@ import { useAgentPickerStore } from '../stores/agentPicker' import { useAuthStore } from '../stores/auth' import type { AgentVersion } from '../stores/agentPicker' -withDefaults(defineProps<{ - trialMessagesLeft?: number -}>(), { - trialMessagesLeft: 10, -}) - const store = useAgentPickerStore() const authStore = useAuthStore() const open = ref(false) @@ -18,26 +12,44 @@ const rootEl = ref(null) // Premium users bypass trial counter and BYOK modal entirely. const isPremium = computed(() => authStore.user?.is_paid ?? false) +// Live trial count from WS events; null until first event arrives (show fallback). +const trialRemaining = computed(() => store.trialMessagesRemaining ?? 10) + +// True when current provider leaks 2.5 internals — 2.5 shown as unavailable. +const providerIsLeaky = computed(() => store.isLeakyProvider) + +// True when free user has exhausted their monthly 2.5 trial quota. +const trialExhausted = computed( + () => !isPremium.value && store.trialMessagesRemaining === 0, +) + const AGENTS: Array<{ id: AgentVersion; label: string; sublabel: string }> = [ { id: 'tether-agent-1.0', label: 'tether-agent-1.0', sublabel: 'Classic · free' }, { id: 'tether-agent-2.0', label: 'tether-agent-2.0', sublabel: 'Modern · free' }, { id: 'tether-agent-2.5', label: 'tether-agent-2.5', sublabel: 'Premium' }, ] +/** + * Whether the 2.5 option is locked (unselectable). + * Locks when: provider is leaky, OR trial is exhausted. + * Premium users are never locked. + */ +function is25Locked(id: AgentVersion): boolean { + if (id !== 'tether-agent-2.5') return false + if (isPremium.value) return false + return providerIsLeaky.value || trialExhausted.value +} + function toggleOpen() { open.value = !open.value } async function select(version: AgentVersion) { + if (is25Locked(version)) return // silently ignore clicks on locked option open.value = false await store.setAgent(version) } -// "Stay on 2.0" — cancel the pending 2.5 selection, keep current agent. -function stayOn20() { - store.cancelByokModal() -} - // Close dropdown when clicking outside the component's root. function onDocumentClick(e: MouseEvent) { if (rootEl.value && !rootEl.value.contains(e.target as Node)) { @@ -73,38 +85,65 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocumentClick) :key="agent.id" type="button" :data-agent="agent.id" - class="w-full flex items-center justify-between px-3 py-2 text-xs text-left hover:bg-[--bg-elev-3] transition-colors" - :class="store.selectedAgent === agent.id ? 'text-[--fg-1]' : 'text-[--fg-3]'" + :disabled="is25Locked(agent.id)" + class="w-full flex items-center justify-between px-3 py-2 text-xs text-left transition-colors" + :class="[ + is25Locked(agent.id) + ? 'opacity-50 cursor-not-allowed' + : 'hover:bg-[--bg-elev-3]', + store.selectedAgent === agent.id ? 'text-[--fg-1]' : 'text-[--fg-3]', + ]" @click="select(agent.id)" > - - - - - - - - - {{ agent.label }} - · {{ agent.sublabel }} - - + + + - trial: {{ trialMessagesLeft }} left + + + + + + {{ agent.label }} + + · {{ agent.sublabel }} + + + + + trial: {{ trialRemaining }} left + + + + + Unavailable on current provider · Switch to Anthropic to use this model + + + + + Upgrade to continue + - + ⓘ @@ -128,7 +167,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocumentClick) diff --git a/frontend/src/components/__tests__/AgentBehaviorSection.test.ts b/frontend/src/components/__tests__/AgentBehaviorSection.test.ts new file mode 100644 index 00000000..5b625021 --- /dev/null +++ b/frontend/src/components/__tests__/AgentBehaviorSection.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for AgentBehaviorSection — settings toggles (Part 3). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('../../lib/api', () => ({ + api: vi.fn(() => Promise.resolve({ ok: true, json: async () => ({}) })), +})) + +describe('AgentBehaviorSection', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('mounts without error', async () => { + const { default: AgentBehaviorSection } = await import('../AgentBehaviorSection.vue') + const wrapper = mount(AgentBehaviorSection) + expect(wrapper.exists()).toBe(true) + }) + + it('renders "Auto-approve agent actions" toggle', async () => { + const { default: AgentBehaviorSection } = await import('../AgentBehaviorSection.vue') + const wrapper = mount(AgentBehaviorSection) + expect(wrapper.text()).toContain('Auto-approve agent actions') + }) + + it('renders "Show raw tool names" toggle', async () => { + const { default: AgentBehaviorSection } = await import('../AgentBehaviorSection.vue') + const wrapper = mount(AgentBehaviorSection) + expect(wrapper.text()).toContain('Show raw tool names') + }) + + it('toggles reflect store state — both default false', async () => { + const { useAgentSettingsStore } = await import('../../stores/agentSettings') + const store = useAgentSettingsStore() + expect(store.autoApproveUserActions).toBe(false) + expect(store.devModeShowRawTools).toBe(false) + + const { default: AgentBehaviorSection } = await import('../AgentBehaviorSection.vue') + const wrapper = mount(AgentBehaviorSection) + await flushPromises() + + // Toggle buttons should exist — they should reflect OFF state + const toggleButtons = wrapper.findAll('button[role="switch"]') + expect(toggleButtons.length).toBeGreaterThanOrEqual(2) + }) + + it('clicking auto-approve toggle calls store.setAutoApprove', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) + + const { useAgentSettingsStore } = await import('../../stores/agentSettings') + const store = useAgentSettingsStore() + const spy = vi.spyOn(store, 'setAutoApprove').mockResolvedValue() + + const { default: AgentBehaviorSection } = await import('../AgentBehaviorSection.vue') + const wrapper = mount(AgentBehaviorSection) + + const toggleButtons = wrapper.findAll('button[role="switch"]') + await toggleButtons[0].trigger('click') + + expect(spy).toHaveBeenCalledWith(true) + }) + + it('clicking dev-mode toggle calls store.setDevMode', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) + + const { useAgentSettingsStore } = await import('../../stores/agentSettings') + const store = useAgentSettingsStore() + const spy = vi.spyOn(store, 'setDevMode').mockResolvedValue() + + const { default: AgentBehaviorSection } = await import('../AgentBehaviorSection.vue') + const wrapper = mount(AgentBehaviorSection) + + const toggleButtons = wrapper.findAll('button[role="switch"]') + await toggleButtons[1].trigger('click') + + expect(spy).toHaveBeenCalledWith(true) + }) + + it('clicking auto-approve toggle when ON calls setAutoApprove(false)', async () => { + const { useAgentSettingsStore } = await import('../../stores/agentSettings') + const store = useAgentSettingsStore() + store.autoApproveUserActions = true + const spy = vi.spyOn(store, 'setAutoApprove').mockResolvedValue() + + const { default: AgentBehaviorSection } = await import('../AgentBehaviorSection.vue') + const wrapper = mount(AgentBehaviorSection) + + const toggleButtons = wrapper.findAll('button[role="switch"]') + await toggleButtons[0].trigger('click') + + expect(spy).toHaveBeenCalledWith(false) + }) +}) diff --git a/frontend/src/components/__tests__/AgentPicker.test.ts b/frontend/src/components/__tests__/AgentPicker.test.ts index c4502541..8ebeb5c6 100644 --- a/frontend/src/components/__tests__/AgentPicker.test.ts +++ b/frontend/src/components/__tests__/AgentPicker.test.ts @@ -69,10 +69,12 @@ describe('AgentPicker', () => { }) it('shows trial badge on 2.5 option', async () => { + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + store.setTrialRemaining(7) + const { default: AgentPicker } = await import('../AgentPicker.vue') - const wrapper = mount(AgentPicker, { - props: { trialMessagesLeft: 7 }, - }) + const wrapper = mount(AgentPicker) await wrapper.find('button').trigger('click') await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('7') @@ -151,8 +153,12 @@ describe('AgentPicker', () => { const authStore = useAuthStore() authStore.user = { user_id: 'u1', username: 'test', is_admin: false, is_paid: false } + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const pickerStore = useAgentPickerStore() + pickerStore.setTrialRemaining(7) + const { default: AgentPicker } = await import('../AgentPicker.vue') - const wrapper = mount(AgentPicker, { props: { trialMessagesLeft: 7 } }) + const wrapper = mount(AgentPicker) await wrapper.find('button').trigger('click') await wrapper.vm.$nextTick() @@ -165,8 +171,12 @@ describe('AgentPicker', () => { const authStore = useAuthStore() authStore.user = { user_id: 'u1', username: 'test', is_admin: false, is_paid: true } + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const pickerStore = useAgentPickerStore() + pickerStore.setTrialRemaining(7) + const { default: AgentPicker } = await import('../AgentPicker.vue') - const wrapper = mount(AgentPicker, { props: { trialMessagesLeft: 7 } }) + const wrapper = mount(AgentPicker) await wrapper.find('button').trigger('click') await wrapper.vm.$nextTick() diff --git a/frontend/src/components/__tests__/AgentPickerTrialByok.test.ts b/frontend/src/components/__tests__/AgentPickerTrialByok.test.ts new file mode 100644 index 00000000..d3b56d29 --- /dev/null +++ b/frontend/src/components/__tests__/AgentPickerTrialByok.test.ts @@ -0,0 +1,162 @@ +/** + * Tests for AgentPicker trial counter and BYOK leakage gate UI (Parts 1 & 2). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ path: '/dashboard' }), +})) + +vi.mock('../../lib/api', () => ({ + api: vi.fn(() => Promise.resolve({ ok: true, json: async () => ({}) })), +})) + +describe('AgentPicker — Part 1: live trial counter from store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + it('shows trialMessagesRemaining from store when set', async () => { + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + store.setTrialRemaining(7) + + const { default: AgentPicker } = await import('../AgentPicker.vue') + const wrapper = mount(AgentPicker) + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('7') + expect(wrapper.text()).toContain('trial') + }) + + it('shows default count when trialMessagesRemaining is null (not yet loaded)', async () => { + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + // trialMessagesRemaining defaults to null + expect(store.trialMessagesRemaining).toBeNull() + + const { default: AgentPicker } = await import('../AgentPicker.vue') + const wrapper = mount(AgentPicker) + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + // Should still show the trial badge (with fallback value) + expect(wrapper.text()).toContain('trial') + }) + + it('shows "Upgrade to continue" overlay when trialMessagesRemaining is 0 and user is free', async () => { + const { useAuthStore } = await import('../../stores/auth') + const authStore = useAuthStore() + authStore.user = { user_id: 'u1', username: 'test', is_admin: false, is_paid: false } + + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + store.setTrialRemaining(0) + + const { default: AgentPicker } = await import('../AgentPicker.vue') + const wrapper = mount(AgentPicker) + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Upgrade') + }) + + it('2.5 selection blocked when trialMessagesRemaining is 0 and user is free', async () => { + const { useAuthStore } = await import('../../stores/auth') + const authStore = useAuthStore() + authStore.user = { user_id: 'u1', username: 'test', is_admin: false, is_paid: false } + + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + store.setTrialRemaining(0) + const spy = vi.spyOn(store, 'setAgent') + + const { default: AgentPicker } = await import('../AgentPicker.vue') + const wrapper = mount(AgentPicker) + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + const option25 = wrapper.findAll('[data-agent]').find(o => o.attributes('data-agent') === 'tether-agent-2.5') + expect(option25).toBeDefined() + await option25!.trigger('click') + + // Should not call setAgent since 2.5 is locked + expect(spy).not.toHaveBeenCalled() + }) + + it('premium user sees no trial badge even when trialMessagesRemaining is set', async () => { + const { useAuthStore } = await import('../../stores/auth') + const authStore = useAuthStore() + authStore.user = { user_id: 'u1', username: 'test', is_admin: false, is_paid: true } + + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + store.setTrialRemaining(5) + + const { default: AgentPicker } = await import('../AgentPicker.vue') + const wrapper = mount(AgentPicker) + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('trial') + }) +}) + +describe('AgentPicker — Part 2: BYOK leakage gate', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + it('2.5 is shown as disabled with explanation when provider is leaky', async () => { + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + store.currentProvider = 'openrouter' + + const { default: AgentPicker } = await import('../AgentPicker.vue') + const wrapper = mount(AgentPicker) + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + // Should show the disabled/unavailable state text + const text = wrapper.text() + expect(text).toContain('Unavailable') + }) + + it('2.5 is not shown as disabled when provider is safe', async () => { + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + store.currentProvider = 'anthropic_oauth' + + const { default: AgentPicker } = await import('../AgentPicker.vue') + const wrapper = mount(AgentPicker) + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Unavailable') + }) + + it('clicking 2.5 does nothing when provider is leaky (no modal, no commit)', async () => { + const { useAgentPickerStore } = await import('../../stores/agentPicker') + const store = useAgentPickerStore() + store.currentProvider = 'openai' + store.selectedAgent = 'tether-agent-2.0' + const spy = vi.spyOn(store, 'setAgent') + + const { default: AgentPicker } = await import('../AgentPicker.vue') + const wrapper = mount(AgentPicker) + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + const option25 = wrapper.findAll('[data-agent]').find(o => o.attributes('data-agent') === 'tether-agent-2.5') + await option25!.trigger('click') + + expect(spy).not.toHaveBeenCalled() + expect(store.selectedAgent).toBe('tether-agent-2.0') + }) +}) diff --git a/frontend/src/constants/agentProvider.ts b/frontend/src/constants/agentProvider.ts new file mode 100644 index 00000000..ad03f912 --- /dev/null +++ b/frontend/src/constants/agentProvider.ts @@ -0,0 +1,15 @@ +/** + * Agent provider constants shared between the picker store and settings. + * + * LEAKY_PROVIDERS — providers where premium 2.5 is unavailable because their + * dashboards expose prompt/response content, leaking proprietary intelligence + * design. Matches the backend's interactive_agent_layer config. + */ +export const LEAKY_PROVIDERS = ['openrouter', 'openai'] as const +export type LeakyProvider = (typeof LEAKY_PROVIDERS)[number] + +export const DEFAULT_PROVIDER = 'anthropic_oauth' + +export function isLeakyProvider(provider: string): boolean { + return (LEAKY_PROVIDERS as readonly string[]).includes(provider) +} diff --git a/frontend/src/stores/__tests__/agentPickerTrialByok.test.ts b/frontend/src/stores/__tests__/agentPickerTrialByok.test.ts new file mode 100644 index 00000000..49306538 --- /dev/null +++ b/frontend/src/stores/__tests__/agentPickerTrialByok.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for trial counter (Part 1) and BYOK leakage gate (Part 2) additions + * to useAgentPickerStore. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { setBotTransport } from '../../composables/useBotTransport' +import { makeTransport } from './testHelpers' + +vi.mock('../../lib/api', () => ({ + api: vi.fn(() => Promise.resolve({ ok: false, json: async () => ({}) })), +})) + +// ─── Part 1: trial counter ─────────────────────────────────────────────────── + +describe('useAgentPickerStore — trial counter', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('trialMessagesRemaining defaults to null (not yet loaded)', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + expect(store.trialMessagesRemaining).toBeNull() + }) + + it('setTrialRemaining updates trialMessagesRemaining', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + store.setTrialRemaining(5) + expect(store.trialMessagesRemaining).toBe(5) + }) + + it('setTrialRemaining(0) sets count to 0', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + store.setTrialRemaining(0) + expect(store.trialMessagesRemaining).toBe(0) + }) +}) + +// ─── Part 1: chat store forwards trial_usage_update ────────────────────────── + +describe('useChatStore — trial_usage_update updates agentPicker', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('trial_usage_update event updates agentPickerStore.trialMessagesRemaining', async () => { + setBotTransport(makeTransport([ + { type: 'trial_usage_update', session_id: 'sess1', remaining: 3 }, + { type: 'turn_complete', session_id: 'sess1', final_text: '' }, + ])) + + const { useChatStore } = await import('../chat') + const { useAgentPickerStore } = await import('../agentPicker') + const chatStore = useChatStore() + const pickerStore = useAgentPickerStore() + + await chatStore.send('hi') + + expect(pickerStore.trialMessagesRemaining).toBe(3) + }) + + it('multiple trial_usage_update events keep the last value', async () => { + setBotTransport(makeTransport([ + { type: 'trial_usage_update', session_id: 'sess1', remaining: 5 }, + { type: 'trial_usage_update', session_id: 'sess1', remaining: 4 }, + { type: 'turn_complete', session_id: 'sess1', final_text: 'done' }, + ])) + + const { useChatStore } = await import('../chat') + const { useAgentPickerStore } = await import('../agentPicker') + const chatStore = useChatStore() + const pickerStore = useAgentPickerStore() + + await chatStore.send('hi') + + expect(pickerStore.trialMessagesRemaining).toBe(4) + }) +}) + +// ─── Part 2: BYOK leakage gate ─────────────────────────────────────────────── + +describe('useAgentPickerStore — BYOK leakage gate', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('currentProvider defaults to anthropic_oauth', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + expect(store.currentProvider).toBe('anthropic_oauth') + }) + + it('isLeakyProvider is false for anthropic_oauth', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + expect(store.isLeakyProvider).toBe(false) + }) + + it('isLeakyProvider is true for openrouter', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + store.currentProvider = 'openrouter' + expect(store.isLeakyProvider).toBe(true) + }) + + it('isLeakyProvider is true for openai', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + store.currentProvider = 'openai' + expect(store.isLeakyProvider).toBe(true) + }) + + it('isLeakyProvider is false for anthropic_api', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + store.currentProvider = 'anthropic_api' + expect(store.isLeakyProvider).toBe(false) + }) + + it('setAgent 2.5 is blocked when provider is leaky — no modal opened, agent unchanged', async () => { + const { useAgentPickerStore } = await import('../agentPicker') + const store = useAgentPickerStore() + store.currentProvider = 'openrouter' + store.selectedAgent = 'tether-agent-2.0' + + await store.setAgent('tether-agent-2.5') + + expect(store.selectedAgent).toBe('tether-agent-2.0') + expect(store.showByokModal).toBe(false) + }) +}) diff --git a/frontend/src/stores/__tests__/agentSettings.test.ts b/frontend/src/stores/__tests__/agentSettings.test.ts new file mode 100644 index 00000000..b91be7b4 --- /dev/null +++ b/frontend/src/stores/__tests__/agentSettings.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for useAgentSettingsStore — auto-approve + dev_mode toggles (Part 3). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +vi.mock('../../lib/api', () => ({ + api: vi.fn(() => Promise.resolve({ ok: true, json: async () => ({}) })), +})) + +describe('useAgentSettingsStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('autoApproveUserActions defaults to false', async () => { + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + expect(store.autoApproveUserActions).toBe(false) + }) + + it('devModeShowRawTools defaults to false', async () => { + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + expect(store.devModeShowRawTools).toBe(false) + }) + + it('fetchSettings loads autoApproveUserActions from API', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + auto_approve_user_actions: 'true', + dev_mode_show_raw_tools: 'false', + }), + } as any) + + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + await store.fetchSettings() + + expect(store.autoApproveUserActions).toBe(true) + }) + + it('fetchSettings loads devModeShowRawTools from API', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + auto_approve_user_actions: 'false', + dev_mode_show_raw_tools: 'true', + }), + } as any) + + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + await store.fetchSettings() + + expect(store.devModeShowRawTools).toBe(true) + }) + + it('fetchSettings keeps defaults when API fails', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ ok: false, json: async () => ({}) } as any) + + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + await store.fetchSettings() + + expect(store.autoApproveUserActions).toBe(false) + expect(store.devModeShowRawTools).toBe(false) + }) + + it('setAutoApprove(true) PUTs to /api/settings/auto_approve_user_actions', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) + + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + await store.setAutoApprove(true) + + expect(vi.mocked(api)).toHaveBeenCalledWith( + '/api/settings/auto_approve_user_actions', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ value: 'true' }), + }), + ) + expect(store.autoApproveUserActions).toBe(true) + }) + + it('setAutoApprove(false) PUTs false value', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) + + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + store.autoApproveUserActions = true + await store.setAutoApprove(false) + + expect(store.autoApproveUserActions).toBe(false) + }) + + it('setAutoApprove rolls back on API failure', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ ok: false, json: async () => ({}) } as any) + + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + store.autoApproveUserActions = false + await store.setAutoApprove(true) + + expect(store.autoApproveUserActions).toBe(false) + }) + + it('setDevMode(true) PUTs to /api/settings/dev_mode_show_raw_tools', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) + + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + await store.setDevMode(true) + + expect(vi.mocked(api)).toHaveBeenCalledWith( + '/api/settings/dev_mode_show_raw_tools', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ value: 'true' }), + }), + ) + expect(store.devModeShowRawTools).toBe(true) + }) + + it('setDevMode rolls back on API failure', async () => { + const { api } = await import('../../lib/api') + vi.mocked(api).mockResolvedValueOnce({ ok: false, json: async () => ({}) } as any) + + const { useAgentSettingsStore } = await import('../agentSettings') + const store = useAgentSettingsStore() + store.devModeShowRawTools = false + await store.setDevMode(true) + + expect(store.devModeShowRawTools).toBe(false) + }) +}) diff --git a/frontend/src/stores/agentPicker.ts b/frontend/src/stores/agentPicker.ts index 635ee80e..4c56555c 100644 --- a/frontend/src/stores/agentPicker.ts +++ b/frontend/src/stores/agentPicker.ts @@ -1,7 +1,8 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, computed } from 'vue' import { api } from '../lib/api' import { useAuthStore } from './auth' +import { isLeakyProvider as checkLeaky, DEFAULT_PROVIDER } from '../constants/agentProvider' const AGENT_VERSIONS = ['tether-agent-1.0', 'tether-agent-2.0', 'tether-agent-2.5'] as const export type AgentVersion = (typeof AGENT_VERSIONS)[number] @@ -19,6 +20,20 @@ export const useAgentPickerStore = defineStore('agentPicker', () => { // Holds the pending 2.5 selection while the BYOK modal awaits confirmation. const pendingAgent = ref(null) + // Trial counter: null = not yet received from server (show default); 0 = exhausted. + const trialMessagesRemaining = ref(null) + + // Provider: defaults to anthropic_oauth (safe). Set from user settings when available. + const currentProvider = ref(DEFAULT_PROVIDER) + + // True when the current provider leaks premium 2.5 internals. + const isLeakyProvider = computed(() => checkLeaky(currentProvider.value)) + + /** Called by chat store when a trial_usage_update WS event arrives. */ + function setTrialRemaining(remaining: number): void { + trialMessagesRemaining.value = remaining + } + async function fetchPreference(): Promise { try { const resp = await api('/api/settings') @@ -28,6 +43,10 @@ export const useAgentPickerStore = defineStore('agentPicker', () => { if (isValidAgent(val)) { selectedAgent.value = val } + // Load provider if present (future: user sets this in settings) + if (typeof data.current_provider === 'string') { + currentProvider.value = data.current_provider + } } catch { // Network failure — keep default } @@ -54,12 +73,19 @@ export const useAgentPickerStore = defineStore('agentPicker', () => { /** * Select an agent version. * - * For tether-agent-2.5 on free users: opens the BYOK confirmation modal - * WITHOUT committing. The selection is only persisted after confirmByokModal(). + * For tether-agent-2.5 on leaky providers: silently blocked — provider + * cannot run 2.5 due to IP-leakage policy. Picker handles the UI state. + * + * For tether-agent-2.5 on free users (safe provider): opens the BYOK + * confirmation modal WITHOUT committing. The selection is only persisted + * after confirmByokModal(). * * For premium users (is_paid) or any other version: commits immediately. */ async function setAgent(version: AgentVersion): Promise { + // Leaky provider gate — silently block 2.5 regardless of tier. + if (version === 'tether-agent-2.5' && isLeakyProvider.value) return + const isPremium = useAuthStore().user?.is_paid ?? false if (version === 'tether-agent-2.5' && !isPremium) { @@ -89,6 +115,10 @@ export const useAgentPickerStore = defineStore('agentPicker', () => { selectedAgent, showByokModal, pendingAgent, + trialMessagesRemaining, + currentProvider, + isLeakyProvider, + setTrialRemaining, fetchPreference, setAgent, confirmByokModal, diff --git a/frontend/src/stores/agentSettings.ts b/frontend/src/stores/agentSettings.ts new file mode 100644 index 00000000..2a9d6a80 --- /dev/null +++ b/frontend/src/stores/agentSettings.ts @@ -0,0 +1,63 @@ +import { defineStore } from 'pinia' +import { ref, type Ref } from 'vue' +import { api } from '../lib/api' + +/** + * Stores user-configurable agent behaviour toggles: + * - autoApproveUserActions: skip per-tool confirmation for user_action tools + * - devModeShowRawTools: show raw tool names alongside friendly phrases in chat + * + * Call fetchSettings() once on app init or when the settings page mounts. + */ +export const useAgentSettingsStore = defineStore('agentSettings', () => { + const autoApproveUserActions = ref(false) + const devModeShowRawTools = ref(false) + + async function fetchSettings(): Promise { + try { + const resp = await api('/api/settings') + if (!resp.ok) return + const data = await resp.json() + if (data.auto_approve_user_actions !== undefined) { + autoApproveUserActions.value = data.auto_approve_user_actions === 'true' + } + if (data.dev_mode_show_raw_tools !== undefined) { + devModeShowRawTools.value = data.dev_mode_show_raw_tools === 'true' + } + } catch { + // Network failure — keep defaults + } + } + + /** Optimistically persist a boolean setting; rollback on failure. */ + async function commitToggle(key: string, target: Ref, enabled: boolean): Promise { + const previous = target.value + target.value = enabled + try { + const resp = await api(`/api/settings/${key}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: String(enabled) }), + }) + if (!resp.ok) target.value = previous + } catch { + target.value = previous + } + } + + function setAutoApprove(enabled: boolean): Promise { + return commitToggle('auto_approve_user_actions', autoApproveUserActions, enabled) + } + + function setDevMode(enabled: boolean): Promise { + return commitToggle('dev_mode_show_raw_tools', devModeShowRawTools, enabled) + } + + return { + autoApproveUserActions, + devModeShowRawTools, + fetchSettings, + setAutoApprove, + setDevMode, + } +}) diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index b5590b74..302a4aa5 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -77,7 +77,7 @@ export const useChatStore = defineStore('chat', () => { isSessionActive.value = false return case 'trial_usage_update': - // handled elsewhere (trial counter UI) + useAgentPickerStore().setTrialRemaining(event.remaining) break } } diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index c27d5e7e..88dce1a1 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -8,6 +8,7 @@ import AnthropicAccountSection from '../components/AnthropicAccountSection.vue' import ConnectionsSection from '../components/ConnectionsSection.vue' import ApiKeysSection from '../components/ApiKeysSection.vue' import ICalSection from '../components/ICalSection.vue' +import AgentBehaviorSection from '../components/AgentBehaviorSection.vue' const auth = useAuthStore() @@ -387,6 +388,9 @@ async function linkTelegram() {
+ + +

LLM Configuration

From 922e8b8e667acf06b56194f45eb73a37e799ed5f Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 11:17:48 -0700 Subject: [PATCH 006/168] feat(frontend): add Chat nav link + Phase J side chat panel - Add /chat router-link to main nav alongside Dashboard/Calendar/etc. - New SideChatPanel.vue replaces BotChat in App.vue side drawer - Compact split layout: 160px ConversationList + flex-1 ConversationView - Emits close on button click or Escape - App.vue: Ctrl+/ / Cmd+/ keyboard shortcut toggles panel - PermissionModal mounted at App level (global, not inside side panel) - Tests: SideChatPanel unit tests, AppNav link + keyboard shortcut tests --- frontend/src/App.vue | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 42a6c499..589cdfa3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,11 +1,12 @@ From c1ededaf09e0397825286c941ab03164980692f1 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 12:01:05 -0700 Subject: [PATCH 007/168] Wire A6 control protocol into interactive-agent-layer session PermissionGate was structurally dormant: control_request SSE events from the pool were never consumed, so all tool calls bypassed policy enforcement. Now run_turn inspects each event before passing to _translate_event. control_request events are routed through PermissionGate.can_use_tool, and the decision is forwarded to the pool via PoolClient.send_control_response. Stale-request 404s (pool already timed out and denied) are swallowed so slow-user scenarios complete cleanly. control_timeout events are skipped silently. The dormant TODO(pool-permissions) comment is removed. Adds 8 tests: - background tool auto-allow - user_action + auto_approve=True, allow without WS prompt - user_action + permission_request WS event, user approves, allow - user_action + user denies, deny - control_timeout ignored, no send_control_response - stale 404 on send_control_response swallowed, turn completes - multiple control_requests in one turn - integration: real PoolClient + FastAPI ControlBridge in-process --- interactive_agent_layer/session.py | 73 ++- .../test_control_protocol.py | 520 ++++++++++++++++++ 2 files changed, 583 insertions(+), 10 deletions(-) create mode 100644 tests/interactive_agent_layer/test_control_protocol.py diff --git a/interactive_agent_layer/session.py b/interactive_agent_layer/session.py index 2bb25a66..4781bdcb 100644 --- a/interactive_agent_layer/session.py +++ b/interactive_agent_layer/session.py @@ -4,6 +4,7 @@ import dataclasses import hashlib import json +import logging import time import uuid from collections.abc import Callable @@ -11,8 +12,11 @@ from interactive_agent_layer.coalescing import CoalescingBuffer from interactive_agent_layer.config import get_auto_approve_user_actions -from interactive_agent_layer.permissions import PermissionGate -from interactive_agent_layer.pool_client import PoolClient +from interactive_agent_layer.permissions import ( + PermissionGate, + PermissionResultAllow, +) +from interactive_agent_layer.pool_client import PoolClient, PoolClientError from interactive_agent_layer.translation import ( BackgroundEntry, BackgroundHiddenEntry, @@ -22,6 +26,8 @@ ) from interactive_agent_layer.ws_publisher import WSPublisher +log = logging.getLogger(__name__) + @dataclasses.dataclass class Session: @@ -98,20 +104,17 @@ async def run_turn(self, session_id: str, prompt: str) -> AsyncIterator[dict]: session = self.sessions[session_id] # raises KeyError if missing options_hash = _stable_options_hash(session.options) - # TODO(pool-permissions): PermissionGate is constructed here but its - # can_use_tool callback cannot be passed to query_stream — the real - # PoolClient uses SSE (one-way server push) and has no mechanism to - # intercept tool execution before it happens. The gate's auto-approve - # path still functions correctly for event translation; the user_action - # deny path is dormant until the pool protocol gains a control-message - # channel. Tracked as a follow-up: pool control-protocol extension. + # PermissionGate routes pool control_request SSE events through policy: + # background/passthrough → auto-allow; user_action → prompt user or + # auto-approve based on config. Decisions are forwarded back to the + # pool via send_control_response, which unblocks the pool's can_use_tool + # callback and lets the SDK proceed or skip the tool call. gate = PermissionGate( translation_table=self.translation_table, ws_publisher=self.ws_publisher, session=session, auto_approve_user_actions=get_auto_approve_user_actions(), ) - _ = gate # gate built for future wiring; currently dormant (see TODO above) handle = await self.pool_client.acquire( session.user_id, options_hash, session.options @@ -121,6 +124,18 @@ async def run_turn(self, session_id: str, prompt: str) -> AsyncIterator[dict]: async for sdk_event in self.pool_client.query_stream( handle, prompt, session_id=session_id ): + # Pool blocks its can_use_tool callback until we respond — + # process control events inline before translating other events. + if sdk_event.get("event") == "control_request": + await _handle_control_request( + sdk_event, handle, gate, self.pool_client + ) + continue + + if sdk_event.get("event") == "control_timeout": + # Pool already denied the tool call; nothing to do. + continue + async for layer_event in _translate_event( session_id, sdk_event, self.translation_table, session ): @@ -140,6 +155,44 @@ async def interrupt(self, session_id: str) -> None: await self.pool_client.interrupt(handle) +async def _handle_control_request( + event: dict, + handle_id: str, + gate: PermissionGate, + pool_client: PoolClient, +) -> None: + """Route a pool control_request through PermissionGate and send the decision back. + + If the pool already timed out and resolved the request before we respond, + send_control_response returns 404 — we swallow that specific error so the + turn can complete normally. + """ + tool_name = event.get("tool_name", "") + tool_input = event.get("tool_input", {}) + request_id = event.get("request_id", "") + subtype = event.get("subtype", "can_use_tool") + + result = await gate.can_use_tool(tool_name, tool_input, None) + decision = "allow" if isinstance(result, PermissionResultAllow) else "deny" + denial_message = getattr(result, "reason", None) if decision == "deny" else None + + try: + await pool_client.send_control_response( + handle_id, + request_id=request_id, + subtype=subtype, + decision=decision, + denial_message=denial_message, + ) + except PoolClientError as exc: + # 404 means pool already timed out and denied this request — safe to ignore. + log.debug( + "send_control_response ignored (pool already resolved): %s request_id=%s", + exc, + request_id, + ) + + async def _translate_event( session_id: str, sdk_event: dict, diff --git a/tests/interactive_agent_layer/test_control_protocol.py b/tests/interactive_agent_layer/test_control_protocol.py new file mode 100644 index 00000000..efb2651e --- /dev/null +++ b/tests/interactive_agent_layer/test_control_protocol.py @@ -0,0 +1,520 @@ +"""Tests for A6 control-protocol wire-up in interactive_agent_layer.session. + +Covers: +- control_request for background tool → auto-allow → send_control_response("allow") +- control_request for user_action tool with auto_approve=True → allow +- control_request for user_action tool → permission_request WS event + user approves → allow +- control_request for user_action tool → user denies → deny +- control_timeout event is silently skipped (pool already denied, no send_control_response) +- stale-request 404 on send_control_response is swallowed, turn completes normally +- Round-trip integration: real PoolClient ↔ FastAPI ↔ ControlBridge in-process +""" +from __future__ import annotations + +import asyncio +import pathlib +from contextlib import asynccontextmanager +from types import SimpleNamespace +from typing import Any, AsyncIterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient, ASGITransport + +import pytest + +from interactive_agent_layer.permissions import PermissionResultAllow, PermissionResultDeny +from interactive_agent_layer.session import Layer, Session +from interactive_agent_layer.ws_publisher import WSPublisher + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def patch_permission_timeout(monkeypatch): + """Avoid loading the real config (needs jwt.secret) when PermissionGate runs.""" + monkeypatch.setattr( + "interactive_agent_layer.permissions.get_permission_timeout", + lambda: 30, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_YAML_PATH = ( + pathlib.Path(__file__).parent.parent.parent / "config" / "agent_translations.yaml" +) + + +def _make_layer( + pool_client, + ws_publisher: WSPublisher | None = None, +) -> Layer: + """Build a Layer with TranslationTable loaded from config. + + get_auto_approve_user_actions is patched globally to False by the conftest + autouse fixture. Tests that need auto_approve=True must patch it themselves + around the run_turn call. + """ + from interactive_agent_layer.translation import TranslationTable + + if ws_publisher is None: + ws_publisher = WSPublisher() + + return Layer( + pool_client=pool_client, + ws_publisher=ws_publisher, + translation_table=TranslationTable.from_yaml(_YAML_PATH), + ) + + +class _BasePool: + """Base class for test pool clients.""" + + _send_control_calls: list[dict] + + def __init__(self): + self._send_control_calls = [] + + async def acquire( + self, user_id: str, options_hash: str, options: dict, timeout_seconds=None + ) -> str: + return "test-handle" + + async def release(self, handle_id: str, *, reusable: bool = False) -> None: + pass + + async def interrupt(self, handle_id: str) -> None: + pass + + async def send_control_response( + self, + handle_id: str, + *, + request_id: str, + subtype: str, + decision: str, + denial_message: str | None = None, + ) -> None: + self._send_control_calls.append( + { + "handle_id": handle_id, + "request_id": request_id, + "subtype": subtype, + "decision": decision, + "denial_message": denial_message, + } + ) + + +# --------------------------------------------------------------------------- +# Test 1: background tool → auto-allow +# --------------------------------------------------------------------------- + +async def test_control_request_background_tool_auto_allows(): + """control_request for get_anchors (background) → send_control_response("allow").""" + + class _Pool(_BasePool): + async def query_stream(self, handle_id, prompt, session_id="default"): + yield { + "event": "control_request", + "request_id": "req-bg-001", + "subtype": "can_use_tool", + "tool_name": "get_anchors", + "tool_input": {}, + } + yield {"type": "result", "final_text": "done", "tokens_used": 1} + + pool = _Pool() + layer = _make_layer(pool) + + s = layer.create_session("user1", "ws1", "v1", {}) + events = [e async for e in layer.run_turn(s.session_id, "hi")] + + assert len(pool._send_control_calls) == 1 + call = pool._send_control_calls[0] + assert call["handle_id"] == "test-handle" + assert call["request_id"] == "req-bg-001" + assert call["subtype"] == "can_use_tool" + assert call["decision"] == "allow" + + # control_request should NOT produce a layer event + types = [e["type"] for e in events] + assert "turn_complete" in types + assert "permission_request" not in types + + +# --------------------------------------------------------------------------- +# Test 2: user_action tool + auto_approve=True → allow without WS prompt +# --------------------------------------------------------------------------- + +async def test_control_request_user_action_auto_approve_allows(): + """user_action tool with auto_approve=True → allow, no permission_request WS event.""" + + published: list[dict] = [] + + class _Pool(_BasePool): + async def query_stream(self, handle_id, prompt, session_id="default"): + yield { + "event": "control_request", + "request_id": "req-ua-auto", + "subtype": "can_use_tool", + "tool_name": "upsert_tasks", + "tool_input": {"tasks": [], "count": 0}, + } + yield {"type": "result", "final_text": "done", "tokens_used": 1} + + pool = _Pool() + ws = WSPublisher() + ws.push = AsyncMock(side_effect=lambda ws_id, event: published.append(event)) + layer = _make_layer(pool, ws) + + s = layer.create_session("user1", "ws1", "v1", {}) + + # Override the conftest autouse patch: auto_approve=True for this turn + with patch( + "interactive_agent_layer.session.get_auto_approve_user_actions", + return_value=True, + ): + await _consume(layer.run_turn(s.session_id, "hi")) + + assert len(pool._send_control_calls) == 1 + assert pool._send_control_calls[0]["decision"] == "allow" + perm_events = [e for e in published if e.get("type") == "permission_request"] + assert perm_events == [], "auto_approve must not emit permission_request" + + +# --------------------------------------------------------------------------- +# Test 3: user_action tool, user approves → allow + permission_request WS event +# --------------------------------------------------------------------------- + +async def test_control_request_user_action_user_approves(): + """user_action tool: permission_request WS event emitted, user approves → allow.""" + + published: list[dict] = [] + session_holder: list[Session] = [] + + class _Pool(_BasePool): + async def query_stream(self, handle_id, prompt, session_id="default"): + yield { + "event": "control_request", + "request_id": "req-ua-approve", + "subtype": "can_use_tool", + "tool_name": "upsert_tasks", + "tool_input": {"tasks": ["buy milk"], "count": 1}, + } + yield {"type": "result", "final_text": "done", "tokens_used": 1} + + async def _ws_push(ws_id: str, event: dict) -> None: + published.append(event) + if event.get("type") == "permission_request": + # Simulate user approving immediately + request_id = event["request_id"] + sess = session_holder[0] + fut = sess.permission_pending.get(request_id) + if fut is not None and not fut.done(): + fut.set_result(True) + + pool = _Pool() + ws = WSPublisher() + ws.push = _ws_push + layer = _make_layer(pool, ws) + + s = layer.create_session("user1", "ws1", "v1", {}) + session_holder.append(s) + + await _consume(layer.run_turn(s.session_id, "hi")) + + perm_events = [e for e in published if e.get("type") == "permission_request"] + assert len(perm_events) == 1 + assert perm_events[0]["summary"] == "Update 1 tasks" + + assert len(pool._send_control_calls) == 1 + assert pool._send_control_calls[0]["decision"] == "allow" + + +# --------------------------------------------------------------------------- +# Test 4: user_action tool, user denies → deny +# --------------------------------------------------------------------------- + +async def test_control_request_user_action_user_denies(): + """user_action tool: user denies → send_control_response("deny").""" + + session_holder: list[Session] = [] + + class _Pool(_BasePool): + async def query_stream(self, handle_id, prompt, session_id="default"): + yield { + "event": "control_request", + "request_id": "req-ua-deny", + "subtype": "can_use_tool", + "tool_name": "upsert_tasks", + "tool_input": {"tasks": [], "count": 0}, + } + yield {"type": "result", "final_text": "done", "tokens_used": 1} + + async def _ws_push(ws_id: str, event: dict) -> None: + if event.get("type") == "permission_request": + request_id = event["request_id"] + sess = session_holder[0] + fut = sess.permission_pending.get(request_id) + if fut is not None and not fut.done(): + fut.set_result(False) + + pool = _Pool() + ws = WSPublisher() + ws.push = _ws_push + layer = _make_layer(pool, ws) + + s = layer.create_session("user1", "ws1", "v1", {}) + session_holder.append(s) + + await _consume(layer.run_turn(s.session_id, "hi")) + + assert len(pool._send_control_calls) == 1 + assert pool._send_control_calls[0]["decision"] == "deny" + + +# --------------------------------------------------------------------------- +# Test 5: control_timeout is silently skipped +# --------------------------------------------------------------------------- + +async def test_control_timeout_is_silently_skipped(): + """control_timeout SSE event is ignored — no send_control_response, turn completes.""" + + class _Pool(_BasePool): + async def query_stream(self, handle_id, prompt, session_id="default"): + yield {"event": "control_timeout", "request_id": "req-timeout-001"} + yield {"type": "result", "final_text": "done", "tokens_used": 1} + + pool = _Pool() + layer = _make_layer(pool) + + s = layer.create_session("user1", "ws1", "v1", {}) + events = await _consume_list(layer.run_turn(s.session_id, "hi")) + + assert pool._send_control_calls == [] + types = [e["type"] for e in events] + assert "turn_complete" in types + + +# --------------------------------------------------------------------------- +# Test 6: stale 404 on send_control_response is swallowed +# --------------------------------------------------------------------------- + +async def test_control_request_stale_404_is_swallowed(): + """If pool times out before layer responds, send_control_response raises PoolClientError. + The layer must swallow it and complete the turn normally.""" + from agent_pool_manager.client import PoolClientError + + class _Pool(_BasePool): + async def query_stream(self, handle_id, prompt, session_id="default"): + yield { + "event": "control_request", + "request_id": "req-stale", + "subtype": "can_use_tool", + "tool_name": "get_anchors", + "tool_input": {}, + } + yield {"type": "result", "final_text": "done", "tokens_used": 1} + + async def send_control_response(self, handle_id, *, request_id, subtype, decision, denial_message=None): + raise PoolClientError("HTTP 404: request_id 'req-stale' not found or already resolved") + + pool = _Pool() + layer = _make_layer(pool) + + s = layer.create_session("user1", "ws1", "v1", {}) + # Must not raise — stale 404 should be logged and swallowed + events = await _consume_list(layer.run_turn(s.session_id, "hi")) + types = [e["type"] for e in events] + assert "turn_complete" in types + + +# --------------------------------------------------------------------------- +# Test 7: multiple control_requests in one turn +# --------------------------------------------------------------------------- + +async def test_multiple_control_requests_in_one_turn(): + """Two control_request events in sequence both get responses.""" + + class _Pool(_BasePool): + async def query_stream(self, handle_id, prompt, session_id="default"): + yield { + "event": "control_request", + "request_id": "req-001", + "subtype": "can_use_tool", + "tool_name": "get_anchors", + "tool_input": {}, + } + yield { + "event": "control_request", + "request_id": "req-002", + "subtype": "can_use_tool", + "tool_name": "get_context", + "tool_input": {}, + } + yield {"type": "result", "final_text": "done", "tokens_used": 1} + + pool = _Pool() + layer = _make_layer(pool) + + s = layer.create_session("user1", "ws1", "v1", {}) + await _consume(layer.run_turn(s.session_id, "hi")) + + assert len(pool._send_control_calls) == 2 + request_ids = {c["request_id"] for c in pool._send_control_calls} + assert request_ids == {"req-001", "req-002"} + for call in pool._send_control_calls: + assert call["decision"] == "allow" + + +# --------------------------------------------------------------------------- +# Integration test: real PoolClient ↔ FastAPI ↔ real ControlBridge in-process +# --------------------------------------------------------------------------- + +async def test_integration_control_protocol_round_trip(): + """Real PoolClient → FastAPI pool service → ControlBridge → control_response. + + Uses a fake Pool that injects a can_use_tool callback mid-query via + ControlBridge.request(). No real Claude subprocess needed. + + Architecture note on ASGITransport: + httpx's ASGITransport buffers the entire streaming response before + delivering any chunks to the caller. This means the SSE stream (running + inside ``await app(scope, receive, send)``) must complete before + ``query_stream`` can yield any events. Consequently a second HTTP call + (``send_control_response``) cannot complete while the SSE stream is + running — doing so would deadlock. + + Workaround: we resolve the bridge future directly (``bridge.respond``) from + the main task while the stream is blocked, allowing the ASGI app to + complete. The layer still processes the buffered ``control_request`` event + and calls ``send_control_response``, which gets a 404 (bridge already + resolved) — the layer's stale-request handling is tested by + ``test_control_request_stale_404_is_swallowed``. + + This test verifies: + - Pool emits ``control_request`` SSE event with correct shape + - Bridge future resolves with the expected decision + - Turn completes normally (``turn_complete`` event produced) + - ``send_control_response`` 404 is silently swallowed (no exception) + """ + from agent_pool_manager.client import PoolClient + from agent_pool_manager.control import ControlBridge + from agent_pool_manager.server import build_app + + bridge = ControlBridge(timeout_seconds=5.0) + handle_id = "int-handle-001" + tool_decision: list[str] = [] + + # ------------------------------------------------------------------ + # Fake subprocess: receive_response triggers a bridge permission request + # mid-stream then yields a result message. + # ------------------------------------------------------------------ + class _FakeSubproc: + async def query(self, prompt: str, session_id: str = "default") -> None: + pass + + async def receive_response(self) -> AsyncIterator[Any]: + resp = await bridge.request( + handle_id, + "can_use_tool", + {"tool_name": "get_anchors", "tool_input": {}}, + ) + tool_decision.append(resp.get("decision", "unknown")) + # Use a dict-backed object so _serialise_msg(msg) returns + # {"type": "result", ...} — vars() returns {} for class-attr objects. + result_obj = SimpleNamespace(type="result", final_text="ok", tokens_used=1) + yield result_obj + + fake_subproc = _FakeSubproc() + _SubHolder = type("Sub", (), {"proc": fake_subproc}) + + # ------------------------------------------------------------------ + # Fake Pool with the minimal surface server.py accesses. + # ------------------------------------------------------------------ + class _FakePool: + def __init__(self): + self._lock = asyncio.Lock() + self._active: dict = {handle_id: _SubHolder()} + self.control_bridge = bridge + + async def acquire(self, *a, **kw): + return handle_id, {"ready_at": "2026-01-01T00:00:00Z"} + + async def release(self, *a, **kw): + pass + + async def interrupt(self, *a, **kw): + pass + + def status(self): + return {} + + fake_pool = _FakePool() + + # set app.state directly — lifespan does not fire with ASGITransport + app = build_app() + app.state.pool = fake_pool + app.state.refill = MagicMock() + + transport = ASGITransport(app=app) + pool_client = PoolClient(base_url="http://test", _transport=transport) + ws_publisher = WSPublisher() + layer = _make_layer(pool_client, ws_publisher) + + s = layer.create_session("user1", "ws1", "v1", {}) + events: list[dict] = [] + + async def _run_turn(): + async for e in layer.run_turn(s.session_id, "hi"): + events.append(e) + + # Run the turn as a background task so we can concurrently inject + # the bridge response from this task. + turn_task = asyncio.create_task(_run_turn()) + + # Yield repeatedly until the bridge has a pending request (meaning + # _run_sdk is blocked inside bridge.request waiting for a decision). + for _ in range(200): # up to 2 seconds in 10ms steps + await asyncio.sleep(0.01) + if bridge._pending: + break + + assert bridge._pending, "bridge._pending must have an entry (bridge.request was called)" + + # Resolve the bridge future directly: get_anchors is a background tool → allow. + request_id = next(iter(bridge._pending)) + resolved = bridge.respond(request_id, {"decision": "allow"}) + assert resolved, "bridge.respond must return True" + + # Wait for the turn to complete (stream completes, layer processes events) + await asyncio.wait_for(turn_task, timeout=5.0) + + # Bridge callback returned "allow" + assert tool_decision == ["allow"], f"Expected ['allow'], got {tool_decision}" + + # Turn produced a turn_complete event + types = [e["type"] for e in events] + assert "turn_complete" in types + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _consume(ait) -> None: + async for _ in ait: + pass + + +async def _consume_list(ait) -> list[dict]: + events = [] + async for e in ait: + events.append(e) + return events From 9b415b2b8bd8c4122297db2abf4fec9cbd0b9253 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 12:02:28 -0700 Subject: [PATCH 008/168] refactor(frontend): collapse App.vue nav links into v-for Extract the seven near-identical blocks into a navLinks array with an isActive() helper. Behavior preserved exactly: - /plan, /dashboard, /calendar, /kanban, /chat use prefix matching - /context, /anchors use exact matching (exact: true) - Drops redundant active-class attributes that duplicated the :class active-state logic on /plan, /context, /anchors All 736 frontend tests pass. --- frontend/src/App.vue | 61 +++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 589cdfa3..15e0f87b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,6 +13,22 @@ const authStore = useAuthStore() const router = useRouter() const { activeMode, setMode } = useTheme() +type NavLink = { to: string; label: string; match: string; exact?: boolean } + +const navLinks: NavLink[] = [ + { to: '/dashboard', label: 'Dashboard', match: '/dashboard' }, + { to: '/calendar', label: 'Calendar', match: '/calendar' }, + { to: '/plan/day', label: 'Plan', match: '/plan' }, + { to: '/context', label: 'Context', match: '/context', exact: true }, + { to: '/anchors', label: 'Anchors', match: '/anchors', exact: true }, + { to: '/kanban', label: 'Kanban', match: '/kanban' }, + { to: '/chat', label: 'Chat', match: '/chat' }, +] + +function isActive(link: NavLink, path: string): boolean { + return link.exact ? path === link.match : path.startsWith(link.match) +} + function toggleMode() { setMode(activeMode.value === 'dark' ? 'light' : 'dark') } @@ -59,43 +75,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
Tether
- - Dashboard - - - Calendar - - - Plan - - - Context - - - Anchors - - - Kanban - - - Chat + + {{ link.label }}
From be66c14f86c70c2c9b67c7088f4fb7768accd70f Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 12:02:53 -0700 Subject: [PATCH 009/168] refactor: simplify A6 control protocol test imports --- tests/interactive_agent_layer/test_control_protocol.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/interactive_agent_layer/test_control_protocol.py b/tests/interactive_agent_layer/test_control_protocol.py index efb2651e..60a9d27a 100644 --- a/tests/interactive_agent_layer/test_control_protocol.py +++ b/tests/interactive_agent_layer/test_control_protocol.py @@ -13,17 +13,13 @@ import asyncio import pathlib -from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any, AsyncIterator from unittest.mock import AsyncMock, MagicMock, patch import pytest -from httpx import AsyncClient, ASGITransport +from httpx import ASGITransport -import pytest - -from interactive_agent_layer.permissions import PermissionResultAllow, PermissionResultDeny from interactive_agent_layer.session import Layer, Session from interactive_agent_layer.ws_publisher import WSPublisher From a03538dc673ba9c982b83d2c7527328e25908c6a Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 14:37:39 -0700 Subject: [PATCH 010/168] feat(bot): add event_fn streaming and fix LayerClient incremental SSE Wire agent_text_delta and future layer events (permission_request, etc.) from the interactive-agent-layer SSE stream to the WS client via a new async event_fn callback. Text deltas arrive as individual chunk frames, giving browsers incremental rendering without waiting for turn_complete. LayerClient.turn() previously buffered the entire SSE response before yielding any events; it now drains complete SSE blocks as they arrive, enabling true streaming delivery. dispatch_message() and _dispatch_v2_0() gain an event_fn parameter. bot_chat wires it to forward agent_text_delta as chunk frames and other event types as-is. send_fn is skipped at turn_complete when deltas were already streamed to prevent duplicate response content. 610 tests pass. --- api/routes/bot.py | 24 ++++++ bot/agent_dispatch.py | 48 ++++++++--- interactive_agent_layer/client.py | 18 +++- tests/api/test_agent_dispatch_ws.py | 85 +++++++++++++++++- tests/bot/test_agent_dispatch.py | 129 ++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 20 deletions(-) diff --git a/api/routes/bot.py b/api/routes/bot.py index 29594b0a..28e411f3 100644 --- a/api/routes/bot.py +++ b/api/routes/bot.py @@ -254,6 +254,29 @@ async def status_fn(msg: str) -> None: ) raise # Let the session task propagate disconnect to the outer handler + # Async event callback for streamed layer events (text deltas, + # permission requests, etc.). agent_text_delta events are sent + # immediately as chunk frames so the browser can render them + # incrementally. Other event types are forwarded as-is. + # When any delta is sent, dispatch skips the final send_fn call + # at turn_complete to prevent duplicating the response content. + async def event_fn(event: dict) -> None: + try: + etype = event.get("type") + if etype == "agent_text_delta": + delta = event.get("delta", "") + if delta: + await websocket.send_json({"type": "chunk", "content": delta}) + else: + await websocket.send_json(event) + except Exception as e: + logger.debug( + "bot_chat: event_fn send failed (client likely disconnected)," + " user_id=%s: %s", + user_id, e, + ) + raise + # Capture responses delivered via send_fn. handle_message always # calls send_fn(final) and returns None — the return value is not # used for response delivery. This list is local to each message @@ -274,6 +297,7 @@ def capture_send_fn(msg: str) -> None: user_id=user_id, vault=getattr(websocket.app.state, "vault", None), status_fn=status_fn, + event_fn=event_fn, ) ) diff --git a/bot/agent_dispatch.py b/bot/agent_dispatch.py index b2af84f4..9dd6ed32 100644 --- a/bot/agent_dispatch.py +++ b/bot/agent_dispatch.py @@ -80,12 +80,15 @@ async def _dispatch_v2_0( user_id: str, vault: Any = None, status_fn: Any = None, + event_fn: Any = None, ) -> None: """Run the tether-agent-2.0 pipeline via the interactive-agent-layer. - Starts a layer session, runs one turn, forwards status/action events to the - WS client via status_fn, and delivers the final response via send_fn when - turn_complete arrives. + Starts a layer session, runs one turn, and routes events: + - agent_text_delta / unknown event types → event_fn (async, for direct WS + forwarding; skips send_fn at turn_complete if any delta was sent) + - status / agent_action → status_fn + - turn_complete → send_fn(final_text) unless deltas were already streamed Falls back to handle_message (1.0 pipeline) when: - agent_layer.enabled is false in config @@ -107,6 +110,7 @@ async def _dispatch_v2_0( base_url: str = config.get("agent_layer.base_url", "http://127.0.0.1:5003") layer = LayerClient(base_url) session_id: str | None = None + delta_sent = False try: session_id = await layer.start_session( @@ -121,18 +125,28 @@ async def _dispatch_v2_0( etype = event.get("type") if etype == "turn_complete": - send_fn(event.get("final_text", "")) + if not delta_sent: + send_fn(event.get("final_text", "")) break - if status_fn is not None: - if etype == "status": - msg = event.get("message", "") - if msg: - await status_fn(msg) - elif etype == "agent_action": - action = event.get("action", "") - if action: - await status_fn(action) + if etype in ("status", "agent_action"): + if status_fn is not None: + if etype == "status": + msg = event.get("message", "") + if msg: + await status_fn(msg) + else: + action = event.get("action", "") + if action: + await status_fn(action) + continue + + # agent_text_delta, permission_request, and any future event types + # are forwarded via event_fn for direct WS delivery. + if event_fn is not None: + await event_fn(event) + if etype == "agent_text_delta" and event.get("delta"): + delta_sent = True except asyncio.CancelledError: if session_id is not None: @@ -164,6 +178,7 @@ async def dispatch_message( user_id: str, vault: Any = None, status_fn: Any = None, + event_fn: Any = None, ) -> None: """Dispatch a user message to the correct pipeline based on agent_version. @@ -182,6 +197,8 @@ async def dispatch_message( user_id: Authenticated user ID. vault: Optional credential vault for per-user LLM auth. status_fn: Optional async callback for real-time status pushes. + event_fn: Optional async callback for streamed layer events (text deltas, + permission requests, etc.) forwarded directly to the WS client. """ version = agent_version if agent_version in _KNOWN_VERSIONS else _DEFAULT_VERSION if version != agent_version: @@ -197,7 +214,10 @@ async def dispatch_message( return if version == "tether-agent-2.0": - await _dispatch_v2_0(text, send_fn, pool, user_id, vault=vault, status_fn=status_fn) + await _dispatch_v2_0( + text, send_fn, pool, user_id, + vault=vault, status_fn=status_fn, event_fn=event_fn, + ) return # tether-agent-2.5 — stub until premium-pipeline-migrator wires the real path diff --git a/interactive_agent_layer/client.py b/interactive_agent_layer/client.py index f045f4e5..f02079f3 100644 --- a/interactive_agent_layer/client.py +++ b/interactive_agent_layer/client.py @@ -56,7 +56,12 @@ async def get_status(self, session_id: str) -> dict: return resp.json() async def turn(self, session_id: str, prompt: str) -> AsyncIterator[dict]: - """Stream SSE events from the layer. Yields parsed dicts.""" + """Stream SSE events from the layer. Yields parsed dicts incrementally. + + Events are yielded as complete SSE blocks arrive — no full-response + buffering. Incomplete blocks are held in the buffer until the next + chunk completes them or the stream closes. + """ async with self.client.stream( "POST", f"{self.base_url}/session/{session_id}/turn", @@ -66,7 +71,14 @@ async def turn(self, session_id: str, prompt: str) -> AsyncIterator[dict]: buffer = "" async for chunk in resp.aiter_text(): buffer += chunk - for block in buffer.split("\n\n"): - for line in block.splitlines(): + # Drain all complete SSE blocks (separated by blank lines). + while "\n\n" in buffer: + block, buffer = buffer.split("\n\n", 1) + for line in block.splitlines(): + if line.startswith("data: "): + yield json.loads(line[6:]) + # Flush any trailing block not terminated by a blank line. + if buffer.strip(): + for line in buffer.splitlines(): if line.startswith("data: "): yield json.loads(line[6:]) diff --git a/tests/api/test_agent_dispatch_ws.py b/tests/api/test_agent_dispatch_ws.py index 31e48174..ab337c79 100644 --- a/tests/api/test_agent_dispatch_ws.py +++ b/tests/api/test_agent_dispatch_ws.py @@ -88,8 +88,23 @@ async def _turn_gen(session_id, prompt): def _dispatch_via_ws(user_message: dict, extra_patches=None) -> tuple[dict, dict]: """Run a single user message through bot_chat and return (chunk, done) frames. + For non-streaming responses only (one chunk + done). Use _dispatch_via_ws_all + when testing streaming (multiple chunk frames before done). + extra_patches: list of (target, mock) tuples for additional patch.object calls. """ + frames = _dispatch_via_ws_all(user_message, extra_patches=extra_patches) + chunks = [f for f in frames if f["type"] == "chunk"] + dones = [f for f in frames if f["type"] == "done"] + # Collapse multiple chunks into one for backward compat with existing tests + if len(chunks) > 1: + combined = "\n\n".join(c["content"] for c in chunks) + return {"type": "chunk", "content": combined}, dones[0] + return chunks[0] if chunks else {"type": "chunk", "content": ""}, dones[0] + + +def _dispatch_via_ws_all(user_message: dict, extra_patches=None) -> list[dict]: + """Run a single user message through bot_chat and return ALL frames received until done.""" from starlette.testclient import TestClient app = _make_app() @@ -110,9 +125,13 @@ def _dispatch_via_ws(user_message: dict, extra_patches=None) -> tuple[dict, dict cookies={"tether_token": token}, ) as ws: ws.send_json({"type": "user", "content": "hello", **user_message}) - chunk = ws.receive_json() - done = ws.receive_json() - return chunk, done + frames: list[dict] = [] + while True: + frame = ws.receive_json() + frames.append(frame) + if frame.get("type") == "done": + break + return frames def _assert_stub_present(content: str, version_suffix: str) -> None: @@ -212,3 +231,63 @@ def test_ws_missing_agent_version_defaults_to_2_0_layer_path(): assert chunk["content"] == "1.0-response" assert "coming soon" not in chunk["content"].lower() assert done["type"] == "done" + + +# --------------------------------------------------------------------------- +# tether-agent-2.0 — streaming text deltas via event_fn +# --------------------------------------------------------------------------- + +def test_ws_2_0_text_deltas_arrive_as_chunk_frames(): + """agent_text_delta events must arrive as individual chunk frames before done.""" + events = [ + {"type": "agent_text_delta", "session_id": "sid-ws", "delta": "Hello"}, + {"type": "agent_text_delta", "session_id": "sid-ws", "delta": " world"}, + {"type": "turn_complete", "session_id": "sid-ws", "final_text": "Hello world", "tokens_used": 5}, + ] + constructor, _client = _make_layer_constructor(events=events) + frames = _dispatch_via_ws_all( + {"agent_version": "tether-agent-2.0"}, + extra_patches=[("bot.agent_dispatch.LayerClient", constructor)], + ) + + chunk_frames = [f for f in frames if f["type"] == "chunk"] + done_frames = [f for f in frames if f["type"] == "done"] + + assert len(chunk_frames) == 2, "two delta events must produce two chunk frames" + assert chunk_frames[0]["content"] == "Hello" + assert chunk_frames[1]["content"] == " world" + assert len(done_frames) == 1 + + +def test_ws_2_0_no_duplicate_chunk_after_streaming(): + """When deltas are streamed, final_text must NOT be sent as an extra chunk frame.""" + events = [ + {"type": "agent_text_delta", "session_id": "sid-ws", "delta": "Hi"}, + {"type": "turn_complete", "session_id": "sid-ws", "final_text": "Hi", "tokens_used": 2}, + ] + constructor, _client = _make_layer_constructor(events=events) + frames = _dispatch_via_ws_all( + {"agent_version": "tether-agent-2.0"}, + extra_patches=[("bot.agent_dispatch.LayerClient", constructor)], + ) + + chunk_frames = [f for f in frames if f["type"] == "chunk"] + # Only one delta chunk — no duplicate final_text chunk + assert len(chunk_frames) == 1 + assert chunk_frames[0]["content"] == "Hi" + + +def test_ws_2_0_non_streaming_still_sends_final_chunk(): + """When no deltas arrive, final_text must still arrive as a single chunk frame.""" + events = [ + {"type": "turn_complete", "session_id": "sid-ws", "final_text": "All at once", "tokens_used": 4}, + ] + constructor, _client = _make_layer_constructor(events=events) + frames = _dispatch_via_ws_all( + {"agent_version": "tether-agent-2.0"}, + extra_patches=[("bot.agent_dispatch.LayerClient", constructor)], + ) + + chunk_frames = [f for f in frames if f["type"] == "chunk"] + assert len(chunk_frames) == 1 + assert chunk_frames[0]["content"] == "All at once" diff --git a/tests/bot/test_agent_dispatch.py b/tests/bot/test_agent_dispatch.py index 921e5f20..7bbd2a8b 100644 --- a/tests/bot/test_agent_dispatch.py +++ b/tests/bot/test_agent_dispatch.py @@ -313,6 +313,135 @@ async def test_dispatch_2_0_end_session_called_on_http_error(): client.end_session.assert_not_awaited() +# --------------------------------------------------------------------------- +# 2.0 event_fn — text delta streaming +# --------------------------------------------------------------------------- + +async def test_dispatch_2_0_text_deltas_forwarded_via_event_fn(): + """agent_text_delta events must be forwarded to event_fn.""" + events = [ + {"type": "agent_text_delta", "session_id": "sid-1", "delta": "Hello"}, + {"type": "agent_text_delta", "session_id": "sid-1", "delta": " world"}, + {"type": "turn_complete", "session_id": "sid-1", "final_text": "Hello world", "tokens_used": 5}, + ] + constructor, _client = _make_layer_client(events=events) + event_calls: list[dict] = [] + + async def fake_event_fn(event: dict) -> None: + event_calls.append(event) + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + sent_parts: list[str] = [] + await dispatch_message( + "tether-agent-2.0", + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + event_fn=fake_event_fn, + ) + + delta_events = [e for e in event_calls if e.get("type") == "agent_text_delta"] + assert len(delta_events) == 2, "both text deltas must be forwarded" + assert delta_events[0]["delta"] == "Hello" + assert delta_events[1]["delta"] == " world" + + +async def test_dispatch_2_0_send_fn_skipped_when_deltas_sent(): + """When agent_text_delta events are streamed, send_fn must NOT be called at turn_complete.""" + events = [ + {"type": "agent_text_delta", "session_id": "sid-1", "delta": "Hi"}, + {"type": "turn_complete", "session_id": "sid-1", "final_text": "Hi", "tokens_used": 2}, + ] + constructor, _client = _make_layer_client(events=events) + + async def noop_event_fn(event: dict) -> None: + pass + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + sent_parts: list[str] = [] + await dispatch_message( + "tether-agent-2.0", + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + event_fn=noop_event_fn, + ) + + assert sent_parts == [], "send_fn must not be called when deltas were already streamed" + + +async def test_dispatch_2_0_send_fn_used_when_no_deltas(): + """When no agent_text_delta events arrive, send_fn must receive final_text at turn_complete.""" + events = [ + {"type": "turn_complete", "session_id": "sid-1", "final_text": "All at once", "tokens_used": 5}, + ] + constructor, _client = _make_layer_client(events=events) + + async def noop_event_fn(event: dict) -> None: + pass + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + sent_parts: list[str] = [] + await dispatch_message( + "tether-agent-2.0", + "hello", + send_fn=sent_parts.append, + pool=None, + user_id="user1", + event_fn=noop_event_fn, + ) + + assert sent_parts == ["All at once"], "send_fn must be called when no deltas were streamed" + + +async def test_dispatch_2_0_unknown_events_forwarded_via_event_fn(): + """Events that are neither status/agent_action nor known dispatch types must go via event_fn.""" + events = [ + {"type": "permission_request", "session_id": "sid-1", "request_id": "r1", "tool": "delete_tasks"}, + {"type": "turn_complete", "session_id": "sid-1", "final_text": "Done", "tokens_used": 3}, + ] + constructor, _client = _make_layer_client(events=events) + event_calls: list[dict] = [] + + async def fake_event_fn(event: dict) -> None: + event_calls.append(event) + + with ( + patch("bot.agent_dispatch.LayerClient", constructor), + patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), + ): + from bot.agent_dispatch import dispatch_message + + await dispatch_message( + "tether-agent-2.0", + "hello", + send_fn=lambda m: None, + pool=None, + user_id="user1", + event_fn=fake_event_fn, + ) + + forwarded_types = [e["type"] for e in event_calls] + assert "permission_request" in forwarded_types, "permission_request must be forwarded via event_fn" + + # --------------------------------------------------------------------------- # Existing 1.0 vault/status_fn forwarding test — unchanged # --------------------------------------------------------------------------- From f0b85e012495bd6b462adc9d086174ff36b14d9f Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 14:40:41 -0700 Subject: [PATCH 011/168] Emit permission_request on SSE stream instead of WSPublisher WSPublisher uses in-process asyncio queues that don't cross the OS process boundary between the layer service (:5003) and API service (:8000). This meant permission_request events were silently dropped and PermissionGate remained effectively dormant for user_action tools. PermissionGate now enqueues permission_request events into an outbound asyncio.Queue (injected by run_turn). The queue is drained concurrently while the control_request handler awaits the user's decision, and each event is yielded on the SSE stream so dispatch's event_fn can forward it to the user's WebSocket across process boundaries. Changes: - PermissionGate: replace ws_publisher param with outbound_events queue; put() instead of ws.push() for permission_request - session.run_turn: create gate_events queue, pass to gate; handle control_request via create_task + _drain_until_done so permission_request events flow on the SSE stream while awaiting the user decision - Add _drain_until_done async generator: races queue.get() against task completion using asyncio.wait, exits promptly when task is done - Update test_control_protocol: tests 2/3/4 now resolve futures inline from the yielded SSE stream instead of via ws.push mock - Update test_permissions: replace blocking-ws pattern with queue-based helper; fixtures no longer take mock_ws 605/605 suite passing. --- interactive_agent_layer/permissions.py | 18 ++- interactive_agent_layer/session.py | 53 ++++++- .../test_control_protocol.py | 74 ++++------ .../test_permissions.py | 135 +++++------------- 4 files changed, 126 insertions(+), 154 deletions(-) diff --git a/interactive_agent_layer/permissions.py b/interactive_agent_layer/permissions.py index d9735402..a6547109 100644 --- a/interactive_agent_layer/permissions.py +++ b/interactive_agent_layer/permissions.py @@ -18,6 +18,7 @@ ) + @dataclasses.dataclass class PermissionResultAllow: pass @@ -34,18 +35,22 @@ class PermissionGate: The callback signature matches the claude-agent-sdk CanUseTool protocol: async (tool_name: str, args: dict, ctx: Any) -> PermissionResultAllow | PermissionResultDeny + + ``outbound_events`` receives permission_request dicts when a user_action tool + needs approval. Callers (run_turn) drain this queue and yield the events on + the SSE stream so they cross the process boundary to the API / dispatch layer. """ def __init__( self, translation_table: TranslationTable, - ws_publisher: Any, # WSPublisher — avoid circular import session: Any, # Session — avoid circular import + outbound_events: asyncio.Queue, auto_approve_user_actions: bool = False, ) -> None: self._table = translation_table - self._ws = ws_publisher self._session = session + self._outbound_events = outbound_events self._auto_approve = auto_approve_user_actions async def can_use_tool( @@ -79,15 +84,18 @@ async def _dispatch( future: asyncio.Future[bool] = loop.create_future() self._session.permission_pending[request_id] = future - await self._ws.push( - self._session.user_ws_id, + # Emit on the outbound queue — run_turn drains this and yields the + # event on the SSE stream so it crosses the process boundary to + # dispatch / the API layer. WSPublisher is intentionally not used + # here: it only works within the same OS process. + await self._outbound_events.put( { "type": "permission_request", "session_id": self._session.session_id, "request_id": request_id, "summary": summary, "details": detail, - }, + } ) try: diff --git a/interactive_agent_layer/session.py b/interactive_agent_layer/session.py index 4781bdcb..4ff8d625 100644 --- a/interactive_agent_layer/session.py +++ b/interactive_agent_layer/session.py @@ -1,6 +1,8 @@ """Session dataclass and Layer class.""" from __future__ import annotations +import asyncio +import contextlib import dataclasses import hashlib import json @@ -109,10 +111,15 @@ async def run_turn(self, session_id: str, prompt: str) -> AsyncIterator[dict]: # auto-approve based on config. Decisions are forwarded back to the # pool via send_control_response, which unblocks the pool's can_use_tool # callback and lets the SDK proceed or skip the tool call. + # + # permission_request events are enqueued here and yielded on the SSE + # stream so they cross the process boundary to the dispatch / API layer. + # WSPublisher (in-process only) is not used for permission_request. + gate_events: asyncio.Queue[dict] = asyncio.Queue() gate = PermissionGate( translation_table=self.translation_table, - ws_publisher=self.ws_publisher, session=session, + outbound_events=gate_events, auto_approve_user_actions=get_auto_approve_user_actions(), ) @@ -127,9 +134,18 @@ async def run_turn(self, session_id: str, prompt: str) -> AsyncIterator[dict]: # Pool blocks its can_use_tool callback until we respond — # process control events inline before translating other events. if sdk_event.get("event") == "control_request": - await _handle_control_request( - sdk_event, handle, gate, self.pool_client + ctrl_task = asyncio.create_task( + _handle_control_request(sdk_event, handle, gate, self.pool_client) ) + # Drain gate_events while the permission decision is pending. + # For background/passthrough tools ctrl_task completes immediately + # and the drain loop exits without yielding anything. + # For user_action tools a permission_request event is put into + # gate_events; we yield it on the SSE stream so dispatch's + # event_fn can forward it to the user's WebSocket. + async for gate_event in _drain_until_done(gate_events, ctrl_task): + yield gate_event + await ctrl_task # propagate any exception continue if sdk_event.get("event") == "control_timeout": @@ -193,6 +209,37 @@ async def _handle_control_request( ) +async def _drain_until_done( + queue: asyncio.Queue, + task: asyncio.Task, +) -> AsyncIterator[dict]: + """Yield items from queue until task is done. + + Uses asyncio.wait to race a queue.get() against the task completing so + we exit promptly without polling. The get_task is cancelled on exit to + avoid leaving orphaned tasks. + """ + while not task.done(): + get_task: asyncio.Task = asyncio.ensure_future(queue.get()) + try: + done, _ = await asyncio.wait( + [get_task, task], + return_when=asyncio.FIRST_COMPLETED, + ) + except BaseException: + get_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await get_task + raise + if get_task in done: + yield get_task.result() + else: + # task completed before a queue item arrived — clean up and exit + get_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await get_task + + async def _translate_event( session_id: str, sdk_event: dict, diff --git a/tests/interactive_agent_layer/test_control_protocol.py b/tests/interactive_agent_layer/test_control_protocol.py index 60a9d27a..7c9d33fe 100644 --- a/tests/interactive_agent_layer/test_control_protocol.py +++ b/tests/interactive_agent_layer/test_control_protocol.py @@ -15,12 +15,12 @@ import pathlib from types import SimpleNamespace from typing import Any, AsyncIterator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from httpx import ASGITransport -from interactive_agent_layer.session import Layer, Session +from interactive_agent_layer.session import Layer from interactive_agent_layer.ws_publisher import WSPublisher @@ -149,9 +149,7 @@ async def query_stream(self, handle_id, prompt, session_id="default"): # --------------------------------------------------------------------------- async def test_control_request_user_action_auto_approve_allows(): - """user_action tool with auto_approve=True → allow, no permission_request WS event.""" - - published: list[dict] = [] + """user_action tool with auto_approve=True → allow, no permission_request on SSE stream.""" class _Pool(_BasePool): async def query_stream(self, handle_id, prompt, session_id="default"): @@ -165,9 +163,7 @@ async def query_stream(self, handle_id, prompt, session_id="default"): yield {"type": "result", "final_text": "done", "tokens_used": 1} pool = _Pool() - ws = WSPublisher() - ws.push = AsyncMock(side_effect=lambda ws_id, event: published.append(event)) - layer = _make_layer(pool, ws) + layer = _make_layer(pool) s = layer.create_session("user1", "ws1", "v1", {}) @@ -176,12 +172,12 @@ async def query_stream(self, handle_id, prompt, session_id="default"): "interactive_agent_layer.session.get_auto_approve_user_actions", return_value=True, ): - await _consume(layer.run_turn(s.session_id, "hi")) + events = await _consume_list(layer.run_turn(s.session_id, "hi")) assert len(pool._send_control_calls) == 1 assert pool._send_control_calls[0]["decision"] == "allow" - perm_events = [e for e in published if e.get("type") == "permission_request"] - assert perm_events == [], "auto_approve must not emit permission_request" + perm_events = [e for e in events if e.get("type") == "permission_request"] + assert perm_events == [], "auto_approve must not emit permission_request on SSE stream" # --------------------------------------------------------------------------- @@ -189,10 +185,7 @@ async def query_stream(self, handle_id, prompt, session_id="default"): # --------------------------------------------------------------------------- async def test_control_request_user_action_user_approves(): - """user_action tool: permission_request WS event emitted, user approves → allow.""" - - published: list[dict] = [] - session_holder: list[Session] = [] + """user_action tool: permission_request yielded on SSE stream, user approves → allow.""" class _Pool(_BasePool): async def query_stream(self, handle_id, prompt, session_id="default"): @@ -205,27 +198,22 @@ async def query_stream(self, handle_id, prompt, session_id="default"): } yield {"type": "result", "final_text": "done", "tokens_used": 1} - async def _ws_push(ws_id: str, event: dict) -> None: - published.append(event) - if event.get("type") == "permission_request": - # Simulate user approving immediately - request_id = event["request_id"] - sess = session_holder[0] - fut = sess.permission_pending.get(request_id) - if fut is not None and not fut.done(): - fut.set_result(True) - pool = _Pool() - ws = WSPublisher() - ws.push = _ws_push - layer = _make_layer(pool, ws) + layer = _make_layer(pool) s = layer.create_session("user1", "ws1", "v1", {}) - session_holder.append(s) + events: list[dict] = [] - await _consume(layer.run_turn(s.session_id, "hi")) + async for event in layer.run_turn(s.session_id, "hi"): + events.append(event) + if event.get("type") == "permission_request": + # Simulate user approving: resolve the future stored in session + request_id = event["request_id"] + fut = s.permission_pending.get(request_id) + if fut is not None and not fut.done(): + fut.set_result(True) - perm_events = [e for e in published if e.get("type") == "permission_request"] + perm_events = [e for e in events if e.get("type") == "permission_request"] assert len(perm_events) == 1 assert perm_events[0]["summary"] == "Update 1 tasks" @@ -238,9 +226,7 @@ async def _ws_push(ws_id: str, event: dict) -> None: # --------------------------------------------------------------------------- async def test_control_request_user_action_user_denies(): - """user_action tool: user denies → send_control_response("deny").""" - - session_holder: list[Session] = [] + """user_action tool: permission_request yielded on SSE stream, user denies → deny.""" class _Pool(_BasePool): async def query_stream(self, handle_id, prompt, session_id="default"): @@ -253,23 +239,17 @@ async def query_stream(self, handle_id, prompt, session_id="default"): } yield {"type": "result", "final_text": "done", "tokens_used": 1} - async def _ws_push(ws_id: str, event: dict) -> None: - if event.get("type") == "permission_request": - request_id = event["request_id"] - sess = session_holder[0] - fut = sess.permission_pending.get(request_id) - if fut is not None and not fut.done(): - fut.set_result(False) - pool = _Pool() - ws = WSPublisher() - ws.push = _ws_push - layer = _make_layer(pool, ws) + layer = _make_layer(pool) s = layer.create_session("user1", "ws1", "v1", {}) - session_holder.append(s) - await _consume(layer.run_turn(s.session_id, "hi")) + async for event in layer.run_turn(s.session_id, "hi"): + if event.get("type") == "permission_request": + request_id = event["request_id"] + fut = s.permission_pending.get(request_id) + if fut is not None and not fut.done(): + fut.set_result(False) assert len(pool._send_control_calls) == 1 assert pool._send_control_calls[0]["decision"] == "deny" diff --git a/tests/interactive_agent_layer/test_permissions.py b/tests/interactive_agent_layer/test_permissions.py index d861bfb2..7cb67386 100644 --- a/tests/interactive_agent_layer/test_permissions.py +++ b/tests/interactive_agent_layer/test_permissions.py @@ -3,7 +3,6 @@ import asyncio import pathlib -from unittest.mock import MagicMock import pytest @@ -35,45 +34,6 @@ def table(): return TranslationTable.from_yaml(yaml_path) -def _make_blocking_ws(): - """ - Return a mock ws whose push() blocks until unblocked. - - Attributes: - ws.push_event — asyncio.Event; set when push() is called - ws.release_event — asyncio.Event; push() awaits this before returning - ws.calls — list of (ws_id, event) tuples - """ - push_event = asyncio.Event() - release_event = asyncio.Event() - calls = [] - - async def _push(ws_id, event): - calls.append((ws_id, event)) - push_event.set() - await release_event.wait() - - ws = MagicMock() - ws.push = _push - ws.push_event = push_event - ws.release_event = release_event - ws.calls = calls - return ws - - -@pytest.fixture -def mock_ws(): - """Simple non-blocking mock ws for tests that don't need interception.""" - ws = MagicMock() - - async def _push(ws_id, event): - pass - - ws.push = _push - ws.calls = [] - return ws - - @pytest.fixture def session(): return Session( @@ -86,21 +46,21 @@ def session(): @pytest.fixture -def gate_auto_approve(table, mock_ws, session): +def gate_auto_approve(table, session): return PermissionGate( translation_table=table, - ws_publisher=mock_ws, session=session, + outbound_events=asyncio.Queue(), auto_approve_user_actions=True, ) @pytest.fixture -def gate_no_auto(table, mock_ws, session): +def gate_no_auto(table, session): return PermissionGate( translation_table=table, - ws_publisher=mock_ws, session=session, + outbound_events=asyncio.Queue(), auto_approve_user_actions=False, ) @@ -135,68 +95,58 @@ async def test_passthrough_entry_always_allow(gate_no_auto): # --------------------------------------------------------------------------- -# 4. UserAction + auto_approve=True → allow, no permission_request pushed +# 4. UserAction + auto_approve=True → allow, no permission_request enqueued # --------------------------------------------------------------------------- async def test_user_action_auto_approve_allows(table, session): - """auto_approve should allow without touching ws at all.""" - pushed = [] - - async def _noop_push(ws_id, event): - pushed.append(event) - - ws = MagicMock() - ws.push = _noop_push - + """auto_approve should allow without enqueuing a permission_request.""" + queue: asyncio.Queue = asyncio.Queue() gate = PermissionGate( translation_table=table, - ws_publisher=ws, session=session, + outbound_events=queue, auto_approve_user_actions=True, ) result = await gate.can_use_tool("upsert_tasks", {"count": 3, "tasks": []}, None) assert isinstance(result, PermissionResultAllow) - assert pushed == [] + assert queue.empty(), "auto_approve must not enqueue permission_request" # --------------------------------------------------------------------------- -# Helper: run gate concurrently with a blocking ws so we can intercept +# Helper: run gate and intercept permission_request via outbound queue # --------------------------------------------------------------------------- -async def _run_with_blocking_ws(table, session, tool_name, args, resolve_value, *, timeout=None): +async def _run_with_queue( + table, session, tool_name, args, resolve_value, *, timeout=5.0 +): """ - Run can_use_tool with a blocking ws. - Once the gate has pushed the permission_request, resolve the pending future - with resolve_value (True/False) or leave it (None → rely on natural timeout). - Returns (result, calls) where calls is the list of push call tuples. + Run can_use_tool and intercept the permission_request from the outbound queue. + + Once the gate has enqueued the permission_request event, resolve the pending + future with resolve_value (True/False), or leave it (None → rely on timeout). + Returns (result, event) where event is the permission_request dict. """ - ws = _make_blocking_ws() + queue: asyncio.Queue = asyncio.Queue() gate = PermissionGate( translation_table=table, - ws_publisher=ws, session=session, + outbound_events=queue, auto_approve_user_actions=False, ) task = asyncio.create_task(gate.can_use_tool(tool_name, args, None)) - # Wait until the gate has called push (the event is set inside _push). - await ws.push_event.wait() + # Wait until the gate enqueues the permission_request event. + event = await asyncio.wait_for(queue.get(), timeout=timeout) - # At this point the gate is suspended inside _push, so permission_pending is populated. if resolve_value is not None: - # Resolve the future first, then unblock push so the gate can proceed. - pending_copy = dict(session.permission_pending) - if pending_copy: - request_id = next(iter(pending_copy)) - fut = pending_copy[request_id] + request_id = event["request_id"] + fut = session.permission_pending.get(request_id) + if fut is not None and not fut.done(): fut.set_result(resolve_value) - # Unblock the ws.push() so the gate continues. - ws.release_event.set() - result = await task - return result, ws.calls + return result, event # --------------------------------------------------------------------------- @@ -204,15 +154,12 @@ async def _run_with_blocking_ws(table, session, tool_name, args, resolve_value, # --------------------------------------------------------------------------- async def test_user_action_no_auto_approve_and_user_approves(table, session): - result, calls = await _run_with_blocking_ws( + result, event = await _run_with_queue( table, session, "upsert_tasks", {"count": 3, "tasks": ["task-1"]}, resolve_value=True, ) assert isinstance(result, PermissionResultAllow) - assert len(calls) == 1 - ws_id, event = calls[0] - assert ws_id == "ws-1" assert event["type"] == "permission_request" assert event["session_id"] == "sess-1" assert "request_id" in event @@ -230,18 +177,14 @@ async def test_user_action_timeout_denies(table, session, monkeypatch): lambda: 0.01, ) - async def _noop_push(ws_id, event): - pass - - ws = MagicMock() - ws.push = _noop_push - + queue: asyncio.Queue = asyncio.Queue() gate = PermissionGate( translation_table=table, - ws_publisher=ws, session=session, + outbound_events=queue, auto_approve_user_actions=False, ) + # Don't resolve the future — let it time out naturally. result = await gate.can_use_tool("upsert_tasks", {"count": 1, "tasks": []}, None) assert isinstance(result, PermissionResultDeny) @@ -251,7 +194,7 @@ async def _noop_push(ws_id, event): # --------------------------------------------------------------------------- async def test_user_action_denied_by_user(table, session): - result, _ = await _run_with_blocking_ws( + result, _ = await _run_with_queue( table, session, "upsert_tasks", {"count": 1, "tasks": []}, resolve_value=False, ) @@ -263,11 +206,10 @@ async def test_user_action_denied_by_user(table, session): # --------------------------------------------------------------------------- async def test_permission_summary_interpolation(table, session): - _, calls = await _run_with_blocking_ws( + _, event = await _run_with_queue( table, session, "upsert_tasks", {"count": 3, "tasks": []}, resolve_value=True, ) - _, event = calls[0] assert event["summary"] == "Update 3 tasks" @@ -276,7 +218,7 @@ async def test_permission_summary_interpolation(table, session): # --------------------------------------------------------------------------- async def test_permission_pending_cleaned_up_after_approval(table, session): - await _run_with_blocking_ws( + await _run_with_queue( table, session, "upsert_tasks", {"count": 1, "tasks": []}, resolve_value=True, ) @@ -284,7 +226,7 @@ async def test_permission_pending_cleaned_up_after_approval(table, session): async def test_permission_pending_cleaned_up_after_denial(table, session): - await _run_with_blocking_ws( + await _run_with_queue( table, session, "upsert_tasks", {"count": 1, "tasks": []}, resolve_value=False, ) @@ -297,16 +239,11 @@ async def test_permission_pending_cleaned_up_after_timeout(table, session, monke lambda: 0.01, ) - async def _noop_push(ws_id, event): - pass - - ws = MagicMock() - ws.push = _noop_push - + queue: asyncio.Queue = asyncio.Queue() gate = PermissionGate( translation_table=table, - ws_publisher=ws, session=session, + outbound_events=queue, auto_approve_user_actions=False, ) await gate.can_use_tool("upsert_tasks", {"count": 1, "tasks": []}, None) From be446a7dc39c6721da8343752b9a2495b99f2c98 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 14:01:36 -0700 Subject: [PATCH 012/168] feat(dispatch): wire tether-agent-2.5 to premium handler with is_paid gate Paid users are routed to the premium session handler (Beacon, memory, RAG). Free users receive an upgrade notice and fall back to tether-agent-1.0. ImportError and DB failures degrade gracefully to 1.0 fallback. AgentSDKBackend not refactored: it is on the router fallback chain, not the 2.5 session path, and lacks user_id required by LayerClient.start_session(). --- bot/agent_dispatch.py | 99 +++++++++++++-- tests/bot/test_agent_dispatch.py | 35 +++--- tests/bot/test_agent_dispatch_v25.py | 177 +++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 tests/bot/test_agent_dispatch_v25.py diff --git a/bot/agent_dispatch.py b/bot/agent_dispatch.py index 9dd6ed32..ccb66a8b 100644 --- a/bot/agent_dispatch.py +++ b/bot/agent_dispatch.py @@ -5,7 +5,7 @@ tether-agent-1.0 → existing JSON-mutation pipeline (handle_message) tether-agent-2.0 → real LayerClient pipeline; 1.0 fallback on error/disabled - tether-agent-2.5 → stub notice + 1.0 fallback (premium-pipeline-migrator owns this) + tether-agent-2.5 → premium session for paid/admin users; 1.0 fallback for free unknown / None → treated as tether-agent-2.0 (picker default) The 2.0 pipeline calls the interactive-agent-layer service, which handles @@ -13,9 +13,13 @@ layer are forwarded to the WS client via status_fn; the final response is delivered via send_fn when turn_complete arrives. -Fallback behaviour: if the layer is disabled (config) or unreachable (HTTP -error), dispatch silently falls back to handle_message so users always get -a response. +The 2.5 pipeline routes paid users and admin users to the premium handler +(Session + Beacon + RAG). Free users receive an upgrade notice and fall back +to tether-agent-1.0. Admin users bypass the subscription check entirely. + +Fallback behaviour: if the 2.0 layer is disabled (config) or unreachable +(HTTP error), dispatch silently falls back to handle_message so users always +get a response. Note: The Telegram polling path (bot/message_handler.py) calls handle_message directly — it bypasses this dispatcher (Telegram has no picker UI). @@ -170,23 +174,95 @@ async def _dispatch_v2_0( await layer.end_session(session_id) +async def _dispatch_v25( + text: str, + send_fn: Callable[[str], None], + pool: Any, + user_id: str, + *, + vault: Any = None, + status_fn: Any = None, + is_admin: bool = False, +) -> None: + """Handle tether-agent-2.5: premium session for paid/admin users, 1.0 fallback for free. + + Paid users and admin users are routed to the premium handler (Session + Beacon + RAG). + Admin users bypass the subscription check entirely — no subscription row required. + Free users (non-admin, non-paid) receive an upgrade notice and fall back to tether-agent-1.0. + If tether-premium is not installed, paid/admin users also fall back to 1.0. + + This function is a clean boundary that maps to a future HTTP endpoint in the + self-hosted premium access plan (phase P1+). + """ + import db.postgres as pg + from db.pg_queries.subscriptions import get_user_is_paid + + is_paid = False + if not is_admin: + # Admin users skip the subscription DB check entirely. + try: + async with pg.get_conn(pool, user_id) as conn: + is_paid = await get_user_is_paid(conn) + except Exception: + logger.warning( + "dispatch_v25: subscription check failed for user_id=%s — defaulting to free", + user_id, + ) + + if is_admin or is_paid: + try: + from tether_premium.register import get_premium_handler + from db.pg_queries import get_anchors + from bot.handler_utils import get_current_anchor + + async with pg.get_conn(pool, user_id) as conn: + anchors = await get_anchors(conn) + current_anchor = get_current_anchor(anchors) + + response = await get_premium_handler()( + text, pool, user_id, anchors, current_anchor, + send_fn=send_fn, status_fn=status_fn, + ) + if response: + send_fn(response) + return + except (ImportError, NotImplementedError): + logger.warning( + "dispatch_v25: premium not available for user_id=%s — falling back to 1.0", + user_id, + ) + except Exception: + logger.exception( + "dispatch_v25: premium handler raised for user_id=%s — falling back to 1.0", + user_id, + ) + else: + send_fn( + "tether-agent-2.5 is available on the Pro plan — you're currently on " + "the free plan. Routing to tether-agent-1.0 for this message." + ) + + await handle_message(text, send_fn, pool, user_id, vault=vault, status_fn=status_fn) + + async def dispatch_message( agent_version: str | None, text: str, send_fn: Callable[[str], None], pool: Any, user_id: str, + *, vault: Any = None, status_fn: Any = None, event_fn: Any = None, + is_admin: bool = False, ) -> None: """Dispatch a user message to the correct pipeline based on agent_version. For tether-agent-1.0, delegates directly to handle_message with no stub. For tether-agent-2.0, calls the interactive-agent-layer real pipeline with a silent fallback to 1.0 on error or when the layer is disabled. - For tether-agent-2.5 (not yet wired), sends a stub notice and falls back - to the 1.0 pipeline so the user still gets a response. + For tether-agent-2.5, routes to _dispatch_v25 (paid/admin = premium; free = 1.0 fallback). Unknown or None versions default to tether-agent-2.0 and log a warning. Args: @@ -199,6 +275,8 @@ async def dispatch_message( status_fn: Optional async callback for real-time status pushes. event_fn: Optional async callback for streamed layer events (text deltas, permission requests, etc.) forwarded directly to the WS client. + is_admin: When True, bypass subscription check for 2.5 dispatch (admin users + have no subscription row but must reach the premium handler). """ version = agent_version if agent_version in _KNOWN_VERSIONS else _DEFAULT_VERSION if version != agent_version: @@ -220,7 +298,14 @@ async def dispatch_message( ) return - # tether-agent-2.5 — stub until premium-pipeline-migrator wires the real path + if version == "tether-agent-2.5": + await _dispatch_v25( + text, send_fn, pool, user_id, + vault=vault, status_fn=status_fn, is_admin=is_admin, + ) + return + + # Catch-all for any future known versions not yet wired send_fn(_stub_message(version)) logger.warning( "dispatch_message: %s not yet wired — stub sent, falling back to 1.0 user_id=%s", diff --git a/tests/bot/test_agent_dispatch.py b/tests/bot/test_agent_dispatch.py index 7bbd2a8b..d277c0a6 100644 --- a/tests/bot/test_agent_dispatch.py +++ b/tests/bot/test_agent_dispatch.py @@ -3,17 +3,21 @@ Tests the dispatch matrix: - tether-agent-1.0 → handle_message called, no stub injected - tether-agent-2.0 → real LayerClient pipeline; falls back to 1.0 on error or disabled -- tether-agent-2.5 → stub notice + 1.0 fallback (not yet wired) -- unknown / None version → treated as tether-agent-2.0 (picker default) +- tether-agent-2.5 → routes to _dispatch_v25 (M4: paid/admin=premium, free=1.0 fallback) +- unknown / None version → treated as tether-agent-2.0 (layer pipeline default) - vault/status_fn → forwarded transparently to handle_message (1.0 path) """ from __future__ import annotations +import os from unittest.mock import AsyncMock, MagicMock, call, patch import httpx import pytest +# Required before any config-loading import. +os.environ.setdefault("TETHER_JWT_SECRET", "test-secret-for-tests") + # --------------------------------------------------------------------------- # Helpers @@ -88,28 +92,25 @@ async def test_dispatch_1_0_no_stub(): # --------------------------------------------------------------------------- -# 2.5 path — still stubbed (premium-pipeline-migrator owns this) +# 2.5 path — _dispatch_v25 (M4: paid/admin=premium, free=1.0 fallback) # --------------------------------------------------------------------------- -async def test_dispatch_2_5_stub_then_calls_1_0(): - """tether-agent-2.5 must prepend a stub mentioning 2.5, then fall back to 1.0.""" - with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response): +async def test_dispatch_25_routes_to_dispatch_v25(): + """tether-agent-2.5 must call _dispatch_v25, not the generic stub.""" + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0_response), \ + patch("bot.agent_dispatch._dispatch_v25", AsyncMock()) as mock_v25: + from bot.agent_dispatch import dispatch_message - sent_parts: list[str] = [] + sent: list[str] = [] await dispatch_message( - "tether-agent-2.5", - "hello", - send_fn=sent_parts.append, - pool=None, - user_id="user1", + "tether-agent-2.5", "hello", + send_fn=sent.append, pool=None, user_id="user1", ) - assert len(sent_parts) == 2, "2.5 path must produce stub + 1.0 response" - stub, response = sent_parts - assert "2.5" in stub, f"stub must mention 2.5, got: {stub!r}" - _assert_not_wired_stub(stub) - assert response == "1.0-response" + mock_v25.assert_awaited_once() + assert not any("not yet wired" in s for s in sent), \ + "2.5 must not send the generic stub message" # --------------------------------------------------------------------------- diff --git a/tests/bot/test_agent_dispatch_v25.py b/tests/bot/test_agent_dispatch_v25.py new file mode 100644 index 00000000..33fba478 --- /dev/null +++ b/tests/bot/test_agent_dispatch_v25.py @@ -0,0 +1,177 @@ +"""Tests for tether-agent-2.5 dispatch path in agent_dispatch.py. + +M4 wires a real 2.5 path: paid users get the premium session handler; +free users receive an upgrade notice and fall back to tether-agent-1.0. + +All DB and premium-package interactions are fully mocked. +""" +from __future__ import annotations + +import os +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +# Must be set before config loading is triggered (jwt.secret is required). +os.environ.setdefault("TETHER_JWT_SECRET", "test-secret-for-tests") + +TEST_USER_ID = "00000000-0000-0000-0000-000000000042" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _fake_handle_1_0(text, send_fn, pool, user_id, vault=None, status_fn=None): + send_fn("1.0-response") + + +def _make_conn_ctx(is_paid_return=False): + """Mock async context manager that yields a conn where get_user_is_paid returns is_paid_return.""" + mock_conn = AsyncMock() + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=mock_conn) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + +# --------------------------------------------------------------------------- +# _dispatch_v25() — must exist and behave correctly +# --------------------------------------------------------------------------- + +class TestDispatchV25: + @pytest.mark.asyncio + async def test_free_user_gets_notice_and_10_fallback(self): + """Free user: upgrade notice sent, 1.0 handle_message called.""" + sent = [] + + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0), \ + patch("db.postgres.get_conn", return_value=_make_conn_ctx()), \ + patch("db.pg_queries.subscriptions.get_user_is_paid", + new=AsyncMock(return_value=False)): + + from bot.agent_dispatch import _dispatch_v25 + await _dispatch_v25("do a task", sent.append, None, TEST_USER_ID) + + # An upgrade/fallback notice was sent before the 1.0 response + assert len(sent) >= 2, f"Expected notice + 1.0-response, got: {sent}" + assert any( + "pro" in s.lower() or "paid" in s.lower() or "1.0" in s or "free" in s.lower() + for s in sent + ), f"Free user must see upgrade/fallback notice. Got: {sent}" + assert "1.0-response" in sent + + @pytest.mark.asyncio + async def test_paid_user_calls_premium_and_no_stub(self): + """Paid user: premium handler called, no upgrade/stub notice sent.""" + import sys + + sent = [] + mock_premium_handler = AsyncMock(return_value="Premium reply") + + # Inject a fake tether_premium.register into sys.modules so the + # lazy import inside _dispatch_v25 succeeds without tether-premium installed. + mock_register = MagicMock() + # get_premium_handler() → the handler callable + mock_register.get_premium_handler = MagicMock(return_value=mock_premium_handler) + mock_tether_premium = MagicMock() + fake_modules = { + "tether_premium": mock_tether_premium, + "tether_premium.register": mock_register, + } + + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0), \ + patch("db.postgres.get_conn", return_value=_make_conn_ctx()), \ + patch("db.pg_queries.subscriptions.get_user_is_paid", + new=AsyncMock(return_value=True)), \ + patch("db.pg_queries.get_anchors", new=AsyncMock(return_value=[])), \ + patch("bot.handler_utils.get_current_anchor", return_value={}), \ + patch.dict(sys.modules, fake_modules): + + from bot.agent_dispatch import _dispatch_v25 + await _dispatch_v25("do a task", sent.append, None, TEST_USER_ID) + + mock_premium_handler.assert_awaited_once() + assert "Premium reply" in sent + # No stub/upgrade notice for paid user + assert not any( + "1.0" in s or "free" in s.lower() or "not yet wired" in s + for s in sent + ), f"Paid user must not see 1.0/free/stub notices. Got: {sent}" + + @pytest.mark.asyncio + async def test_subscription_failure_falls_back_gracefully(self): + """DB error on subscription check → treat as free, fall back to 1.0, no crash.""" + sent = [] + + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0), \ + patch("db.postgres.get_conn", return_value=_make_conn_ctx()), \ + patch("db.pg_queries.subscriptions.get_user_is_paid", + new=AsyncMock(side_effect=RuntimeError("DB down"))): + + from bot.agent_dispatch import _dispatch_v25 + await _dispatch_v25("do a task", sent.append, None, TEST_USER_ID) + + assert "1.0-response" in sent + + @pytest.mark.asyncio + async def test_premium_import_error_falls_back_gracefully(self): + """If tether_premium is not installed, paid user still gets 1.0 fallback.""" + sent = [] + + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0), \ + patch("db.postgres.get_conn", return_value=_make_conn_ctx()), \ + patch("db.pg_queries.subscriptions.get_user_is_paid", + new=AsyncMock(return_value=True)), \ + patch("db.pg_queries.get_anchors", new=AsyncMock(return_value=[])), \ + patch("bot.handler_utils.get_current_anchor", return_value={}): + + # Simulate premium package absent + import sys + fake_modules = { + "tether_premium": None, + "tether_premium.register": None, + } + with patch.dict(sys.modules, fake_modules): + from bot.agent_dispatch import _dispatch_v25 + await _dispatch_v25("do a task", sent.append, None, TEST_USER_ID) + + # Should have fallen back to 1.0, not crashed + assert "1.0-response" in sent + + +# --------------------------------------------------------------------------- +# dispatch_message() routing +# --------------------------------------------------------------------------- + +class TestDispatchMessageRouting: + @pytest.mark.asyncio + async def test_v25_routes_to_dispatch_v25_function(self): + """dispatch_message('tether-agent-2.5') calls _dispatch_v25, not the generic stub.""" + sent = [] + + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0), \ + patch("bot.agent_dispatch._dispatch_v25", AsyncMock()) as mock_v25: + + from bot.agent_dispatch import dispatch_message + await dispatch_message( + "tether-agent-2.5", "hello", + send_fn=sent.append, pool=None, user_id=TEST_USER_ID, + ) + + mock_v25.assert_awaited_once() + # Generic "not yet wired" stub must NOT appear + assert not any("not yet wired" in s for s in sent) + + @pytest.mark.asyncio + async def test_v20_still_uses_stub(self): + """tether-agent-2.0 still hits the stub (not owned by M4).""" + sent = [] + + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0): + from bot.agent_dispatch import dispatch_message + await dispatch_message( + "tether-agent-2.0", "hello", + send_fn=sent.append, pool=None, user_id=TEST_USER_ID, + ) + + assert any("not yet wired" in s or "2.0" in s for s in sent) From 76d7c3290685058446012568e82ef0afeb477c53 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 15:01:44 -0700 Subject: [PATCH 013/168] fix(dispatch): admin users bypass subscription check for tether-agent-2.5 Admin users have no subscription row but must reach the premium handler. Add is_admin param to _dispatch_v25 and dispatch_message; when True, skip the get_user_is_paid DB call entirely and go straight to the premium path. Thread is_admin=websocket.state.is_admin from api/routes/bot.py through dispatch_message into _dispatch_v25. Two new tests: admin bypass reaches premium, is_admin flag forwarded. --- api/routes/bot.py | 1 + tests/bot/test_agent_dispatch_v25.py | 61 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/api/routes/bot.py b/api/routes/bot.py index 28e411f3..67ae770c 100644 --- a/api/routes/bot.py +++ b/api/routes/bot.py @@ -298,6 +298,7 @@ def capture_send_fn(msg: str) -> None: vault=getattr(websocket.app.state, "vault", None), status_fn=status_fn, event_fn=event_fn, + is_admin=websocket.state.is_admin, ) ) diff --git a/tests/bot/test_agent_dispatch_v25.py b/tests/bot/test_agent_dispatch_v25.py index 33fba478..42beefc9 100644 --- a/tests/bot/test_agent_dispatch_v25.py +++ b/tests/bot/test_agent_dispatch_v25.py @@ -3,6 +3,9 @@ M4 wires a real 2.5 path: paid users get the premium session handler; free users receive an upgrade notice and fall back to tether-agent-1.0. +M4 hotfix: admin users bypass the subscription check entirely and are +routed directly to the premium path regardless of subscription row. + All DB and premium-package interactions are fully mocked. """ from __future__ import annotations @@ -138,6 +141,64 @@ async def test_premium_import_error_falls_back_gracefully(self): # Should have fallen back to 1.0, not crashed assert "1.0-response" in sent + @pytest.mark.asyncio + async def test_admin_user_bypasses_subscription_check_and_gets_premium(self): + """Admin user with no subscription row must reach premium handler, not 1.0 fallback. + + is_admin=True must short-circuit the paid check entirely — no subscription + row is required. get_user_is_paid returns False (simulates no row), but + the admin flag overrides and routes to the premium path. + """ + import sys + sent = [] + mock_premium_handler = AsyncMock(return_value="Premium reply for admin") + + mock_register = MagicMock() + mock_register.get_premium_handler = MagicMock(return_value=mock_premium_handler) + mock_tether_premium = MagicMock() + fake_modules = { + "tether_premium": mock_tether_premium, + "tether_premium.register": mock_register, + } + + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0), \ + patch("db.postgres.get_conn", return_value=_make_conn_ctx(is_paid_return=False)), \ + patch("db.pg_queries.subscriptions.get_user_is_paid", + new=AsyncMock(return_value=False)), \ + patch("db.pg_queries.get_anchors", new=AsyncMock(return_value=[])), \ + patch("bot.handler_utils.get_current_anchor", return_value={}), \ + patch.dict(sys.modules, fake_modules): + + from bot.agent_dispatch import _dispatch_v25 + await _dispatch_v25("do a task", sent.append, None, TEST_USER_ID, is_admin=True) + + mock_premium_handler.assert_awaited_once() + assert "Premium reply for admin" in sent + # No upgrade/free-plan notice for admins + assert not any( + "free plan" in s.lower() or "pro plan" in s.lower() + for s in sent + ), f"Admin must not see free-plan upgrade notice. Got: {sent}" + # Must NOT have fallen back to 1.0 + assert "1.0-response" not in sent, "Admin must not fall back to 1.0" + + @pytest.mark.asyncio + async def test_admin_flag_forwarded_from_dispatch_message(self): + """dispatch_message passes is_admin through to _dispatch_v25.""" + with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0), \ + patch("bot.agent_dispatch._dispatch_v25", AsyncMock()) as mock_v25: + + from bot.agent_dispatch import dispatch_message + await dispatch_message( + "tether-agent-2.5", "hello", + send_fn=lambda m: None, pool=None, user_id=TEST_USER_ID, + is_admin=True, + ) + + _args, kwargs = mock_v25.call_args + assert kwargs.get("is_admin") is True, \ + f"_dispatch_v25 must receive is_admin=True, got call_args={mock_v25.call_args}" + # --------------------------------------------------------------------------- # dispatch_message() routing From 94b2664d32c4f43928d38e18c93191641c3abe39 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 15:21:54 -0700 Subject: [PATCH 014/168] fix(tests): update stale dispatch tests for post-M3/M4 pipeline behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove test_v20_still_uses_stub: 2.0 now uses the real LayerClient pipeline (not a stub) — this test was written before PR #391 landed - Replace test_ws_2_5_stub_prepended with test_ws_2_5_free_user_gets_upgrade_notice: 2.5 now sends a Pro-plan upgrade notice (not a "coming soon" generic stub) for free users in the _dispatch_v25 path wired by M4 --- tests/api/test_agent_dispatch_ws.py | 30 +++++++++++++++++++++++----- tests/bot/test_agent_dispatch_v25.py | 13 ------------ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/api/test_agent_dispatch_ws.py b/tests/api/test_agent_dispatch_ws.py index ab337c79..30de5a4b 100644 --- a/tests/api/test_agent_dispatch_ws.py +++ b/tests/api/test_agent_dispatch_ws.py @@ -3,7 +3,7 @@ Verifies the bot_chat WebSocket handler routes messages based on agent_version: - tether-agent-1.0 → no stub, chunk contains only the 1.0 response - tether-agent-2.0 → real layer pipeline; falls back silently to 1.0 on error -- tether-agent-2.5 → chunk contains stub + 1.0 response (joined by \\n\\n) +- tether-agent-2.5 → free user: upgrade notice + 1.0 fallback (M4) - agent_version missing → defaults to 2.0 path (layer pipeline / fallback) Note: The Telegram path (_process_telegram_update) calls handle_message directly @@ -13,6 +13,11 @@ visible stub. It runs the real layer pipeline, falling back silently to 1.0 when the layer is unavailable. This is tested both with a mocked successful layer session and with a mocked HTTP failure. + +Deliberate behaviour change (M4): tether-agent-2.5 no longer sends the generic +"not yet wired" stub. Free users see a Pro-plan upgrade notice; paid/admin users +reach the premium handler. This WS test uses a non-admin, no-DB token so the +free user path executes. """ from __future__ import annotations @@ -162,15 +167,30 @@ def test_ws_agent_1_0_no_stub(): # --------------------------------------------------------------------------- -# tether-agent-2.5 — stub + 1.0 response in chunk (still wired to stub path) +# tether-agent-2.5 — M4: free user gets upgrade notice + 1.0 fallback # --------------------------------------------------------------------------- -def test_ws_2_5_stub_prepended(): - """2.5 must include a stub notice before the 1.0 response in a single chunk frame.""" +def test_ws_2_5_free_user_gets_upgrade_notice(): + """2.5 free user: must see Pro-plan upgrade notice and receive 1.0 response. + + Test uses a non-admin token and no DB pool, so subscription check fails + (defaults to free). _dispatch_v25 must send the upgrade notice then fall + back to handle_message (1.0 pipeline). + """ chunk, done = _dispatch_via_ws({"agent_version": "tether-agent-2.5"}) + combined = chunk.get("content", "") assert chunk["type"] == "chunk" - _assert_stub_present(chunk["content"], "2.5") + assert "1.0-response" in combined, "1.0 fallback must be present" + combined_lower = combined.lower() + assert ( + "pro plan" in combined_lower + or "free plan" in combined_lower + or "pro" in combined_lower + ), f"upgrade notice must mention Pro/free plan, got: {combined!r}" + # Must NOT include the old not-yet-wired generic stub + assert "not yet wired" not in combined_lower, \ + "2.5 must not send the generic 'not yet wired' stub" assert done["type"] == "done" diff --git a/tests/bot/test_agent_dispatch_v25.py b/tests/bot/test_agent_dispatch_v25.py index 42beefc9..6480d503 100644 --- a/tests/bot/test_agent_dispatch_v25.py +++ b/tests/bot/test_agent_dispatch_v25.py @@ -223,16 +223,3 @@ async def test_v25_routes_to_dispatch_v25_function(self): # Generic "not yet wired" stub must NOT appear assert not any("not yet wired" in s for s in sent) - @pytest.mark.asyncio - async def test_v20_still_uses_stub(self): - """tether-agent-2.0 still hits the stub (not owned by M4).""" - sent = [] - - with patch("bot.agent_dispatch.handle_message", new=_fake_handle_1_0): - from bot.agent_dispatch import dispatch_message - await dispatch_message( - "tether-agent-2.0", "hello", - send_fn=sent.append, pool=None, user_id=TEST_USER_ID, - ) - - assert any("not yet wired" in s or "2.0" in s for s in sent) From d9abcebfb3847aac49f6a7560eed776802aedb12 Mon Sep 17 00:00:00 2001 From: jlunder00 Date: Thu, 21 May 2026 15:30:39 -0700 Subject: [PATCH 015/168] chore: bump tether-premium pin to 0.0.2a9 (#396) Co-authored-by: github-actions[bot] --- requirements-premium-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-premium-dev.txt b/requirements-premium-dev.txt index df623b67..087a989c 100644 --- a/requirements-premium-dev.txt +++ b/requirements-premium-dev.txt @@ -1,2 +1,2 @@ --extra-index-url https://jlunder00.github.io/tether-pypi/ -tether-premium==0.0.2a8 +tether-premium==0.0.2a9 From 5c65894172db2f5b0cc4bf7a67223fc331107e6a Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 15:32:56 -0700 Subject: [PATCH 016/168] chore: bump cloud version to 0.4.0a1 --- cloud-version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud-version.txt b/cloud-version.txt index 098db4a5..6495aedb 100644 --- a/cloud-version.txt +++ b/cloud-version.txt @@ -1 +1 @@ -0.3.0a1 +0.4.0a1 From 2fd92d4fff511b5901eabc05dc4a4f5b86157dfb Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 15:45:15 -0700 Subject: [PATCH 017/168] fix: wire real PoolClient in layer __main__ (StubPoolClient removed in #394) --- interactive_agent_layer/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interactive_agent_layer/__main__.py b/interactive_agent_layer/__main__.py index 1f5d8e3a..2b4beb6c 100644 --- a/interactive_agent_layer/__main__.py +++ b/interactive_agent_layer/__main__.py @@ -5,7 +5,7 @@ import uvicorn -from interactive_agent_layer.pool_client import StubPoolClient +from interactive_agent_layer.pool_client import PoolClient from interactive_agent_layer.server import create_app from interactive_agent_layer.session import Layer from interactive_agent_layer.ws_publisher import WSPublisher @@ -18,7 +18,7 @@ def main() -> None: args = parser.parse_args() publisher = WSPublisher() - pool = StubPoolClient() # replaced with real pool client in follow-up PR + pool = PoolClient() layer = Layer(pool_client=pool, ws_publisher=publisher) app = create_app(layer) From f2ffb0581f8babf96f1e5f3066c826b2c445e837 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 15:49:59 -0700 Subject: [PATCH 018/168] chore: bump cloud version to 0.4.1a1 --- cloud-version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud-version.txt b/cloud-version.txt index 6495aedb..06e65bf5 100644 --- a/cloud-version.txt +++ b/cloud-version.txt @@ -1 +1 @@ -0.4.0a1 +0.4.1a1 From 103c3b7688493eeb4eed889ef92d5ba2a3b75e22 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 16:02:35 -0700 Subject: [PATCH 019/168] fix(settings): remove extra user_id arg from set_user_setting call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_user_setting(conn, key, value) uses RLS for user scoping — passing request.state.user_id as a 4th positional argument caused TypeError 500. --- api/routes/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/routes/settings.py b/api/routes/settings.py index a551c2b4..a984cf3a 100644 --- a/api/routes/settings.py +++ b/api/routes/settings.py @@ -22,5 +22,5 @@ async def list_settings(request: Request, _auth=Depends(auth_dependency), async def put_setting(key: str, body: SetSettingBody, request: Request, _auth=Depends(auth_dependency), conn: asyncpg.Connection = Depends(get_db_conn)): - await set_user_setting(conn, request.state.user_id, key, body.value) + await set_user_setting(conn, key, body.value) return {"ok": True} From 7add44eb1c49fbf261cc710ec44d70a1e0e66df6 Mon Sep 17 00:00:00 2001 From: Jason Lunder Date: Thu, 21 May 2026 16:08:02 -0700 Subject: [PATCH 020/168] fix(picker): open dropdown upward + load preference on ConversationView mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — Can't scroll to 2.5: AgentPicker dropdown used top-full mt-1 (opens downward) but the picker lives in the composer bar at the bottom of ConversationView, which is inside overflow-hidden ancestors in ChatPageView. The absolute-positioned dropdown was clipped before all three options could be seen. Changed to bottom-full mb-1 so the list opens upward where there is room. Bug 2 — Selection resets to 2.0: ConversationView was not calling fetchPreference() on mount, so the stored agent preference was never loaded. The picker always started at the 2.0 default. Added onMounted(() => agentPickerStore.fetchPreference()) to match BotChat's existing behaviour. The rollback to the previous value on a 500 response is correct optimistic-update behaviour and will resolve once the backend set_user_setting() bug is fixed. --- frontend/src/components/AgentPicker.vue | 2 +- .../__tests__/chat/ConversationView.test.ts | 18 ++++++++++++++---- .../src/components/chat/ConversationView.vue | 6 +++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/AgentPicker.vue b/frontend/src/components/AgentPicker.vue index 80acfd5c..1bfa590f 100644 --- a/frontend/src/components/AgentPicker.vue +++ b/frontend/src/components/AgentPicker.vue @@ -78,7 +78,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocumentClick)