Skip to content

fix(whatsapp): continuous sessions β€” two-sided history, stable ids, offline catch-up, visible errors - #781

Merged
claudiusthebot merged 2 commits into
mainfrom
feat/whatsapp-continuity
Aug 23, 2026
Merged

claudiusthebot merged 2 commits into
mainfrom
feat/whatsapp-continuity

Conversation

@claudiusthebot

Copy link
Copy Markdown
Collaborator

Log audit + code trace of WhatsApp session management turned up five defects that all break the same promise from different angles: to the person on the phone this is one long conversation, and the bot should be able to reconstruct it whenever its own context starts fresh (reset, restart, overflow).

1. History only held the user's half

The single pushMessage call was on the inbound path. sendContent β€” the funnel every outbound send passes through β€” remembered messages for reply-addressing but never persisted them. So read_chat_history showed a one-sided chat, and search_chat_history could never find anything the bot itself said: the flight details it sent yesterday were unrecoverable after a reset.

Telegram/Discord don't have this problem because their read_history/search_history overrides query the platform's history. WhatsApp has no platform history API β€” the local store is the record, and it was half-empty.

Outbound sends now persist with sender id 0 (the cross-frontend assistant convention), the configured botDisplayName, and media type/caption for media sends.

2. Message ids collide after every restart β€” imminent silent data loss

The in-memory id counter restarts at 1,000,000 every boot; history persists; INSERT OR IGNORE + UNIQUE(chat_id, msg_id) dedupes. The live deployment's history already holds ids 1000000–1000012 for wa_dm_353834733284 β€” the very next restart would have the first thirteen messages silently vanish from history, and a reaction aimed at an old id from history would hit whichever new message reused the number.

The counter is now seeded past MAX(msg_id) over wa_* chats at frontend start. The LIKE prefix escapes its underscore β€” unescaped, wa_ matches waX… too and would seed from foreign chats (tested).

3. History paging was a silent no-op

read_chat_history advertises before (ISO date) and offset_id β€” and the core fallback handler ignored both, returning the same newest-30 window no matter what the model asked. "Go further back" did nothing on every frontend without a platform override (WhatsApp, native). Both cursors now work.

4. /reset destroyed the only record of the conversation

performSessionReset cleared local history unconditionally β€” a harmless mirror-wipe on Telegram, total record destruction on WhatsApp. Reset now takes keepHistory; WhatsApp passes it: a reset clears the model's session, not the chat log. /help text updated to match.

5. Messages sent during a restart vanished; failures were invisible

  • Baileys delivers offline-queued messages as upsert type append; the handler dropped every append (the comment believed it was only history sync β€” the Baileys source marks offline nodes as append, messages-recv.js:1432). Appends now run through the same gates as live messages (fromMe/allowlists drop echoes and newsletters) and are processed as catch-up: always recorded with the message's own timestamp, replied to only within a 15-minute freshness window β€” a night's backlog gets recorded, not serially answered.
  • A failed turn logged and settled the watchdog β€” and sent nothing. Blue ticks, then silence. Failures now mirror the Telegram queue's semantics (runTurnWithRecovery, extracted for unit testing): user stop β†’ silent, brief transient β†’ one retry, everything else β†’ friendlyMessage() in the chat.

Prompt

prompts/whatsapp.md gains a Continuity section: your context and the chat are different things; when the sender references something not in context ("the flights", "what you said yesterday"), search the history before asking them to repeat themselves.

Verification

  • 18 new tests: outbound recording (text/media/no-empty-rows), restart seeding (incl. LIKE-escape and monotonicity), both paging cursors + bad-date path, all four recovery outcomes, catch-up window boundaries.
  • Full suite green (4527+ passed), typecheck, prettier, oxlint clean; statements.generated.ts regenerated via npm run build:sql (drift gate).
  • Live evidence for feat: Microsoft Teams frontend via Power AutomateΒ #2: sqlite3 talon.db shows wa_dm_353834733284 occupying 1000000–1000012 with the daemon un-restarted since β€” this PR lands before the collision does.

πŸ€– Generated with Claude Code

https://claude.ai/code/session_0161F7hgHnXqHeg1fgxMe7ym

claudiusthebot and others added 2 commits August 23, 2026 12:14
…ds, visible errors

The WhatsApp frontend's persistent history held only inbound messages,
its message-id counter reset every boot, a failed turn died silently in
the log, and /reset destroyed the only record of the conversation. Each
of these breaks the same promise from a different angle: to the person
on the phone this is one long conversation, and the bot should be able
to reconstruct it whenever its own context starts fresh.

Outbound recording. sendContent β€” the single funnel every new-content
send passes through (chat sends, action tools, scheduled sends,
heartbeat deliveries) β€” now persists the bot's row alongside the
inbound ones: sender id 0 (the cross-frontend assistant convention),
the configured botDisplayName, media type and caption for media sends.
Until now read_chat_history showed a one-sided chat and
search_chat_history could never find anything the bot itself had said β€”
the flight details it sent yesterday were unrecoverable after a reset.

Id seeding. The in-memory counter restarts at 1,000,000 every boot, but
the ids it hands out are also history's msg_ids, where INSERT OR IGNORE
+ UNIQUE(chat_id, msg_id) dedupes. On the live deployment history
already holds 1000000–1000012, so the very next restart would have the
first thirteen messages silently vanish from history β€” and a reaction
aimed at an old id from history hit whatever new message reused the
number. The counter is now seeded past MAX(msg_id) over wa_* chats at
frontend start (LIKE with an escaped underscore β€” unescaped, "wa_"
matches "waX..." too and seeds from foreign chats).

Paging. read_chat_history advertises `before` and `offset_id`, and the
core fallback handler ignored both β€” "go further back" handed back the
same newest 30 messages no matter what the model asked, on every
frontend without a platform history override (WhatsApp, native). Both
cursors now work, via the existing recentBefore query and a new
recentBeforeTime.

Reset. performSessionReset cleared local history unconditionally. For
Telegram/Discord that's a mirror wipe β€” the platform still has
everything. For WhatsApp the local store IS the chat record, so /reset
destroyed exactly what the continuity tools exist to recover. WhatsApp
now passes keepHistory: a reset clears the model's session, not the
conversation log, and /help says so.

Errors. A failed turn logged and settled the watchdog β€” and sent
nothing. The message was marked read (blue ticks) and then silence,
which reads as being ignored. Turn failures now mirror the Telegram
queue's semantics via a shared runTurnWithRecovery: a user stop settles
silently, a brief transient failure gets one retry, everything else
delivers friendlyMessage() into the chat. Extracted into
turn-recovery.ts so the policy is unit-testable without a Baileys
socket.

prompts/whatsapp.md gains a Continuity section: the model's context and
the chat are different things; when the sender references something not
in context, search the history before asking them to repeat themselves.

14 new tests cover outbound recording (text, media caption/type, no
empty rows), restart seeding (including the LIKE-escape case and
monotonicity), both paging cursors plus the no-cursor and bad-date
paths, and all four recovery outcomes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161F7hgHnXqHeg1fgxMe7ym
Baileys delivers messages queued while the device was offline as
messages.upsert type "append" (messages-recv.js marks offline nodes so),
and the handler dropped every append wholesale β€” anything sent to the
bot during a restart simply vanished: never recorded, never answered.
The comment believed append was only history sync.

Appends now run through the same handleInbound gates as live messages β€”
fromMe drops our own sends echoing back, the allowlists drop newsletter
posts and strangers β€” and are processed as catch-up: always recorded to
history (with the message's own platform timestamp, so late-recorded
rows read in true order), but given a reply turn only while fresh.
Past the 15-minute window the message is history-only and the next live
turn reads it from there β€” replying to a whole night's backlog hours
later reads worse than picking up naturally.

The freshness policy lives in turn-recovery.ts (shouldReplyToCatchUp)
with boundary tests; a missing timestamp counts as fresh, since one odd
reply beats a silently dropped question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161F7hgHnXqHeg1fgxMe7ym
@claudiusthebot
claudiusthebot enabled auto-merge (squash) August 23, 2026 12:17
@claudiusthebot
claudiusthebot merged commit f6d5e65 into main Aug 23, 2026
47 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