Skip to content

Thread history backfill re-adopts bot's own prior replies as untrusted context #955

@Aaronontheweb

Description

@Aaronontheweb

Summary

PR #954 fixes the proactive-post amnesia case (a user replying to a thread bootstrapped by an agent-initiated send_slack_message lands on a session with no transcript), but the implementation opened the gate too wide: history backfill now re-adopts every bot-authored message in a thread, including the bot's own prior replies that already exist in the session's persisted transcript. Result: adopted-context shows the bot's previous turns as if they were external speakers, and the model sees its own outputs replayed back as conversation context.

Repro

Live dogfood session on a regular (non-proactive-bootstrap) Slack thread. After a daemon restart triggered a recovery + history backfill:

  • Recovered from snapshot (turns=3, history=13) — session already had a full transcript
  • Fetched 9 thread history messages — backfill returned 9 entries instead of the 3 user-authored entries the previous filter would have returned
  • The next approval prompt's adopted-context block listed two speakers: the human user and the bot's own user ID (the workspace user the daemon authenticates as)

The watermark dedup the PR relied on is doing nothing here:

  • Pre-restart, the cursor was stuck at the thread root and not advancing across hydration calls (cursor=1778553106.206079 repeated across 02:33 and 02:43 fetches)
  • Even if the watermark did advance, it filters by Slack ts — it cannot tell a bot reply we wrote ourselves from one we didn't

Why this design was wrong

The PR conflated two different sets:

  • "Messages from bots" — what the change filters on
  • "Messages we couldn't have seen via our own producer-side transcripts" — what actually matters for backfill

Those overlap at exactly one entry: the proactive-post root that bootstrapped the thread. Every other bot message in the thread was produced by some session of ours, which wrote it to its own persisted transcript at the time. Server-side history backfill is only useful for content we never produced ourselves.

The PR's defense — "watermark dedups, caught-up sessions don't refetch" — is load-bearing on a primitive that (a) can lag advancement, and (b) doesn't filter on the right axis. The deleted Filters_out_bot_messages test was the boundary that prevented this exact failure mode.

Historical context

The original problem PR #954 was trying to solve is real and still needs a fix: when a DeliveryKind.None reminder fires and the daemon's ephemeral session calls send_slack_message and terminates, the new Slack thread that's created has no producing session. A user reply lands on a freshly-bootstrapped destination session whose transcript is empty, so the agent has amnesia about the message it just posted.

Before #954, SlackThreadHistoryFetcher and DiscordThreadHistoryFetcher both unconditionally skipped bot-authored entries in server-side thread history. That filter was correct for normal threads (where the bot's prior turns are already in the session transcript) but it dropped the proactive-post root in the bootstrap case, leaving the destination session with nothing to adopt.

#954 fixed amnesia by removing the bot-message filter outright. That fixes the bootstrap case but introduces the regression described above for every other thread.

OpenSpec change add-proactive-channel-sessions-conformance (delta on thread-history-backfill) captures the intent but with the wrong design constraint.

Proposed fix

Restrict bot-authored backfill to the thread root only:

if (message.IsBotAuthored && message.Ts != thread.ParentTs)
    continue;

(Plus the analogous check in DiscordThreadHistoryFetcher for the page iterator and the raw-message-fetch site; the thread-root resolution site already operates on the root specifically.)

This satisfies both invariants with a one-line predicate per fetcher:

  1. Proactive-post amnesia stays fixed — the proactive post IS the thread root, so it surfaces.
  2. No re-adopting our own outputs — every other bot message in the thread is produced by one of our sessions and already in transcript; the root-check filters them out.

Tests that change:

  • Includes_bot_messages_with_bot_id_as_sender_when_user_is_missing — reshape so the bot reply at non-root is filtered, the bot post at root is included
  • New regression test: bot message at thread root is included; bot message later in the thread is excluded
  • New regression test: a session with prior transcript that recovers and backfills does not re-adopt its own bot turns

Out of scope

  • The watermark dedup behavior (cursor not advancing reliably) is a separate issue worth tracing, but it's no longer in the critical path once the root-only filter is in place — the watermark becomes a cost optimization, not a correctness primitive.

References

  • PR fix(channels): include bot messages in thread history backfill #954 (fix(channels): include bot messages in thread history backfill) — the change being amended
  • Affected files:
    • src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs
    • src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs
    • src/Netclaw.Actors.Tests/Channels/SlackThreadHistoryFetcherTests.cs
    • src/Netclaw.Actors.Tests/Channels/DiscordThreadHistoryFetcherTests.cs
  • OpenSpec change to revise: openspec/changes/add-proactive-channel-sessions-conformance/

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingchannelsDiscord, Slack, and other channels.context-pipelineLLM context assembly: prompt layers, dynamic injection, memory recall, temporal grounding

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions