Chat-surface correctness: persistent scheduled sends, frontend-scoped tools + prompts - #454
Merged
Merged
Conversation
`send(..., delay_seconds=N)` lived only in an in-process setTimeout:
the model tells the user "reminder in 30 minutes", Talon restarts at
minute 20, and the promise silently evaporates. Cron jobs survive
restarts; scheduled sends now do too.
- New storage/scheduled-store.ts: pending sends persist in the kv
table ("scheduled.messages" blob — a handful of entries, well inside
kv's small-state doctrine). The store is the source of truth; the
per-frontend timer maps are just the armed alarms.
- Telegram + Discord schedule_message: persist before arming (a crash
between the two is recoverable in that order, fatal in the other).
Firing and cancelling clean up both store and timer.
- Restore on boot: when a frontend's action handler is created,
persisted entries re-arm. Overdue entries fire immediately — late
beats never — unless stale past 24h, which drops them (a week-late
"dinner in an hour" is noise, not delivery).
- cancel_scheduled now also clears store-only entries (scheduled
before a restart), and reports honestly when nothing existed.
- New list_scheduled tool (telegram + discord): id, seconds remaining,
text — so the model can inspect and manage pending sends.
- Delay cap raised 1h → 24h: with persistence, long delays are safe.
- Bugfix en passant: delayed sends dropped buttons and reply threading
on the tool side — the bridge now forwards rows + reply_to and the
fire path replays them.
New scheduled-messages.test.ts (9 tests): store CRUD/ordering,
schedule→fire→cleanup, cancel (live timer and store-only), per-chat
list, restore (re-arm, overdue-fire, stale-drop, frontend isolation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With frontend: ["telegram", "native"], every chat registered a tool server per configured frontend — so a native-app conversation saw mcp__telegram-tools__* alongside native-tools in its tool list. Confusing in the client UI, and an invitation to deliver a reply to the wrong surface. New shared helper frontendsForChat(chatId, configured) infers the owning frontend from the chat-id shape (native d_*, teams teams_chat_*, discord discord_*, telegram numeric — the same convention the gateway uses to route actions) and scopes the server list to exactly that frontend. Cross-surface contexts keep the full set on purpose: the heartbeat sentinel, one-shot cron runs, and terminal sessions legitimately reach any surface via explicit chat_id. Applied in all three per-frontend loops: claude-sdk buildMcpServers, codex buildCodexMcpServers, openai-agents buildBundle. (The kilo/opencode remote-server path already binds a single frontend per server instance.) Covered by frontend-scoping.test.ts: id classification per frontend, owned-chat scoping, heartbeat/terminal full-set fallback, and the owner-not-configured fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Companion to the tool-server scoping fix: the system prompt was built once for frontends[0], so with frontend: ["telegram", "native"] a native-app chat received the full Telegram platform prompt — telegram.md guidance, Telegram reaction lists, Telegram file-sending instructions — with native.md never rendered for it. The per-session snapshot layer (prepareSystemPrompt) now resolves the chat's owning frontend from its chat-id shape and builds that frontend's prompt flavour, frozen per session as before: - config.ts: new pure buildSystemPromptPartsFor(config, additions, frontend) + primaryFrontend(config); rebuildSystemPrompt now delegates to them (behaviour unchanged for the primary frontend). - system-prompt.ts: snapshot path builds non-primary flavours purely — WITHOUT mutating the global config prompt, so a native chat starting a session can't clobber the prompt telegram warm-up and legacy readers consume. Primary-frontend chats keep the historical global-rebuild path. Cross-surface chat ids (heartbeat, one-shot, terminal) fall back to the primary flavour. Covered in shared-system-prompt.test.ts: native chat gets the native flavour with no global rebuild, telegram chat keeps the primary path, heartbeat falls back, and flavours freeze independently per session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the frontend-scoping sweep across every backend. The delivery contract and flow-violation reminder template the frontend's actual tool NAMES (native's send tool is send_message, telegram's is send) — building them for the process-primary frontend gave native chats a contract instructing `send(...)`, which after tool-server scoping doesn't even exist in their toolset. - claude-sdk + openai-agents (tool-only contracts, where a wrong name means a failed delivery): the contract/flow-violation frontend now resolves via frontendsForChat(chatId, ...) — owning frontend, primary fallback for cross-surface chats. - codex / opencode / kilo (text-modes, wrong names were confusing but not fatal): suffix constants become per-frontend builders (codexSystemPromptSuffix / opencodeSystemPromptSuffix / kiloSystemPromptSuffix), applied per chat in each message handler. The telegram-shaped constants remain for one-shot (cross-surface) paths. - remote-server (kilo/opencode MCP registration): the per-chat talon-tools server bound state.frontendName — the process-primary frontend — for every chat. It now binds the chat's owning frontend when configured, so a native chat on kilo gets native-tools. Covered by the existing contract/handler suites plus the stub functional tests for codex, kilo, and opencode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ersistent-scheduled-sends Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
claudiusthebot
enabled auto-merge (squash)
July 4, 2026 17:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two chat-workflow fixes off main:
1. Scheduled messages survive restarts
send(..., delay_seconds=N)lived only in an in-processsetTimeout. The model tells the user "reminder in 30 minutes", Talon restarts at minute 20, and the promise silently evaporates — the exact failure the "never promise without a mechanism" rule warns about, baked into the platform. Cron jobs survive restarts; scheduled sends now do too.storage/scheduled-store.ts): pending sends live in the kv table (scheduled.messagesblob). The store is the source of truth; per-frontend timer maps are just the armed alarms.schedule_message— a crash between the two steps is recoverable in that order, fatal in the other. Firing and cancelling clean up both store and timers.cancel_scheduledalso clears store-only entries (scheduled before a restart, no live timer).list_scheduledtool (Telegram + Discord): schedule id, seconds remaining, text.rows+reply_to_message_id, and the fire path replays them.2. Frontend tool servers scoped to the chat's owning frontend
With
frontend: ["telegram", "native"], every chat registered a tool server per configured frontend — a native-app conversation sawmcp__telegram-tools__*alongsidenative-toolsin its tool list. Confusing in the client UI, and an invitation to deliver a reply to the wrong surface.New shared
frontendsForChat(chatId, configured)infers the owning frontend from the chat-id shape (natived_*, teamsteams_chat_*, discorddiscord_*, telegram numeric — the same convention the gateway uses to route actions) and scopes the server list to exactly that frontend. Cross-surface contexts (heartbeat sentinel, one-shot cron, terminal sessions) keep the full set on purpose — they reach any surface via explicitchat_id. Applied in claude-sdk, codex, and openai-agents; the kilo/opencode remote-server path already binds a single frontend per server instance.3. Per-chat frontend prompt flavour
Companion to #2, one level deeper: the system prompt itself was built once for
frontends[0], so a native-app chat received the full Telegram platform prompt (telegram.md guidance, Telegram reaction lists) — native.md was never rendered for it. The per-session snapshot layer now resolves the chat's owning frontend and builds that flavour, frozen per session as before. Non-primary flavours are built purely — without mutating the global config prompt — so a native chat starting a session can't clobber the prompt other readers consume. Cross-surface contexts fall back to the primary flavour.4. Per-chat frontend for delivery contracts + remote-server tools
Completes the sweep across every backend. The delivery contract and [FLOW VIOLATION] reminder template the frontend's actual tool names (native's send tool is
send_message, telegram's issend) — built for the process-primary frontend, native chats got a contract instructingsend(...), which after #2 doesn't even exist in their toolset. claude-sdk + openai-agents resolve the contract frontend per chat; codex/opencode/kilo suffix constants become per-frontend builders applied per chat; and the kilo/opencode remote-server path now binds each chat's owning frontend instead ofstate.frontendNamefor all chats.Test plan
scheduled-messages.test.ts(9 tests, per-testTALON_DB_PATHisolation + fake timers): store CRUD/ordering, schedule->fire->cleanup, cancel with a live timer, cancel of a store-only (pre-restart) entry, per-chat listing, restore re-arm, overdue immediate fire, stale drop, cross-frontend isolation.frontend-scoping.test.ts: chat-id classification per frontend, owned-chat scoping, heartbeat/terminal full-set fallback, owner-not-configured fallback.shared-system-prompt.test.ts: native chat gets the native flavour with no global rebuild, telegram chat keeps the primary path, heartbeat falls back, flavours freeze independently per session.🤖 Generated with Claude Code