Skip to content

feat(eko): add quick replies for exec approvals - #53

Merged
S2P2 merged 2 commits into
mainfrom
feat/eko-quick-replies
May 31, 2026
Merged

feat(eko): add quick replies for exec approvals#53
S2P2 merged 2 commits into
mainfrom
feat/eko-quick-replies

Conversation

@S2P2

@S2P2 S2P2 commented May 31, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #52.

  • add Eko send_exec_approval() using /bot/v1/message/quickreply when a fresh reply token is available
  • map Eko quick-reply approval labels back to /approve, /approve session, /approve always, and /deny while a dangerous-command approval is pending
  • document Eko selectable prompt support and cover the quick-reply client payload

Tests

  • scripts/run_tests.sh tests/gateway/test_eko_plugin.py tests/gateway/test_approve_deny_commands.py

@github-actions

github-actions Bot commented May 31, 2026

Copy link
Copy Markdown

🔎 Lint report: feat/eko-quick-replies vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9524 on HEAD, 9524 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 4940 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@S2P2

S2P2 commented May 31, 2026

Copy link
Copy Markdown
Owner Author

PR #53 Review: `feat(eko): add quick replies for exec approvals`

Files changed: 6 | +388 / -1 | Branch: `feat/eko-quick-replies` → `main`

Summary

The PR adds Eko quick-reply support for three interactive prompts:

  1. Dangerous-command approvals — `send_exec_approval()` renders 4 quick-reply buttons ("Approve Once", "Approve Session", "Approve Always", "Deny")
  2. Slash confirmations — `send_slash_confirm()` renders 3 buttons ("Approve Once", "Always Approve", "Cancel")
  3. Clarify choices — `send_clarify()` override renders user-provided choices as quick-reply buttons

All three degrade gracefully when no reply token is available (returns `success=False`, gateway falls back to text). Quick-reply taps arrive as ordinary text messages; the gateway's existing text-intercept chain maps them to the right action.

What's Good

  • Clean fallback design. All three adapter methods return `SendResult(success=False)` when no reply token is available, letting the gateway's text fallback path handle it. No special-casing needed upstream.

  • Correct precedence chain. The new `_approval_command_for_text_reply` intercept is placed after clarify and slash-confirm intercepts in `_handle_message`, matching the documented priority: clarify > slash-confirm > tool-approval.

  • `_tool_approval_live` guard is correct. The slash-confirm block already skips when `_tool_approval_live` is true (`if _pending_confirm and not _tool_approval_live`), so "Approve Once" from the approval quick-reply won't accidentally resolve a slash-confirm.

  • Label mapping is tight. `_approval_command_for_text_reply` only accepts 4 exact normalized labels. No fuzzy matching, no risk of false positives from random user text.

  • `dataclasses.replace(event, text=...)` is safe. `MessageEvent` is a `@dataclass`, so replacing the text field works correctly and preserves all other context.

  • Clarify integration is correct. `mark_awaiting_text(clarify_id)` is called after rendering quick-reply buttons, so the text-intercept path catches the tap response. Falls back to `super().send_clarify()` when no token is available.

  • Tests are solid (232 pass). Coverage includes: label mapping, token-present/absent for all three methods, clarify awaiting_text state, integration test for "Approve Session" through `_handle_message`, client payload structure.

Issues and Suggestions

1. Minor: Clarify + approval race (theoretical, low severity)

If a clarify with `awaiting_text=True` and a tool approval are both pending simultaneously, the clarify intercept at line 7104 runs first and would capture "Approve Once" / "Deny" as a clarify response instead of an approval. This is unlikely in practice (the agent thread is blocked on clarify, so it can't call tools that trigger approval), but could surface with concurrent subagents sharing a session key.

Consider: Add a guard in the clarify intercept that skips known approval labels when `_tool_approval_live` is true, e.g.:

if _raw_clarify_reply and not _raw_clarify_reply.startswith("/"):
    # Skip if this looks like a quick-reply approval tap
    if _tool_approval_live and _raw_clarify_reply.lower() in {
        "approve once", "approve session", "approve always", "deny"
    }:
        pass  # fall through to approval intercept
    else:
        _resolved = _clarify_mod.resolve_gateway_clarify(...)

This is a nice-to-have, not a blocker — the current code is safe for single-agent sessions.

2. Nit: Inconsistent button label casing in `send_slash_confirm`

The approval quick-reply uses title case ("Approve Once", "Approve Always"), but slash-confirm uses "Always Approve" (not "Approve Always"). This is consistent with the existing slash-confirm text-intercept recognition set (which checks `"always approve"`), but it's visually inconsistent for a user seeing both types of prompts.

No action needed — this follows the existing gateway convention.

3. Nit: `send_exec_approval` returns `message_id=token`

return SendResult(success=True, message_id=token)

Using the consumed reply token as `message_id` is a reasonable identifier, but it means the "message ID" is a one-time token that's already been consumed. If anything downstream tries to use it for reply-to or editing, it would fail. This matches the pattern used by `send_slash_confirm` and `send_clarify` in this PR, so it's internally consistent.

4. Suggestion: Consider adding a test for the clarify+approval coexistence scenario

A test that has both a pending clarify (awaiting_text) and a pending approval, then sends "Approve Once" and verifies it resolves the approval (not the clarify), would lock down the precedence behavior for future changes.

Verdict

LGTM with one optional suggestion. The implementation is clean, correctly follows existing patterns, degrades gracefully, and has solid test coverage. The clarify+approval race is theoretical and not a blocker.

@S2P2

S2P2 commented May 31, 2026

Copy link
Copy Markdown
Owner Author

🐛 Bug: Quick-reply approval taps are silently queued, never dispatched

Reproduced live — after the quick-reply buttons render and the user taps "Approve Once", nothing happens. The agent stays blocked.

Root cause

The approval-label mapping intercept was added to _handle_message in gateway/run.py (line 7168), but the message is filtered before it reaches the runner — in base.py handle_message().

Trace

  1. User sends "run some python script…" → agent starts → terminal triggers approval → send_exec_approval() sends quick-reply buttons ✓
  2. User taps "Approve Once" → Eko delivers it as plain text (not a slash command) via webhook
  3. In base.py handle_message():
    • cmd = event.get_command()None (text is "Approve Once", not /approve)
    • should_bypass_active_session(None)False
    • Not a command → clarify bypass check → no pending clarify → False
    • Falls to _is_queue_text_debounce_candidateTrue (plain text, agent active)
    • Message is queued in _pending_messages, never dispatched to the runner
  4. _approval_command_for_text_reply in run.py never fires — the message is stuck in the pending queue

Evidence from logs

01:03:58 inbound message: msg='run some python script on the fly, i want to test the exec approval flow'
01:04:04 API call #1: model=gpt-5.4-mini (agent calls terminal → triggers approval)
01:04:15 webhook POST /eko/webhook 200  ← "Approve Once" tap arrives here
(no approval resolution log — message was queued, never dispatched)
01:07:13 STOP — agent interrupted manually after ~3 minutes of silence

The webhook at 01:04:15 is the "Approve Once" tap. No corresponding gateway intercept log, confirming it never reached _handle_message.


✅ Better fix: set value to the slash command in the quick-reply payload

The quick-reply value and display label don't need to be the same. If Eko delivers value (not the display label) as the tap-back text, set value to the slash command:

Button display Current value Fixed value
Approve Once "Approve Once" "/approve"
Approve Session "Approve Session" "/approve session"
Approve Always "Approve Always" "/approve always"
Deny "Deny" "/deny"

When the tap arrives as /approve, event.get_command() returns "approve"should_bypass_active_session("approve")True → existing slash-command bypass in base.py dispatches it directly. No new bypass needed.

Why this is better than adding a base.py bypass:

  1. Removes code instead of adding it_approval_command_for_text_reply() and the intercept block in run.py become unnecessary
  2. No new path in base.py — already 3600+ lines with 3 bypass paths; a 4th would be fragile
  3. Uses the existing, well-tested command bypass — same path /approve typed manually would take
  4. Same approach works for send_slash_confirm — "Approve Once" → /approve, "Always Approve" → /always, "Cancel" → /cancel (the slash-confirm intercept already matches _cmd_reply == "approve" etc.)
  5. Clarify is unaffected — clarify choices are dynamic (agent-specified), so they stay as plain text, and the clarify bypass in base.py already handles those correctly

One thing to verify first: does Eko deliver value or data.text on tap? The current payload sets both to the same string. Need to confirm which field the platform sends back, then set value to the slash command and keep data.text as the display label.

…t-active queue

The previous approach mapped approval labels in gateway/run.py, but
plain-text quick-reply taps were silently queued by base.py before
reaching the runner. Setting value=/approve etc. makes the tap arrive
as a real slash command that bypasses the queue via the existing
command-dispatch path.

Removes _approval_command_for_text_reply() and its intercept block
from gateway/run.py — fewer lines, no new bypass path.
@S2P2

S2P2 commented May 31, 2026

Copy link
Copy Markdown
Owner Author

✅ Re-review after fix

Diff looks good. The fix is exactly right:

  1. gateway/run.py changes removed_approval_command_for_text_reply() and the intercept block are gone entirely. No core gateway changes.
  2. test_approve_deny_commands.py changes removed — no longer needed since no gateway code was touched.
  3. client.py reply_quick_reply now accepts optional values param — when omitted, falls back to using the display label as value (backward-compatible).
  4. Adapter methods pass slash commands as valuessend_exec_approval sends ["/approve", "/approve session", "/approve always", "/deny"], send_slash_confirm sends ["/approve", "/always", "/cancel"]. Display labels stay human-readable.
  5. send_clarify unchanged — clarify choices are dynamic, no values override needed. The clarify text-capture bypass in base.py already handles those correctly.

Tests: 208 passed, 0 failed.

This is ready to merge. 🚀

@S2P2
S2P2 merged commit 9372ffd into main May 31, 2026
23 checks passed
@S2P2
S2P2 deleted the feat/eko-quick-replies branch May 31, 2026 18:30
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.

Add Eko quick replies for dangerous command approvals

1 participant