Skip to content

Dev#412

Open
jlunder00 wants to merge 217 commits into
mainfrom
dev
Open

Dev#412
jlunder00 wants to merge 217 commits into
mainfrom
dev

Conversation

@jlunder00

Copy link
Copy Markdown
Owner

No description provided.

jlunder00 and others added 30 commits May 21, 2026 10:44
…line

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.
…gles

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).
- 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
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
Extract the seven near-identical <router-link> 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.
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.
feat(frontend): trial counter, BYOK gate, agent settings toggles
feat(frontend): Chat nav link + Phase J side chat panel
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.
feat(bot): wire tether-agent-2.0 to interactive-agent-layer real pipeline
Wire A6 control protocol into interactive-agent-layer
… 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().
…-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.
- 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
feat(dispatch): M4 — wire tether-agent-2.5 to premium handler with is_paid gate
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
fix: wire real PoolClient in layer __main__
set_user_setting(conn, key, value) uses RLS for user scoping — passing
request.state.user_id as a 4th positional argument caused TypeError 500.
fix(settings): remove extra user_id arg from set_user_setting call
…ew mount

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.
When pool_client.acquire() or any other run_turn() call raises, the
layer's event_generator() previously propagated the exception uncaught.
FastAPI's StreamingResponse had already committed the 200 status, so
Uvicorn closed the TCP connection without a terminal chunk. The httpx
client saw RemoteProtocolError (a subclass of httpx.HTTPError), which
dispatch caught as "layer unavailable" — a misleading message when the
layer itself was healthy but the pool was exhausted.

Fix 1 (interactive_agent_layer/server.py): wrap event_generator() in
try/except. On any exception, log it and emit {"type": "turn_error",
"message": str(exc)} as the final SSE event. The stream closes cleanly;
the client receives a parseable error signal instead of an abrupt close.

Fix 2 (bot/agent_dispatch.py): handle turn_error events explicitly —
log the real error message (e.g. "pool_exhausted — retry after 5s") at
WARNING and fall back to 1.0. Rename the httpx.HTTPError catch log to
"layer turn transport error" to distinguish raw network failures from
the in-band turn_error path.

628 tests pass (3 new: turn_error emission, turn_error fallback,
transport error log message).
fix(picker): open dropdown upward + load preference on mount
fix(bot): emit turn_error SSE event on layer run_turn failure
jlunder00 and others added 30 commits June 4, 2026 11:46
Frontend needs state and priority on first paint to render pending badges
and priority dots without a background upgrade call. Both columns are already
on the conversations table — added to SELECT and GROUP BY, no extra query.

Updated shape: [{id, title, parent_context_node_id, state, priority,
updated_at, message_count}]

Added test_conversations_index_state_and_priority to cover non-default
state (pending) and priority (high). Updated shape assertion to assert
state + priority present, not absent.
…rmission_request, status)

Schema migration of all three interactive-layer event types to match the
memory-context-v2 spec. Breaking change; frontend updated in same commit.

agent_action:
- Rename action_id → id, action → friendly_text
- Add tool_name field
- Add status: 'starting' | 'running' | 'complete' lifecycle
- Lifecycle heuristic: starting on first call, running on coalesced repeat,
  complete synthesized when next distinct tool or turn_complete arrives

permission_request:
- Replace summary/details with kind/target/reason_from_bot
- Add kind enum: 'user_section_edit' | 'destructive' | 'read_out_of_scope'
- Add check_grant_fn / insert_grant_fn injection to PermissionGate for
  per-conversation grant lookup (skips interactive flow on repeat approvals)
- Add conversation_id field to Session dataclass

status:
- Rename message → text; add phase enum
- SDK status events forward phase from caller (bot pipeline)
- PassthroughEntry (send_status_update) emits phase='tool_call'
- Unknown events default to phase='main_reasoning'

translation.py / agent_translations.yaml:
- Add kind field to UserActionEntry
- Add new tools: read_context, write_node_memory, read_memory, grep_context,
  search_context, search_memory, mcp__tether__ aliases, propose_user_memory_write

DB migration:
- k1l2m3n4o5p6_permission_grants: new table with RLS for per-conversation
  tool-approval grants (user_id, conversation_id, target, kind)

Frontend (minimal — field renames only, no new UI):
- types/chat.ts: new WsIncomingEvent shapes, PermissionRequest, ChatMessage.actions
- stores/chat.ts: update agent_action, permission_request, status field names
- Stores __tests__/chat.test.ts: update test fixtures to new schemas

Tests: 139 pytest passing (21 new), 17 vitest chat-store passing (pre-existing
usePoolWarm failures are unrelated to this change)
Replace old `summary`/`details` fields with new `kind`/`target`/`reason_from_bot`
fields in PermissionModal.vue and its test fixture. Display kind as a human-readable
label and target in monospace; show reason toggle only when reason_from_bot is present.

Fixes docker-build TS2339/TS2353 errors from stale field references.
…ests

Defensive: prevents any future hang in test_permissions.py and
test_new_event_schemas.py from blocking CI for 6h. Root cause of
current CI hang (after test_redis_pubsub at 31%) is pre-existing in
dev base, unrelated to this PR.
_seed_users() commits mc_a@test.com / mc_b@test.com outside any rolled-back
transaction. These leaked into test_auth_routes.py (which runs next in CI),
causing get_user_count() > 0 and first-user register returning 400.

Add module-scoped autouse cleanup fixture that deletes both seeded users
by ID after all schema tests finish.
…solation

list_nodes_index was relying solely on the RLS SET LOCAL mechanism for
user scoping. list_conversations_index uses an explicit WHERE user_id = $1
which is both more defensive and consistent. Align nodes index to the same
pattern to fix the test_nodes_index_rls CI failure.
Async module-scoped fixture conflicts with asyncio_default_fixture_loop_scope=function
in pytest-asyncio, erroring all 33 schema tests and breaking the test job.

Switch cleanup_schema_test_users to a sync fixture that creates its own event loop
for the teardown, avoiding the loop scope mismatch.
Add lightweight conversation and node index endpoints
feat(frontend): Stream E — index-based tree + optimistic UI
feat: memory-context v2 schema + MCP tools (Stream A foundations)
feat(layer): Stream B event schema migration (agent_action, permission_request, status)
db: merge alembic migration heads (stream A + stream B)
… context

onMounted/onUnmounted are no-ops outside a Vue component context, so the
5 tests in usePoolWarm.test.ts all failed (0 API calls observed). Fix:

- Add withSetup() helper that mounts the composable inside a defineComponent
  so lifecycle hooks fire correctly; call app.unmount() for cleanup.
- Switch vi.useFakeTimers() to { toFake: ['setTimeout', 'clearTimeout'] }
  to leave Vitest's own scheduling intact (matches the pattern used in
  useDragEdgeScroll.test.ts).
- Add vi.runOnlyPendingTimers() before vi.useRealTimers() in afterEach.

5 previously failing tests now pass; full suite (947 tests) stays green.
write_node_memory:
- conversation_id now required; returns {error: conversation_id_required} if absent
- read-before-write check upgraded from advisory WARN to hard block for all
  write modes (additive, edit, delete); returns {error: read_before_write_required}
  if no prior node_read_log entry exists for the conversation
- removed v1 warning fields from success responses

read_context:
- conversation_id now required; returns {error: conversation_id_required} if absent
- scope envelope enforcement (out_of_scope structured errors) confirmed and tested

Tests:
- tests/mcp/test_write_node_memory_enforcement.py (12 tests, mock-based)
- tests/mcp/test_read_context_enforcement.py (10 tests, mock-based)
- Updated tests/tether_mcp/test_server.py to assert new error response shape
Replace stub implementations (always returning empty) with real
Postgres-backed search:

- search_context: tsvector FTS via plainto_tsquery over node_sections,
  ranked by ts_rank, supports path-restricted subtree search
- search_memory: ILIKE search over user_memory/user_durable_memory by
  key and value, relevance-ranked (exact > prefix > value match)

New pg_queries:
- db/pg_queries/sections.py: search_sections_fts() — FTS with score,
  node_name join, optional node_ids filter
- db/pg_queries/memory.py: search_user_memory() — ILIKE search with
  relevance scoring

Update server.py:
- Add paths param to search_context tool
- Fix stale 'v1 stub' docstrings on both tools

Tests: 23 new tests in tests/mcp/test_search_tools.py, all passing
fix(frontend): usePoolWarm composable tests — lifecycle hook context
feat(frontend): Stream D event renderer — action pills, status indicator
feat(mcp): harden write_node_memory read-before-write + read_context conversation_id enforcement
feat(mcp): implement search_context and search_memory tools
…it__

Add check_grant_fn and insert_grant_fn optional kwargs to Layer.__init__.
Both are threaded to PermissionGate in run_turn so premium callers can
inject DB-backed grant functions at startup without touching the public code.

PermissionGate already accepts and uses these fns (landed in dev via
feature/events-renderer). This PR closes the wiring gap: Layer now stores
and forwards them.

Matched premium PR: tether-premium feature/prompt-assembler (Stream C).
feat(layer): wire check_grant_fn / insert_grant_fn through Layer.__init__
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant