Skip to content

feat(remote): Telegram remote control — settings, live mode, pairing codes, and usage footer - #27

Open
manhnguyen24-dev wants to merge 71 commits into
mainfrom
feature/remote-telegram-control
Open

manhnguyen24-dev wants to merge 71 commits into
mainfrom
feature/remote-telegram-control

Conversation

@manhnguyen24-dev

Copy link
Copy Markdown
Contributor

Summary

Full remote-control surface for EvoFlux over Telegram: pair a phone, watch and steer a task from it, and change how the agent runs — without touching the desktop app.

  • Decidable approval cards — a permission request shows the actual command, its severity, and resolves visibly (once / for this session / rejected) instead of sitting unresolved forever.
  • /settings — change permission mode (ask / accept-edits / plan / autobypass is deliberately unreachable from the phone), the model, the lead agent, and the response style, plus a read-only configured-provider count.
  • /health and /changes — the same diagnostics and file-change summary the desktop app shows, with per-file diff drill-down.
  • Live mode — set response_mode = live and a task's status card updates in place with a rolling window of tool activity (skill name, thinking state, active agent) instead of going silent until it's done, throttled and budgeted so it can't spam the chat.
  • Onboarding — a successful pairing now offers Set up / Health check / Just start working instead of a dead-end confirmation text.
  • /pair — type an 8-digit code shown on the desktop (with a QR code) instead of only supporting a deep link.
  • /history — browse and drill into recent sessions from the phone.
  • /clear — delete the bot's own recent messages in the chat (Telegram only allows a bot to delete what it sent, not what you sent).
  • Usage footer + fixes — done cards now show model, token counts, cached tokens, and cost; fixes four bugs found in live testing (tool log showing unknown: {}, cost/model never appearing, raw HTTP error dumps) — see documents/plans/telegram-integration-testing-2026-09.md for the root-cause writeup.

What changed

  • app/remote/ — the whole remote-control surface: pairing (deep link + code), settings/health/changes/history/clear commands, live-mode activity window and edit budget, capability tokens for card buttons, HTML rendering and markdown-to-Telegram conversion, outbound redaction.
  • app/api/routes/remote.py, app/api/schemas/remote.py — pairing-code issue/verify endpoints.
  • app/models/remote.py + 4 migrations (0000006700000070) — RemoteConnection/RemotePairing, notify scope, response mode, pairing-code fields.
  • web/src/routes/settings.remote-access.tsx and related — desktop-side pairing UI (QR code, pairing code).
  • Full tests/remote/ suite (280+ tests) covering every command, capability token, and edge case (rejected pairing reveals nothing, bypass unreachable by any path, budget starvation guards, etc.).

…store

Foundation for the Telegram remote-control feature (spec:
documents/plans/remote-channel-telegram.md, plan:
documents/plans/remote-access-telegram-implementation.md).

- app/core/credential_store.py: standalone, keyed OS-vault credential store
  (service/account at construction). Independent of app/conductor/, which
  keeps its own separate implementation unchanged - evo-conductor depends on
  Conductor's current behavior and constructor signature.
- app/models/remote.py: RemoteConnection (per-installation bot connection,
  non-secret metadata only) and RemotePairing (connection-scoped principal/
  destination binding), both registered in app/models/__init__.py and
  app/migrations/env.py.
- Migration 00000064 creates remote_connections and remote_pairings;
  SCHEMA_HEAD bumped to match.
- Tests: tests/core/test_credential_store.py, tests/remote/test_models.py,
  and extended tests/core/test_alembic_migrations.py to assert the new
  tables/columns/FKs/unique constraints at head. tests/conductor/test_client_service.py
  still passes unmodified, confirming Conductor has zero behavior change.

AC-3, AC-4, AC-5, AC-6, AC-17 (partial - schema only; service/route behavior
follows in Task 2+).
…n service

Adds RemoteSettings (outbound_data_policy/outbound_pii_policy, defaulting
to redact/standard) wired into RuntimeSettings and exposed via
GET/PUT /api/settings/remote. Adds app/remote/contracts.py with the
provider-neutral, connection-aware value types and protocols for the
remote-access seam (RemoteAdapterKind, RemoteConnectionState,
RemoteErrorClass, RemotePrincipal, RemoteInboundAction,
RemoteOutboundMessage, RemoteAdapterStatus, RemoteAdapter,
RemoteAdapterFactory) with no Telegram/httpx import anywhere. Adds
RemoteConnectionService (create_connection/update_token/set_enabled/
remove/list/get) enforcing the v1 one-connection product limit, validating
a bot token before vaulting it, keeping DB transactions free of vault/
network calls with compensating vault cleanup on commit failure, and
invalidating a connection's pairing row on bot-identity replacement while
leaving it untouched for a same-bot token rotation. Extends
OutboundChannel with the "remote" value for later tasks' explicit policy
selection. app/conductor/ is untouched.
…ove()

Review finding on Task 2: remove() deleted and committed the DB row before
attempting the vault delete, so a vault-delete failure permanently
stranded the bot token in the OS credential store with no remaining row
naming its account key. Reorders to vault-first, DB-second — the same
pattern create_connection/update_token already use — so a vault failure
now leaves the row intact (safely retryable) instead of orphaning the
secret.

Updates test_remove_reports_vault_deletion_failure to assert the row
survives a vault-delete failure, and adds a retry-after-recovery test
proving remove() can be called again once the vault is reachable.
Adds a Telegram-specific HTTP client (getMe/deleteWebhook/getUpdates/
send_text/edit_text/answer_callback/set_commands) and a TelegramAdapter
polling lifecycle, isolated from connection_service.py and Conductor and
fully testable against httpx.MockTransport.

- Bounded pydantic models for the subset of the Bot API wire format used.
- Safe error classes (TelegramApiError/TelegramTransportError/
  TelegramMalformedResponseError/TelegramCallbackDataError) that never
  carry the request URL or bot token, backed by tests sweeping every
  error path.
- Adapter mirrors app/conductor/service.py's start/stop shape: one owner
  task, an asyncio.Event for interruptible backoff/rate-limit sleeps, and
  task cancellation to interrupt a genuinely in-flight long poll.
- Implements the spec's Telegram error taxonomy: webhook conflict cleared
  once and retried immediately, concurrent-consumer conflict -> bounded
  backoff in used_elsewhere, invalid token terminal for the current
  start(), 429 honors retry_after verbatim, transport/unknown failures ->
  exponential backoff with jitter, and phone-unreachable delivery
  failures never affect the inbound poll loop.
- Never sets parse_mode; enforces the 1-64 byte callback_data contract
  before sending; deletes any webhook and drops pending updates before
  the first getUpdates call.

53 tests in tests/remote/telegram, ruff and ty check clean on the
touched files.
Code review found that TelegramAdapter.answer_callback called the client
with no try/except, while send/edit both catch TelegramApiError and call
_record_delivery_failure. The spec's taxonomy groups all three surfaces
under one phone-unreachable delivery-status requirement, independent of
poll-loop health, so answer_callback now matches send/edit: it records
delivery failure/success around the underlying call and re-raises on
failure, same as the other two.

Adds two adapter tests parallel to the existing send-failure/send-success
coverage, proving an unreachable-chat 403 on answerCallbackQuery sets
phone_reachable/last_error_class without touching poll-loop state, and a
successful answer marks phone_reachable again.
Add PairingService (app/remote/pairing.py): issues single-use pairing
tokens (secrets.token_urlsafe(16), 128 bits, 22 chars, Telegram-safe
[A-Za-z0-9_-] alphabet, ten-minute monotonic expiry, in-memory only),
binds a RemotePrincipal to a RemotePairing row only once token validity,
private-chat-ness, non-bot-sender, connection match, and the one-
pairing-per-connection limit all pass, and authorizes/deauthorizes
inbound principals with no caching lag after unpair (AC-7..AC-10).

Design choice: issue_link stays a pure in-memory operation with no DB
access; the one-pairing-per-connection limit is enforced in consume()
instead, so re-pairing requires an explicit unpair first (matching the
spec's separate Connect phone / Unpair phone actions). Every consume()
failure returns the identical None and burns the token only on success,
so a legitimate retry (e.g. group chat -> private chat) still works.

Rate limiting reuses the sliding-window algorithm already proven in
app/services/webbridge_pairing_service.py, replicated as a small private
helper to avoid a cross-feature import for a generic utility.
PairingService.consume() separated its "no existing pairing" SELECT
and its token pop/insert with await points and no lock held across
them. Two concurrent consume() calls could both pass the
one-pairing-per-connection check before either committed (breaking the
v1 limit), or both race past the same since-consumed token toward a
duplicate insert (an unhandled IntegrityError instead of the uniform
refusal).

Add a per-instance asyncio.Lock (same pattern as WebBridgeTicketStore
in app/services/webbridge_pairing_service.py) held across the whole
existing-pairing-check -> token-pop -> insert sequence, and check
tokens.pop()'s return value as defense-in-depth even under the lock.

Confirmed the race was real before this fix: running the two new
concurrent-consume tests against the pre-fix code hung indefinitely
(concurrent unsynchronized use of the same AsyncSession), rather than
racing to a clean duplicate-row or IntegrityError outcome.
Adds the authenticated /api/remote/* HTTP surface (connections CRUD,
token replace, pairing-links/pairing/status) as thin routes over the
existing RemoteConnectionService/PairingService, and app/remote/runtime.py
as a process singleton (start/stop/reconcile_connection/status) that
lazily constructs a real TelegramAdapter only for an enabled, credentialed
connection - every Telegram import stays function-local so an idle
installation never pulls app.remote.telegram.* into sys.modules, creates
a task, or touches the network (AC-1).

Wires remote_runtime.start()/stop() into app/api/app.py's existing
optional-service startup/shutdown pattern, alongside Conductor/Scheduler,
without touching app/conductor/. Adds RemoteConnectionService.set_label
(mirrors set_enabled) so PATCH can rename a connection.
Adds documents/plans/remote-telegram-response-ui.md, a proposed design
that completes the accepted remote-channel-telegram.md feature with
HTML-formatted messages, a typing-indicator status lifecycle, cross-origin
completion notifications, a guided project/prompt picker, and a
read-mostly /settings view. Explicitly amends AC-24 (parse mode) and the
outbound-redaction portion of AC-32/Non-goals, with a cross-reference note
added to the original spec.
Natural-language ingress and current-task behavior (Task 6, complete),
plus in-progress work on stream projection (Task 7), gate cards (Task 8),
and secondary actions (Task 9) per documents/plans/remote-access-telegram-implementation.md.
Committed as-is to give the response-UI plan a stable base to branch from;
Tasks 7-9 test coverage is not yet complete.
- render_gate_card: escape label from actions
- render_settings_card: escape name in Redaction/Notify buttons
- render_prompt_suggestions: escape label from suggestions
- Add 3 test cases verifying escaping of HTML chars in dynamic labels
TelegramClient.send_text/edit_text now always send parse_mode="HTML"
(AC-24, revised) since callers pass text already rendered through
app/remote/formatting.py's HTML-escaping. Adds
TelegramClient.send_chat_action and a matching indicate_typing on the
RemoteAdapter Protocol and TelegramAdapter, a best-effort "still
typing" liveliness primitive for AC-38 that later turn-lifecycle code
will call.
… paths

Review flagged that indicate_typing's widened except clause (added
beyond the brief's TelegramApiError-only sketch) was untested for its
TelegramTransportError and TelegramMalformedResponseError branches.
Adds coverage for both, and adds debug-level logging on swallowed
failures (previously a bare pass) so the high-frequency liveliness
call stays diagnosable without warning-level spam.
RemotePairing.notify_scope (default "all") lets a pairing record whether
it wants notifications from every session or only ones the phone itself
started (AC-42, schema half). RemoteProjection gains an in-memory
active-pairing cache (connection_id, destination_id, notify_scope,
principal_id) so future notification logic can look up the single v1
pairing without a database query per event; the runtime keeps it in sync
on startup restore, on a fresh pairing, and on stop.
RemoteActionService now holds an optional reference to the outbound
RemoteProjection (set_projection, wired from RemoteRuntime._start_locked)
so _cmd_unpair can call clear_active_pairing() immediately after the
pairing row is deleted. Without this, /unpair revoked callback/menu
tokens and inbound authorization but left the in-memory active-pairing
cache added earlier in this branch stale until the runtime restarted —
a regression against AC-10's "immediate revocation" guarantee once that
cache is read by future notification logic.
Adds turn_activity.py (queries SessionMessage rows for a turn's tool-call
summary, used for the done-card's "N tool calls" line) and the
phone-admitted status-message lifecycle on RemoteProjection:
begin_phone_turn() sends one status card and starts a repeating native
typing indicator; _finalize_turn() builds the final done/error card and
edits that same message instead of sending a new one, then stops typing.
Fixes 5 Important issues found in code review of the phone-admitted
status lifecycle:

- Typing loop now re-checks self._adapter every iteration (not a stale
  snapshot) and survives a transient indicate_typing error; set_adapter(None)
  stops every live turn's typing task on shutdown instead of relying on a
  done/error event that may never arrive.
- begin_phone_turn reuses an existing unresolved turn's status message
  (stop its typing task, edit in place) instead of overwriting turn state
  and leaking the old typing task and status card.
- drain_pending is now serialized with an asyncio.Lock (matching
  PairingService._consume_lock's precedent), and _finalize_turn only edits
  a status card that a new _sent_correlations set confirms was actually
  sent, falling back to a fresh send otherwise so a card is never silently
  dropped.
- The _finalize_turn call inside drain_pending's pump is now wrapped in the
  same try/except as every other adapter call, so a DB failure there can't
  abandon the rest of the queue.
- A registered-but-never-begun turn's card now falls back to a "Task"
  title instead of rendering blank.

Also addresses the reviewer's quick minor items: redact turn.title before
interpolation, factor out the duplicated connection-id lookup, guard
begin_phone_turn's create_task with the same RuntimeError pattern used
elsewhere, and add coverage for the typing-task cleanup paths.
…vity

The remote channel can start work but can't be trusted with it: permission
cards name only the tool ("Permission requested: shell") while the actual
command sits unused in the event payload; the default `auto` mode never
blocks, so approvals are structurally unreachable; and a running turn shows
nothing about what it's doing.

Amends five accepted contracts — AC-20/AC-22 (tool names allowed in the
opt-in live mode only), AC-28 (session-scoped `always` was never the
permanent grant the restriction assumed), AC-32 and AC-41 (mode, model and
lead agent become remotely settable), AC-38 (throttled live edits) — and
carves `bypass` out as permanently desktop-only, since a phone that can
silently disable every approval prompt is an escalation path, not a feature.
Direct user feedback: /new, /stop, and /actions "didn't mean anything"
without already knowing EvoFlux's task/turn model, and the pairing
confirmation read like a log line ("Connected to EvoFlux on {label}.")
with no next step. Rewrites lead with the free-text behavior first (the
thing users were most confused about), state each command's before/after
in plain terms, and give the pairing card something to do next.

Design and rationale: documents/plans/remote-telegram-copy-improvements.md
A handler exception was logged at warning level with the connection
and update id only — no traceback. Live debugging against a real bot
had nothing to go on beyond "it failed." logger.exception captures the
stack trace; behavior (retry the same update on next poll) is unchanged.
A conversational turn with zero tool calls rendered as an empty-looking
"✅ Title · 12s / 0 tool calls" card with no answer anywhere in it — live
testing against a real bot showed the app's own thread had the reply,
Telegram never got it. A "done" StreamEnvelope carries no text field at
all (DoneEvent has only type/metadata), so the fix reads the turn's last
persisted assistant message instead, the same source the app itself
reads from.

Also stopped attaching an always-present "Tool log" button that only
ever opened an empty "No tool calls." page for the (most common)
tool-free case — now shown only when the turn actually called a tool.
…g sys.modules

Confirmed a genuinely flaky (not just theoretically risky) test failure:
tests/remote/telegram/test_adapter.py::TestDeliveryIndependentOfPolling
failed 40-70% of full-suite runs, always passed alone. Bisected to a raw
sys.modules.pop() (untracked by monkeypatch, never restored) that forces
every later test to import a second, distinct TelegramApiError class —
so a later test's freshly-local `from ... import TelegramApiError` binds
to a different class than the one already-imported code actually raises,
and pytest.raises(TelegramApiError) silently stops matching.

monkeypatch.delitem() instead of the raw pop restores it at teardown.
Verified with 8 consecutive full-suite runs (0 failures, was ~40-70%).
Phase 1 (Task 1) of documents/plans/remote-telegram-approvals-implementation.md
— pure display-hint derivation, never consulted by any gating decision.
Phase 1 (Task 2) of documents/plans/remote-telegram-approvals-implementation.md
— the card builders only; not yet wired into gates.py.
Phase 1 (Task 3, steps 1-5) of
documents/plans/remote-telegram-approvals-implementation.md — extracts
the permission_asked branch into _on_permission_asked, wired to the new
severity/formatting helpers. Every gate card (including question/plan)
now carries a correlation_id.
Phase 3 (Task 4, final) of documents/plans/remote-telegram-insight-implementation.md.

Completes AC-52: /changes lists the active session's changed files with
line counts; tapping one fetches that file's real diff at tap time via
control.get_file_diff (not a pre-captured string) — the first drill-down
capability in this codebase that fetches fresh rather than reading
action_target verbatim, since a diff must reflect the file's current
state, not its state when the turn finished.
Phase 4 (Task 1) of documents/plans/remote-telegram-response-mode-implementation.md.

Adds the column and defaults it to "summary" — zero behavior change,
nothing reads this yet. That's a separate, larger phase (AC-56/57).
Phase 4 (Task 2) of documents/plans/remote-telegram-response-mode-implementation.md.
Phase 4 (Task 3) of documents/plans/remote-telegram-response-mode-implementation.md.
Task 4 of documents/plans/remote-telegram-response-mode-implementation.md.
/settings now issues set_response_mode capability tokens (keyed on the
pairing id, since response_mode lives on RemotePairing rather than a
chat session) and renders the Responses toggle buttons; the callback
handler applies the selected mode via control.set_response_mode.
Covers AC-56 (rolling activity window content and bounds) and AC-57
(throttled, budgeted live-mode edits), plus the small AC-58 command-menu
gap left over from earlier phases.
Task 1 of documents/plans/remote-telegram-live-mode-implementation.md.
Task 2 of documents/plans/remote-telegram-live-mode-implementation.md.
Task 3 of documents/plans/remote-telegram-live-mode-implementation.md.
…n (AC-55/56)

Task 4 of documents/plans/remote-telegram-live-mode-implementation.md.
RemoteInboundResult now carries the pairing's response_mode (captured
before handle_text's mid-turn rollback expires the ORM instance), and
runtime.py passes it into begin_phone_turn.
… (AC-56/57)

Task 5 of documents/plans/remote-telegram-live-mode-implementation.md.
observe() now feeds tool_call/tool_start/tool_end/thinking into each
live-mode turn's LiveActivityWindow and schedules a throttled, budgeted
status-card edit via the new shared EditBudget; summary-mode turns are
unaffected (AC-20 regression guard). A turn's final done/error card
always bypasses the budget, and a Telegram 429 degrades that
connection's cadence via retry_after.
…AC-58)

Task 6 of documents/plans/remote-telegram-live-mode-implementation.md.
Closes the last gap in the bounded command surface — /settings, /health,
and /changes have been dispatchable since earlier phases but were never
added to Telegram's native "/" command menu.
Task 1 of documents/plans/remote-telegram-providers-implementation.md.
…-53)

Task 2 of documents/plans/remote-telegram-providers-implementation.md.
Task 3 of documents/plans/remote-telegram-providers-implementation.md.
Task 4 of documents/plans/remote-telegram-providers-implementation.md.
AST-based rather than a raw-text scan, so control.py's own module
docstring (documenting which functions it deliberately never calls) isn't
mistaken for a real reference.
…ssage (AC-54)

Task 1 of documents/plans/remote-telegram-onboarding-implementation.md.
…AC-54)

Task 2 of documents/plans/remote-telegram-onboarding-implementation.md.
Task 3 of documents/plans/remote-telegram-onboarding-implementation.md.
Closes the last item in the original control-surface spec (AC-45
through AC-58).
These have been dispatchable and in Telegram's own command menu since
Phase 2/3, but _HELP_TEXT was never updated to mention them — a paired
user typing /help had no way to discover they existed.
Tracks the last 200 message ids this bot sent per destination and, via
clear_history(), deletes them through Telegram's deleteMessage API,
tolerating individual failures (e.g. a message past Telegram's 48-hour
deletion window) rather than aborting the whole clear. Prep for a
/clear command.
A deliberate, post-spec addition beyond the original control-surface
document's AC-58 command set, requested directly this session. Deletes
only messages the bot itself sent (Telegram's own restriction) via the
adapter's clear_history(); an adapter without that capability gets a
plain "can't clear messages" reply instead of an error.
Adds two new phone-facing commands: /pair (type an 8-digit code shown on
the desktop instead of clicking a deep link, with QR support) and
/history (browse and drill into recent sessions). Both are wired into
the Telegram command menu and /help alongside the existing set.

Fixes four bugs found during live Telegram testing (see
documents/plans/telegram-integration-testing-2026-09.md for the full
root-cause writeup):
- Tool log always showed "unknown: {}" — tool_calls are stored in
  OpenAI's nested {"function": {...}} shape, not flat.
- Cost never appeared — UsageEvent.cost is a dict of components, not a
  scalar.
- Model name never appeared — it lives in metadata.models, not a
  top-level field.
- Error cards dumped raw HTTP details instead of a friendly message.

Also adds a context-window/token/cost usage line to the done card, a
markdown-to-Telegram-HTML renderer for richer responses, and a new
remote_pairings migration (pair_code_hash/pair_code_expires_at).

Audit fixes applied while reviewing this batch before commit:
- tests/remote/telegram/test_adapter.py: a test asserted against an
  undefined `status` variable (broken since it was written) — added
  the missing `adapter.status()` call.
- app/remote/actions.py: the new /pair success path called
  set_active_pairing(pairing) with a single positional argument against
  a method requiring four keyword-only strings — would have raised
  TypeError on every successful code-pairing. Fixed to match the
  existing call pattern in runtime.py.
- app/api/routes/remote.py: revoke_pairing built a RemoteOutboundMessage
  with connection_id=str(connection_id) against a UUID-typed field —
  removed the unnecessary/incorrect str() coercion.
- app/remote/pairing.py: two SQLAlchemy `.is_not(None)` calls were
  suppressed with `# type: ignore[union-attr]`, which this project's
  `ty` checker doesn't recognize — switched to `# ty: ignore[...]`.
- app/remote/actions.py: removed an unused, already-stale `CommandName`
  Literal type alias (never referenced, and missing half the real
  commands) and its now-unused `Literal` import.
- app/remote/telegram/adapter.py: /pair and /history were dispatchable
  and in /help but missing from the Telegram command menu itself —
  same class of gap fixed for /settings/health/changes earlier tonight.
- Fixed a missing-space typo in /pair's usage message, and a pending
  ruff-format diff in gates.py.

Full tests/remote/ suite (all touched and pre-existing tests), ruff,
and ty all pass after these fixes.
…am-control

# Conflicts:
#	app/core/schema_version.py
#	app/migrations/env.py
#	app/models/__init__.py
#	documents/features/README.md
…ode switch

The migration that added remote_pairings.response_mode still shipped
server_default="summary" after the model's Python-level default was
changed to "live" during live testing — the two must agree since a raw
insert would otherwise land on the wrong value. Also fixes a stale test
(tests/models/test_remote_models.py) that still asserted the old
"summary" default; tests/remote/test_inbound.py was already updated to
expect "live" when this default changed, but this second, independent
assertion was missed.
…nded

git mv renames a file's path but not its content — after renaming the
four remote_pairings migrations to 00000067-00000070 (to sit after
main's independently-numbered 00000064-00000066), I edited each file's
revision/down_revision strings but only re-staged one of the four
(00000069). The other three (00000067, 00000068, 00000070) kept their
pre-rename revision ids in the actual commit despite the working tree
looking correct, which broke alembic's revision graph
(KeyError: '00000068') the moment a fresh checkout (no stale working
tree) tried to resolve it — exactly what happened on the just-synced
main checkout. Caught by re-running the full migration test suite
there instead of trusting the worktree's already-fixed working tree.
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