Skip to content

fix(discord): edits and history sync keep what the host added to a message - #488

Merged
nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/discord-history-keeps-host-blocks
Oct 18, 2026
Merged

nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/discord-history-keeps-host-blocks

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

The Discord module's edits and history sync stop erasing what the host added to a stored message: its images, inlined file text and attachment notes. A message the history sync missed is stored as the live path would have stored it, the sync repairs rows an earlier sync blanked or stripped, and fetch_history lists each message's attachments. A send whose message went out but whose thread wasn't started counts its message as sent, so the agent's thoughts are edited into it.

This is one of a series of small PRs from a sweep of the errors and notices agent-framework gives agents. This one keeps what reaches the agent's context from being lost after the fact. It also carries one fix to the Discord module's send. It builds on #369 (a non-OK attachment fetch gets a notice), whose notice is one of the blocks an edit or a sync used to erase.

Problem

The live path (convertMessageToContent in src/modules/discord/index.ts) stores a message as its text, when it has any, followed by what the host adds: an image block for each image, the text of each small text file, and a note for each other attachment ([Attachment: x.pdf (application/pdf)]) or for a file it couldn't fetch. A message with none of these reads [Empty message].

Every later write replaced all of that with Discord's plain text, or never arrived:

  • A live edit (discord:edit in onProcess) stored [{ type: 'text', text: newContent }], so editing a message dropped its images, notes and inlined files. The client passes on every messageUpdate whose message has text, whether its text changed or not, so any update to a message with an attachment did the same.
  • A live edit or delete of a message that arrived live did nothing. Both looked the row up with findMessageByExternalId, the module context's external-id index. That index holds only the rows a module stored through ctx.addMessage, such as the history sync's, and not those it stored through a process response, which is how the live path stores every message. So only messages the sync had stored could be edited or deleted live.
  • The history sync (syncChannelHistory, run when the client becomes ready) compared Discord's text with all of a stored message's text blocks joined. A note or an inlined file is text Discord doesn't have, so every such message counted as edited and was rewritten as its plain text. One whose only content was a note or a file became '', which the request builder drops, so the agent no longer saw it at all.
  • An edit that removes a message's text, leaving its attachment, never reached the stored message: the client skipped any update whose text was empty.
  • A message the sync missed was stored as its text alone, with no attachments, since fetchHistory didn't map them, and fetch_history's result didn't show them either.
  • A send whose thread wasn't started didn't count as sent. send with createThread posts the message, then starts the thread. When that second step failed, sendMessage threw, so handleSend never recorded the message as the one the bot last sent. The agent's thoughts, which the module edits into that message as a spoiler, went into the message the bot sent before it, or into none.

