Skip to content

feat(handlers): surface user-quoted portions from reply context - #157

Merged
dylanneve1 merged 2 commits into
mainfrom
feat/reply-with-quote
May 13, 2026
Merged

feat(handlers): surface user-quoted portions from reply context#157
dylanneve1 merged 2 commits into
mainfrom
feat/reply-with-quote

Conversation

@claudiusthebot

Copy link
Copy Markdown
Collaborator

Summary

Telegram (Bot API 7.0+, Dec 2023) lets users highlight a specific substring of a message before replying β€” that selection arrives on the incoming message as ctx.message.quote (TextQuote). Talon was ignoring it: the model only ever saw the full replied-to text and had no way to know which part the user was actually pointing at.

This wires ctx.message.quote into getReplyContext at both call sites (text handler + media handler). When the user has highlighted a portion, the prompt now includes an explicit second line:

[Replying to Alice: "<full original message>" msg_id:42]
[Quoted portion: "<the substring Alice highlighted>"]

Behaviour details

  • Manual quote (is_manual: true) β€” user explicitly selected text. Always shown.
  • Server-auto quote (is_manual: false) β€” Telegram snipped a long message. Shown the same way; still useful signal about what the sender considered most relevant.
  • No quote β€” current behaviour unchanged; second line omitted.
  • Truncation β€” quote text capped at 500 chars to match the full-text limit.
  • Whitespace-only / empty quote β€” dropped to keep the prompt clean.

Scope

Inbound only. Outbound send.quote (so the bot can quote-reply to specific parts when replying) is a separate change if wanted β€” happy to follow up.

Test plan

  • 6 new vitest cases under getReplyContext β€” quoted portions (Bot API 7.0):
    • manual quote appended
    • server-auto quote (is_manual=false) appended same way
    • no quote β†’ omits the second line (regression for current default)
    • missing / empty / whitespace-only quote.text β†’ omits the second line
    • quote text >500 chars β†’ truncated to 500
    • quote present on media-only replied-to message β†’ both [photo] and [Quoted portion: …] visible
  • npm run typecheck clean
  • npm run lint β€” 0 new warnings (11 pre-existing on unrelated lines)
  • npm test β€” 1893/1906 pass (1 pre-existing package.functional flake from running tests on a host where Talon is already up; verified failing on main too)

πŸ€– Generated with Claude Code

@dylanneve1
dylanneve1 enabled auto-merge (squash) May 13, 2026 13:11
@dylanneve1
dylanneve1 force-pushed the feat/reply-with-quote branch from 0b4091d to 4a52707 Compare May 13, 2026 13:19
…API 7.0)

When a user replies to a message in Telegram, they can highlight a specific
substring before sending β€” that selection arrives on the incoming message as
`ctx.message.quote` (TextQuote, Bot API 7.0+). Talon was ignoring it; the
model only saw the full replied-to text, never the part the user was actually
pointing at.

This passes `ctx.message.quote` into `getReplyContext` at both call sites
(text + media handlers). When present, the prompt now includes an explicit
second line:

  [Replying to Alice: "<full original message>" msg_id:42]
  [Quoted portion: "<the substring the user highlighted>"]

Server-auto quotes (long-message snippets where `is_manual=false`) are
included the same way β€” still useful signal. Quote text is truncated to 500
chars to match the full-text limit. Empty / whitespace-only quotes are
dropped to keep the prompt clean.

Inbound only β€” outbound `send.quote` is a separate change if wanted.

- 6 new vitest cases under `getReplyContext β€” quoted portions (Bot API 7.0)`
- typecheck clean, no new lint warnings
- 1906 tests / 1 pre-existing flaky `package.functional` failure unrelated
@dylanneve1
dylanneve1 force-pushed the feat/reply-with-quote branch from 4a52707 to 1c645ac Compare May 13, 2026 13:26
@dylanneve1
dylanneve1 merged commit 92210ec into main May 13, 2026
22 checks passed
dylanneve1 added a commit that referenced this pull request May 19, 2026
… SDK's native error pipeline (#159)

When a turn-terminator tool (`end_turn`, strict `react`) failed to deliver
(e.g. Telegram rejected `end_turn` for "Message too long", invalid chat_id,
network blip), the PostToolBatch hook terminated the SDK loop anyway β€” the
model saw the error in its tool result but had no turn left to react. End
result: silent dropped turn, user sees nothing.

Canonical incident (2026-05-13 13:11Z Pandario reply 226264): end-of-turn
delivery of a 4326-char message hit Telegram's 4096 cap, bridge returned
`{ok: false, error: "Message too long..."}`, hook fired regardless, turn
silently ended.

Supersedes #158 (content-sniffing approach was fragile β€” frontend-coupled,
schema-drift-vulnerable, false-positive-prone on responses that happened
to contain `"ok":false` substrings).

This PR uses the SDK's NATIVE error pipeline instead of inspecting bodies.

Implementation:

1. `end_turn.execute` and `react.execute` THROW when the bridge returns
   `{ok: false}` instead of returning the failure object silently. A new
   `throwIfFailed` helper wraps the bridge result and raises a typed
   `Error("<tool> delivery failed: <bridge error>")`. The "what counts
   as a failure" decision now lives in the tool implementation, where the
   contract is owned.

2. The SDK observes the throw and fires `PostToolUseFailure` with a typed
   `{tool_name, tool_input, tool_use_id, error, is_interrupt}` payload β€”
   no string sniffing, no `unknown` parsing.

3. New `PostToolUseFailure` hook records the failed `tool_use_id` in a
   per-session `Set<string>`. Ignores interrupts (`is_interrupt: true`)
   and non-terminator failures (e.g. `send`).

4. `PostToolBatch` hook now consults the Set β€” if the terminator's
   `tool_use_id` was flagged, it deletes the flag and returns
   `{continue: true}` to keep the SDK loop alive. Otherwise terminates
   as before (perf win from PR #122 preserved on the happy path).

5. The two hooks share state via closure β€” `buildTurnTerminatorHooks()`
   creates a fresh Set per `buildSdkOptions()` call, so concurrent chat
   sessions stay isolated.

Frontend-agnostic by design: any frontend whose tools throw on delivery
failure gets the same recovery behaviour. No bridge envelope shape is
baked into the SDK options layer.

Tests:
  - 9 new `PostToolUseFailure + PostToolBatch coordination` cases
    (terminator failure preserves loop, success terminates, interrupt
    ignored, non-terminator failure ignored, soft-react `end_turn:false`
    ignored, defensive non-failure events, flag-consumed-on-match,
    per-session isolation).
  - 8 new messaging-tools cases for `end_turn` / `react` throw behaviour
    (text path throws on {ok:false}, buttons path throws, generic
    message when error field missing, success path unchanged, react
    strict + soft both throw, react strips end_turn param).
  - All 33 existing PostToolBatch hook tests still pass.
  - 2001/2014 vitest pass β€” same pre-existing `package.functional` flake
    as PR #157 (irrelevant: running tests on a host where Talon daemon is
    already live).
  - typecheck clean, prettier clean, no new lint warnings.

Co-authored-by: Dylan Neve <dylan.neve@intel.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.

2 participants