Skip to content

Chat-surface correctness: persistent scheduled sends, frontend-scoped tools + prompts - #454

Merged
claudiusthebot merged 5 commits into
mainfrom
feat/persistent-scheduled-sends
Jul 4, 2026
Merged

Chat-surface correctness: persistent scheduled sends, frontend-scoped tools + prompts#454
claudiusthebot merged 5 commits into
mainfrom
feat/persistent-scheduled-sends

Conversation

@claudiusthebot

@claudiusthebot claudiusthebot commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two chat-workflow fixes off main:

1. Scheduled messages survive restarts

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 — 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.

  • Persistent store (storage/scheduled-store.ts): pending sends live in the kv table (scheduled.messages blob). The store is the source of truth; per-frontend timer maps are just the armed alarms.
  • Persist-before-arm on Telegram and Discord 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.
  • Restore on boot: when a frontend's action handler is created, persisted entries re-arm. Overdue entries fire immediately (late beats never); entries stale past 24h are dropped.
  • cancel_scheduled also clears store-only entries (scheduled before a restart, no live timer).
  • New list_scheduled tool (Telegram + Discord): schedule id, seconds remaining, text.
  • Delay cap raised 1h -> 24h: with persistence, long delays are safe to honor.
  • Bugfix en passant: delayed sends silently dropped buttons and reply threading on the tool side — the bridge now forwards 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 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 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 (heartbeat sentinel, one-shot cron, terminal sessions) keep the full set on purpose — they reach any surface via explicit chat_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 is send) — built for the process-primary frontend, native chats got a contract instructing send(...), 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 of state.frontendName for all chats.

Test plan

  • New scheduled-messages.test.ts (9 tests, per-test TALON_DB_PATH isolation + 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.
  • New frontend-scoping.test.ts: chat-id classification per frontend, owned-chat scoping, heartbeat/terminal full-set fallback, owner-not-configured fallback.
  • Extended 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.
  • Full CI validates typecheck, lint, and the untouched surfaces.

🤖 Generated with Claude Code

`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>
@claudiusthebot claudiusthebot changed the title Scheduled messages survive restarts Scheduled messages survive restarts + frontend-scoped tool servers Jul 4, 2026
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>
@claudiusthebot claudiusthebot changed the title Scheduled messages survive restarts + frontend-scoped tool servers Chat-surface correctness: persistent scheduled sends, frontend-scoped tools + prompts Jul 4, 2026
claudiusthebot and others added 2 commits July 4, 2026 17:07
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
claudiusthebot enabled auto-merge (squash) July 4, 2026 17:15
@claudiusthebot
claudiusthebot merged commit d18cb7b into main Jul 4, 2026
45 checks passed
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