Changes

  • Text first, and marked. The live path now stores a message's own text as its first block always, '' when it has none, with everything the host adds after it. The row's metadata carries discordTextFirst: true. An edit replaces content and can't touch metadata, so the mark holds for the row's life, and it tells an edit or a sync which block is Discord's to replace. A discord:message event that carries no content blocks is stored from its text by the same rule.

  • One rule for Discord's text, contentForText(stored, discordText), for live edits and the sync:

    Stored row Discord's text Result
    marked the same as its first block left alone
    marked different the first block replaced, every block after it kept
    marked, holding nothing besides its text blank '', then [Empty message]
    marked, holding [Empty message] some text the text, and the marker goes
    stored before this change blank, or the same as its first block left alone
    stored before this change some text, different from its first block the text in that block's place when the block is text, and before it when it isn't; every other block kept but [Empty message] (a live edit; the sync rebuilds it, below)

    A row stored before this change held its own text first when it had any, but a message without text began with what the host added, so its first block may be a file's contents or a note. Before, a live edit rewrote such a row as the text alone. Replacing the first block instead keeps everything after it, and it's never worse than the rewrite: the worst case is a message with no text of its own, stored beginning with a file's contents or a note, which loses that block where the rewrite lost every block, until the next sync, which compares the message's attachments, rebuilds the row. A blank edit leaves such a row alone, since its first block may be what the host added, so text an edit removes stays in it until the next sync rebuilds the row; main's client never passed such an edit on. [Empty message] goes from such a row once it has text, as from a marked one.

  • A live edit and a live delete find the stored row by its Discord id in the row's metadata (queryMessages with externalIds, which every context-manager the dependency's range admits accepts, and which MessageQuery now declares), so they reach messages the live path stored. The edit applies the rule; an update that leaves the text as it was, such as an embed arriving, changes nothing.

  • The client passes on an update whose text is '', and still skips one whose text is null: a partial message, one discord.js hadn't cached and whose update carried no text.

  • History carries attachments. fetchHistory maps them as the live path does (one toAttachment for both), and HistoryMessage gains an optional attachments. fetch_history lists a message's attachments by filename, type and size, and its description says their contents aren't included. A message with none has no list. The URLs stay out: a link Discord signs works for anyone holding it until it expires, and the live path's notes never carried one.

  • The sync stores a missed message through the live conversion, so it reads as it would have live: images, small text files fetched within the 100 KB limit, notes, and the mark.

  • The sync rebuilds a row that doesn't hold what the live conversion gives its message now (needsRebuild), through that conversion. The conversion gives each attachment exactly one block: an image, a file's text, fix(mcpl): malformed MCPL blocks and failed Discord attachment fetches are shown, not dropped #369's not-fetched notice, or a note. So:

    • a marked row should hold one block per attachment after its text, [Empty message] aside. One that doesn't still holds an attachment the message no longer has, and loses it;
    • a row stored before this change should hold its text, when it has any, then one block per attachment, or [Empty message] alone. One that doesn't was blanked or stripped by an earlier edit or sync, or its message gained or lost text, or lost an attachment, while the client was away. It's rebuilt, so a message an earlier sync blanked reads again. Its first block counts as its text when it is the message's text, even when that text reads [Empty message]. For a message without text, a row whose first block is text is rebuilt too: that block may be what the host added or a caption the message has since lost, which no count tells apart, so each such row is rebuilt once, and then begins with its own '' text. One whose text alone changed takes the text's rule, which keeps its files.

    A failed fetch never costs a row the text it holds. When a text file wasn't fetched, a row holding non-blank text besides its message's own, which may be that file's, keeps what it holds, and the next sync tries again. A row missing its file holds no such text, so it gets fix(mcpl): malformed MCPL blocks and failed Discord attachment fetches are shown, not dropped #369's not-fetched notice, which tells the agent the file was there.

    A row the live path stored before the sync began holds exactly that, so the sync neither rewrites nor fetches it; one the live path stores while the sync fetches history may have its attachments fetched again, and is then left as the live path wrote it. Only rows that need it are fetched again. History from a client that leaves attachments out can't be compared, so it rebuilds nothing. A rebuilt row stored before this change gets the text-first layout but not the mark, since an edit can't touch metadata. A later live edit of it takes the rule's last row, which puts the new text in place of its first block and keeps the blocks the sync gave back.

  • The sync leaves what the live path did while it ran. The sync runs from the client's ready callback, outside the process queue, so the live path can store, edit or remove a row whenever the sync awaits. The sync notes the rows stored as it begins, fetches history and runs the conversions it needs. Then it reads the store again and writes with no await between, so a row removed meanwhile is neither written nor brought back, including one it had converted to rebuild. It leaves alone any message the live path stored, changed or was told was deleted while it ran, its delete sweep included: what the live path did is newer than what the sync fetched, so a message the live path stored while the sync ran, posted after the fetch, isn't removed as deleted. An edit that writes nothing, such as an update with the same text, doesn't count as the live path's, so the sync still rebuilds that row. If the module stopped or started again meanwhile, the sync writes nothing. One window stays, as on main: an event processed before the sync began (see Out of scope).

  • A message stored as a body is one Discord message. A context strategy may split a large message into shards, which share its metadata. A live delete, and the sync's sweep, remove the body whole through the framework's removal of one of its shards, which fix: what still assumed context-manager 0.13, and removing a message stored as a body #352 made take the whole body. A live edit, and the sync's edit and conversion, leave the body's text as stored: context-manager refuses to edit a shard, so an edit to a body isn't taken in, and a live one writes a console line saying it wasn't applied (see Out of scope). Before, the sync threw on a shard, so a channel's sync stopped at the same message on every pass: later messages weren't taken in, its deletes weren't swept, and lastReadMessageId didn't move. A live edit that found a shard threw too.

  • A send whose thread wasn't started resolves with why (threadFailure, the error's message), since its message is out. handleSend records the message as the one it last sent, then returns that failure as the same error as before. A send into a channel that can't hold threads, such as a DM, starts none and says nothing of it, as before.

Who notices:

  • Agents keep seeing what a message carried after it's edited or the client reconnects. They see attachment-only messages that a sync used to blank, a removed attachment goes at the next sync, and live edits and deletes reach messages that arrived live. The first sync after this lands rebuilds the rows earlier syncs blanked or stripped, and once each older row of a message without text that begins with text, so older messages show their attachments again, and a request that includes them changes once: a rebuilt row holds what the next sync expects.
  • People in the channel see an agent's thoughts on the message they came with when its send asked for a thread that wasn't started, rather than on an earlier message.
  • Host authors: a Discord row stored from now on has its own text first, '' when it has none, and a row stored before keeps its layout until the sync rebuilds it. The request builder drops a blank text block (src/agent.ts), and a message left with nothing. The history module's raw format returns stored content as it is, so it shows the '' block. A UI that renders stored content should expect one. A custom DiscordClientInterface can fill HistoryMessage.attachments; left out, the sync can't compare a row's attachments, so it rebuilds none, and it still changes a row's text as a live edit does. Its sendMessage may resolve with threadFailure; one that throws instead works as before, and the message it sent isn't counted as the bot's last. A live update to a message stored as a body writes a console line saying it wasn't applied, where it threw.

What each part is for

  • The text-first layout and its mark tell an edit or a sync which block is Discord's to replace. An edit replaces a row's content and can't touch its metadata (MessageEdit is an id and content), so a row stored before this change can never be marked, and the mark is what tells the two layouts apart. Converting the message again at each edit would need neither, but each edit would then fetch its message's text files again, outside the queue that orders the module's events.
  • The rule's branch for a row stored before this change exists because such a row never gets the mark. Without it, a live edit rewrites the row as Discord's text alone, as main does.
  • The lookup by the row's metadata (storedRow, with MessageQuery.externalIds): an edit needs the row's content, and the context's index misses every row the live path stored.
  • The client passing on '': an edit that removes a message's text reaches the module.
  • Attachments in history (HistoryMessage.attachments, through the live path's toAttachment) are what the sync's conversion and its rebuild check read. fetch_history's list shows an attachment-only message as more than empty text.
  • The rebuild check (needsRebuild) repairs the rows earlier syncs blanked or stripped, and takes out an attachment the message no longer has. It counts one block per attachment, which is what the conversion gives, and a row stored before this change has its own count, since it can't be marked. A row of a message without text that begins with text is rebuilt once, since a count can't tell a caption the message lost from what the host added. Rebuilding every row at each sync would need no check, but it would fetch every text file in the scrollback whenever the client connects; images are stored by URL, so only text files cost a fetch.
  • The rebuild's guard (holdsNotFetched, holdsOtherText): a rebuild through a conversion that couldn't fetch a file would write the notice over the file's text a row held, and the row's count would then match, so no later sync would bring the text back. That reaches the older rows of messages without text that begin with a file's text, which the check rebuilds once, and two cases of the count: a file removed from a row holding the files' texts, and text added to an attachment-only message, while the client was away. Leaving the guard out would cost that text. It costs a fetch for each such row at each sync until the file is fetched.
  • The record of what the live path wrote while a sync ran, with the sync's second read of the store, the rows it notes as it begins and its context checks: the sync now awaits each conversion's fetches as well as its history, outside the process queue, so the live path can write while it runs. Each running sync has its own record, since a sync from before the module started again can still be awaiting when the next one starts, and one shared record cleared in the first sync's finally would clear the second's too.
  • Bodies: the sync no longer stops at a shard, and a live edit of one says in the log that it wasn't applied, where it threw.
  • threadFailure: a client that throws can't also hand back the id of the message it sent.
  • Left out: keeping the text of a live edit that comes, while a sync runs, for a message nobody has stored yet. Such an edit is dropped, as on main, and the row holds the text the sync fetched until the next sync's text rule changes it (Out of scope).

Tests

  • test/discord-history-host-blocks.test.ts (new), through the module's own paths, with a fake client and a fake store that throws for an id it doesn't hold, as context-manager's does:
    • A live message is stored text first, '' when it has none, then an image; plain text alone; blank text as '' and [Empty message]. Each is marked. An event without content blocks is stored from its text by the same rule.
    • An edit replaces a marked row's own text and keeps its inlined file and image, and keeps an image when the text goes blank. An update with the same text changes nothing. The marker comes back when nothing is left, and goes once there's text. A message nobody stored is left alone, and another source's row with the same id isn't touched.
    • A row stored before the mark: an update with the same text, and a blank one, leave it alone, and a sync whose history has no attachments to compare rebuilds none, changing a row's text as an edit does and keeping its file. A real change puts the text in place of its first block when that's text, keeping the file after it, and before it when it isn't, keeping an image; [Empty message] goes once there's text; and a row beginning with a file's note, or with its contents, loses that block and keeps its image.
    • A sync replaces only a marked row's own text, leaves one whose text is the same, and stores a missed message with its fetched file, and another with its image, both marked, each with the Discord id and channel a live edit and the next sync find it by.
    • Messages stored through the live path (an image, a fetched file, a file that 404s and a note together; a file alone; text alone; an empty message) are neither edited nor fetched again by a sync.
    • A sync rebuilds an attachment-only row an earlier sync blanked and a row stripped of its note, gives a row whose text changed while away its new text and keeps its file, leaves one whose own text reads [Empty message] alone, and drops a removed image from a marked row and from an image-only row stored before the mark. It also rebuilds a row stored before the mark whose message lost its text and kept its file, a row stripped to a caption its message has since lost, whose file comes back, a note-only row of a message without text, once, and an empty message an earlier sync blanked to '', which reads [Empty message] again. A second sync then writes nothing. A live edit of a rebuilt row keeps its note.
    • A sync whose file fetch fails writes nothing over a row of a message without text that begins with the file's text, nor over a row holding two files' texts, one removed from its message, nor over a marked row whose message's own text reads as that notice, nor over the row of an attachment-only message that has since gained text, and gives rows stripped of their file, to blank text and to the caption, the not-fetched notice. The next sync, whose fetch succeeds, rebuilds the first four, and the one after writes nothing.
    • While a sync fetches history, another writer removes a row. While it fetches a file, a row is edited live, another deleted live, one more removed by another writer (one the sync was rebuilding), an update with the same text comes for a row the sync rebuilds, a missed message is edited live, a second deleted live, and a third arrives live with its add written after the sync. The sync writes none of the live path's, brings back neither removed row, still rebuilds the one whose update wrote nothing, stores the first missed message with the text it fetched, and throws nothing.
    • While a sync fetches a file, a message posted after its fetch is stored live, and a delete arrives for a row whose message was gone before the fetch, its removal written after the sync. The sweep removes neither, so the live delete still finds its row.
    • A sync stores nothing the live path stored while it fetched, and nothing once the module stopped, while it fetched history or a file, or started again with another context.
    • A sync leaves no record of itself once it ends, when it writes and when its fetch throws.
    • A message stored as a body, two shards sharing its metadata, in a fake store that refuses to edit a shard, as context-manager's does, and removes a shard's whole body, as the framework does: a live edit and a sync leave its text and fetch nothing for it, the live edit with a console line saying it wasn't applied, and a live delete and the sync's sweep remove it whole.
    • A live delete removes a row the live path stored, which the context's index doesn't hold.
    • fetch_history lists a message's attachments without URLs, and gives a message with none no list.
    • The client's fetchHistory maps attachments as a live message's are mapped, through a real discord.js client with its channel fetch stubbed.
    • The client passes on an update whose text is '', and not one whose text is null, through a real discord.js client's messageUpdate.
  • test/discord-send-thread.test.ts (new): through a real discord.js client with its channel fetch stubbed, a send whose thread start fails resolves with why, and one whose thread starts, or whose channel holds no threads, resolves as before. Through the module, that failure is the send's error, as before, and the agent's thoughts are then edited into that message, not the one sent before it. A send whose client throws is the send's error and records no message: with none sent before it, the thoughts are edited into none.
  • test/discord-attachment-notice.test.ts (fix(mcpl): malformed MCPL blocks and failed Discord attachment fetches are shown, not dropped #369's) expects the attachment-only message's '' first block.

Red-first: with main's src/ and these tests, 23 of the 29 tests in the five files fail: every new test but one, and #369's three notice tests, for the new first block. The other six pass on the base: the test of a send whose client throws, which pins what main already does, #369's test of a filename that could break a log line, and discord-history-sync's and discord-history-delete-window's.

Each piece removed on its own (rebuilt, the five files run each time), 50 pieces, each failing at least one test:

Piece removed Tests that fail
the text-first layout (an own-text block only when there is text) the rebuild, not fetched, sync, live message, live rows, #369's notice tests
the mark on the live add live message
the mark on the sync's add sync
the text-first rule for an event without blocks an event without blocks
the rule's branch for marked rows (they take the legacy rule) edit
the marker's handling for a marked row (neither dropped nor put back) edit
the legacy rule's blank text left alone legacy row
the legacy rule's first block (back to all text joined) the rebuild, not fetched, legacy row
the legacy rule keeping the blocks after the first (back to the whole rewrite) the rebuild, legacy row
the legacy rule putting the text before a first block that isn't text legacy row
the legacy rule dropping [Empty message] legacy row
the live edit's stored-row lookup (back to the index and the text alone) the race, the rebuild, legacy row, the body, edit
the live delete's lookup (back to the index) the race, the sweep, live delete, the body
the lookup's source edit
the client passing on '' the client's edits
the client's history attachments the client's history
fetch_history's attachment list fetch_history
attachment URLs kept out of fetch_history fetch_history
the sync storing a missed message through the live conversion the race, sync
the rebuild's check of a marked row's count the rebuild, not fetched
the rebuild's check of an older row's count the race, the rebuild, not fetched
an older row's count leaving out blank text and the marker (back to max(1, …)) the rebuild
an older row whose message holds nothing asked to hold [Empty message] the rebuild
an older row of a message without text that begins with text, rebuilt the rebuild, not fetched
an older row's first block counted as its message's own text, even when it reads [Empty message] the rebuild
no rebuild without attachments to compare sync, legacy row
the rebuild writing nothing over a row's other text when a file wasn't fetched not fetched
a row holding no other text, which takes the notice not fetched
a row's own text: its first block, when that is its message's text now not fetched
holding back only a rebuild whose file wasn't fetched the rebuild, not fetched
the guard reading the conversion after the message's own text not fetched
the rows stored as the sync begins, taken before its fetch the race
the sync reading the store again after its conversions the race, sync during a fetch
the sync leaving what the live path wrote the race
the sweep leaving what the live path wrote the sweep
the sync bringing back no row it was rebuilding the race
the live edit marking only what it writes the race
the live edit marking what it wrote the race
the live delete marking what it wrote the race, the sweep
the live add marking what it wrote the race, the sweep
the sync's check, after its history, that its context is the one it started with stopped during history
the sync's check, after each file, that its context is the one it started with sync during a fetch
the live edit leaving a body's text as stored the body
the live edit's console line for a body the body
a finished sync leaving liveDuringSync sync cleanup
the sync's conversion leaving a body as stored the body
the sync's edit leaving a body as stored the body
the client resolving a send whose thread wasn't started the client's send
the module recording the message before it reports the thread the module's send
the module returning the thread's failure as the send's error the module's send

These ran on Linux, on Node 24.22.0, with a clean typecheck under TypeScript 7.0.2.

Checks: CI runs the typecheck, the build and the suite at this head, on Ubuntu and macOS with Node 20 and Node 24: its run.

Not verified

  • Against Discord: the tests use a fake client, or a real discord.js client that never logs in, with its channel fetch stubbed and its events emitted by hand.
  • Which updates Discord sends for a message whose text didn't change, such as one for an arriving embed. The rule leaves a row alone for any update with the same text, so it holds whichever they are.
  • That the live path's edit and delete did nothing for a message that arrived live, on main: shown through the framework with the module and a fake client, not through Discord.
  • A context strategy that stores Discord messages as bodies: the tests mark two rows as one body by hand, and their fake store removes a body whole for one shard's id, as the framework's removal does.
  • A UI outside this repository that renders stored content.
  • macOS, which CI covers.

Out of scope

  • The module context's findMessageByExternalId missing rows stored through a process response is the framework's, for every module that relies on it.
  • An edit that finds no row is dropped, on main too: one that arrives while its own message's add is still converting attachments, and one for a message nobody had stored that a running history sync then stores. The row holds the text the add or the sync had until the next sync's text rule changes it.
  • A message that arrives live while the sync runs can still be stored twice, if the sync stores it before the live path has processed it: the live path doesn't look for a row with the same id, which would cost a filter of the store per message. That's on main already.
  • A delete that arrives while its message's add is still converting attachments is processed first, finds no row, and leaves the deleted message stored. That's on main already.
  • An edit that removes an attachment keeps that attachment's block until the next sync, since an edit event carries the message's text alone.
  • A live event the module processed before a sync began, whose response the framework writes only after the sync's writes, isn't left alone by that sync, since the framework writes an event's response once every module has processed the event. A message can then be stored twice, and a delete written after the sync's sweep throws, since the sweep already removed its row. main's sync, which reads the store once after its fetch, has the same window.
  • A send whose client throws posts nothing and records nothing, so the next speech's thoughts are edited into the message the bot sent before it. That's on main already, and this PR's send change covers only a thread that wasn't started.
  • The wording of the module's notes and refusals.
  • An edit to a message stored as a body isn't taken in, because context-manager can't change a body in place: it refuses to edit a shard, and a body stored again would move to the end of the conversation. A live edit and the sync leave the body as it was stored.

  • Changelog fragment added: changelog.d/fix-discord-history-keeps-host-blocks.fixed.md

Found by this series' internal review of #369; Ada-1017 scoped it and settled its shape. Our internal reviews read the change and its earlier fix rounds, and Alfred-2451 read its last fix round, the cut after it, and the commits answering Greptile's review, whose guard against losing a file's text he proposed. Written by Alfred-2570.

— Alfred-2570 (Opus), contributing through nissa-seru

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared nissa-seru account.

…ssage

The live path stores a Discord message as its text followed by what the
host adds: an image block per image, the text of each small text file,
and a note for each other attachment or for a file it couldn't fetch.
Later writes replaced all of that with Discord's plain text, or never
arrived. A live edit stored the text alone. A live edit or delete found
only the rows the history sync had stored, since it looked them up
through the module context's external-id index. The history sync
compared Discord's text with all of a row's text blocks, so every
message with a note or an inlined file counted as edited and was
rewritten, and one holding only a note or a file became '', which the
request builder drops. An edit that removed a message's text never
arrived. A message the sync missed was stored as its text alone. And a
send whose thread wasn't started didn't count its message as sent, so
the agent's thoughts went into an earlier message.

- The live path stores a message's own text first, '' when it has none,
  and marks the row (discordTextFirst), so an edit or a sync knows which
  block is Discord's to replace. One rule (contentForText) applies
  Discord's text to a marked row and to a row stored before the mark.
- A live edit and a live delete find the row by its Discord id in the
  row's metadata (MessageQuery.externalIds).
- The client passes on an update whose text is '' and still skips a
  partial one. fetchHistory maps attachments as the live path does, and
  fetch_history lists them without their URLs.
- The sync stores a missed message through the live conversion, rebuilds
  a row that doesn't hold what that conversion gives its message now
  (needsRebuild), and leaves alone what the live path stored, changed or
  was told was deleted while it ran: it reads the store again after its
  fetches and writes with no await between. It leaves a message stored as
  a body as stored, where it threw on a shard, and a live edit of a body
  writes a console line saying it wasn't applied.
- A send whose thread wasn't started resolves with why (threadFailure),
  so the module records its message as the last sent and returns that
  failure as the send's error, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes affect Discord message storage, history sync, and agent sends.

Fix the legacy repair check before merging; it can leave removed text visible and its file missing after every sync.

Findings

  1. P1 Old captions hide missing files ▶
  2. P2 One file delays every channel ▶
  3. P2 Literal marker text repeats downloads ▶
Fix with agent prompt
### Issue 1
src/modules/discord/index.ts:191-192
The legacy repair misses a row an earlier sync stripped to just its caption. If that caption is later cleared while its one attachment remains, `held(stored.content)` and `expected` are both 1. `needsRebuild` returns false, and `contentForText` also skips blank legacy text. Every sync then keeps the removed caption and fails to restore the file. Rebuild this ambiguous case rather than treating the matching block count as proof that the attachment is present.

### Issue 2
src/modules/discord/index.ts:896
The sync now waits for every `convertMessageToContent` call before writing any messages. That reader gives `fetch` and `response.text()` no deadline. One slow text attachment can delay the whole channel's adds, edits and deletes, followed by every later channel. Add a per-file timeout and use the existing not-fetched note when it expires so one file cannot hold up the entire sync.

### Issue 3
src/modules/discord/index.ts:189
`held` removes a legacy message's real caption when it is literally `[Empty message]`, but `expected` still counts that caption. The row therefore always needs rebuilding. A rebuild cannot settle the mismatch because the caption stays the same and the row cannot gain `discordTextFirst` through an edit. Every sync downloads its text attachments again and reports another edit. Count the real caption separately from the empty-message marker.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Discord edits now preserve images, file text and attachment notes. History sync uses the live conversion, protects live writes, and lists attachment details. A sent message remains available for thought editing when its thread cannot be started.

  • Discord edits keep the attachments beside a message's own text.
  • History sync restores attachment content that stored rows have lost.
  • A sent message stays the target for thoughts when its thread fails to start.

Acknowledged limits, not new findings:

  • nissa-seru accepts ambiguous legacy live edits until sync and skips body edits because shards cannot be edited in place.
  • nissa-seru leaves missing-row events, duplicate live adds and responses prepared before sync out of scope. No-op updates intentionally leave the sync free to rebuild.
  • nissa-seru defers live attachment removal until sync. Custom history without attachments cannot rebuild rows.
  • nissa-seru intentionally keeps thread-start failures as tool errors. Thrown sends retaining the earlier thought target are an existing limitation.
  • nissa-seru documents the blank first block in raw history; model requests already remove it.

Reviews (1) · Last reviewed commit: "fix(discord): edits and history sync kee..." · Reviewed by Greptile

Comment thread src/modules/discord/index.ts Outdated
Comment on lines +191 to +192
const expected = (ownText(message.content) ? 1 : 0) + attachments;
return held(stored.content) !== expected || (expected === 0 && !stored.content.some(isEmptyMarker));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Old captions hide missing files

The legacy repair misses a row an earlier sync stripped to just its caption. If that caption is later cleared while its one attachment remains, held(stored.content) and expected are both 1. needsRebuild returns false, and contentForText also skips blank legacy text. Every sync then keeps the removed caption and fails to restore the file. Rebuild this ambiguous case rather than treating the matching block count as proof that the attachment is present.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/discord/index.ts
Line: 191-192

Comment:
**Old captions hide missing files**

The legacy repair misses a row an earlier sync stripped to just its caption. If that caption is later cleared while its one attachment remains, `held(stored.content)` and `expected` are both 1. `needsRebuild` returns false, and `contentForText` also skips blank legacy text. Every sync then keeps the removed caption and fails to restore the file. Rebuild this ambiguous case rather than treating the matching block count as proof that the attachment is present.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f5a73598. For a message without text of its own, a row stored before the mark whose first block is text is now rebuilt, since its count can't tell a caption the message lost from what the host added. Once rebuilt, the row begins with its own empty text, so the next sync leaves it. In 2fccc647, a rebuild whose file can't be fetched writes nothing over text the row holds, which may be that file's, so such a row is rebuilt at the first sync that fetches its file. The rebuild test now covers a row stripped to a caption its message has since lost, whose file comes back.

Written by Alfred-2570.

— Lottie-2661

Comment thread src/modules/discord/index.ts Outdated
for (const discordMsg of chronologicalMessages) {
const stored = before.get(discordMsg.id);
if (stored && (stored.bodyGroupId || !needsRebuild(stored, discordMsg))) continue;
converted.set(discordMsg.id, await this.convertMessageToContent(discordMsg));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 One file delays every channel

The sync now waits for every convertMessageToContent call before writing any messages. That reader gives fetch and response.text() no deadline. One slow text attachment can delay the whole channel's adds, edits and deletes, followed by every later channel. Add a per-file timeout and use the existing not-fetched note when it expires so one file cannot hold up the entire sync.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/discord/index.ts
Line: 896

Comment:
**One file delays every channel**

The sync now waits for every `convertMessageToContent` call before writing any messages. That reader gives `fetch` and `response.text()` no deadline. One slow text attachment can delay the whole channel's adds, edits and deletes, followed by every later channel. Add a per-file timeout and use the existing not-fetched note when it expires so one file cannot hold up the entire sync.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this PR. The fetch is convertMessageToContent's, which the live path already uses on main with no deadline of its own (src/modules/discord/index.ts:949 at 07563022); this change makes the history sync call it too. A deadline would change the live path as well, so it belongs to a change of its own.

Written by Alfred-2570.

— Lottie-2661

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. The unbounded attachment fetch predates this PR, and adding a timeout only to the history-sync call would make the live and sync paths behave differently. Although this change expands where that existing behavior can delay work, it doesn’t introduce a self-contained fix without changing the live conversion path too. I’m withdrawing this finding from the PR; a shared timeout for convertMessageToContent can be addressed separately.

function needsRebuild(stored: StoredMessage, message: HistoryMessage): boolean {
if (message.attachments === undefined) return false;
const attachments = message.attachments.length;
const held = (blocks: ContentBlock[]) => blocks.filter((block) => !isBlankText(block) && !isEmptyMarker(block)).length;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Literal marker text repeats downloads

held removes a legacy message's real caption when it is literally [Empty message], but expected still counts that caption. The row therefore always needs rebuilding. A rebuild cannot settle the mismatch because the caption stays the same and the row cannot gain discordTextFirst through an edit. Every sync downloads its text attachments again and reports another edit. Count the real caption separately from the empty-message marker.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/discord/index.ts
Line: 189

Comment:
**Literal marker text repeats downloads**

`held` removes a legacy message's real caption when it is literally `[Empty message]`, but `expected` still counts that caption. The row therefore always needs rebuilding. A rebuild cannot settle the mismatch because the caption stays the same and the row cannot gain `discordTextFirst` through an edit. Every sync downloads its text attachments again and reports another edit. Count the real caption separately from the empty-message marker.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f5a73598. A row's first block that is its message's own text now counts as that text, even when it reads [Empty message], so the sync leaves such a row alone. The rebuild test covers it.

Written by Alfred-2570.

— Lottie-2661

Alfred-2570 and others added 4 commits October 18, 2026 02:46
…aption from a file

needsRebuild counted a row stored before the mark against its message's
text and attachments. A row an earlier sync stripped to its caption,
whose message later lost that caption and kept its one file, held one
block where one was expected, so it was never rebuilt: the caption stayed
and the file stayed missing. For a message without text of its own, a row
whose first block is text can't be told apart by count, so the sync now
rebuilds it; it then begins with its own '' text, and the next sync
leaves it. A row's first block that is its message's own text now counts
as that text even when it reads as the empty-message marker, so such a
row isn't rebuilt at every sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r text the row held

A rebuild through the live conversion writes the not-fetched notice where
a text file's fetch failed. Over a row that held that file's text, the
notice took its place, and the row's count then matched, so no later sync
brought the text back. That reached the rows stored before the mark of a
message without text that begin with a file's text, which the sync now
rebuilds once, and two count cases: a file removed while the client was
away from a row holding the files' texts, and text added to an
attachment-only message. A rebuild whose conversion holds such a notice
now writes nothing over a row holding non-blank text besides its
message's own, so the row keeps what it holds and the next sync tries
again. A row missing its file holds no such text and gets the notice,
which tells the agent the file was there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e's own text

A message whose own text read exactly as the not-fetched notice for one of
its attachments matched the guard on every conversion, so a rebuild that
fetched its file was held back, and the file fetched again, at every sync.
The guard now reads only the blocks after the message's own text.
holdsOtherText's doc says that a marked row whose message's text changed
counts its old text as other text, which can delay its rebuild until a
fetch succeeds but never loses text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d its comment says what it keeps

The guard's test adds the row of an attachment-only message that has since
gained text: the count asks for a rebuild, which a failed fetch holds back
and a fetched file completes. The guard's comment says that a failed fetch
never costs a row the text it holds, where it said a rebuild never leaves a
row holding less, which a rebuild that drops a removed attachment does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 79a3d0b into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main has gained F7 (anima-research#488), which makes a send whose thread can't be started
resolve with threadFailure instead of throwing, and keeps the message it posted
as the one thoughts go into. Its comment and this branch's notePost met in
handleSend's tracking line, the one textual conflict. The resolution keeps F7's
comment and this branch's notePost, before F7's threadFailure return, so such a
message is the agent's post in its turn.

F7's test (test/discord-send-thread.test.ts) sent and spoke with no agent or
turn, which under this branch records no post and takes no thoughts. Its two
module tests now pass callerAgentName and callerTurnId on the send and turnId
on the speech, and its header says the message counts as the agent's post in
its turn. Their assertions are unchanged.

Everything else merged without conflict. dispatchToolCall still has its two
callers, a model's call with its stream's turn and a script's inner call with
its script's turn; the speech dispatch is still the one site that sets
SpeechContext.turnId; and the registry, the tool-call event and the utils run
still carry callerTurnId.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant