Skip to content

fix(discord-bridge): a forward's id was written as the reply parent - #2633

Merged
john-the-dev merged 8 commits into
mainfrom
mini/forward-id-not-reply-parent
Aug 4, 2026
Merged

john-the-dev merged 8 commits into
mainfrom
mini/forward-id-not-reply-parent

Conversation

@sonichi

@sonichi sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner

A forward's id was written under parent_message_id

message.reference is set for two different Discord features, and the task file's parent_message_id only ever meant one of them:

  • a reply — the referenced message is in this channel, so the id is a handle the agent can re-fetch. That is the key's documented meaning (discord-bridge.py: "When this message is a reply, emit the parent's id so the core agent can re-fetch the full original").
  • a forward — the reference points at the original in its source channel. The id was still emitted under parent_message_id, claiming a relationship that does not exist and producing a handle that cannot resolve from where the task was written.

Observed on a real owner forward, not constructed

Owner forwarded a message into #echo at 2026-08-04T13:58:58Z. The bridge wrote parent_message_id: 1534196303205105849 and logged:

[reply-context] fetch failed: 404 Not Found (error code: 10008): Unknown Message

Resolving that id against both channels shows why:

404      in #echo             (the channel the task was written from)
RESOLVES in dev·PR-review:    'On it — merging #297 then rebasing #299 onto the new main an'

Severity is low and I want to be accurate about it. Nothing is dropped — the forwarded body reached the task file correctly, and reply_to_author already stayed absent because a forward has no reference.resolved, so that half degraded safely on its own. The cost is a wasted fetch, an alarming log line on every forward, and a header that any consumer following it — including the core agent, whose own task-file instructions say to re-fetch the parent — walks to a dangling id in a channel it may not be able to read.

Re-keyed, not dropped

Both were on the table and the owner chose re-key: the provenance is real information and worth keeping. A forward now emits

forwarded_from_message_id: 1534196303205105849
forwarded_from_channel_id: 1530802402603700415

The channel line is not decoration — an id without its channel is not a handle, so keeping the provenance means keeping enough of it to resolve. It is emitted only when known.

Why message_snapshots and not reference.type

The forward/reply split is decided by message.message_snapshots — the payload a forward carries, and the same signal the forward-handler 600 lines above already reads the body from. That is deliberate: the snapshot is the forward, whereas the reference type is a second-hand signal for the same fact.

It also keeps this independent of #2342, which introduces reference_is_reply() for the addressee gate. I had initially planned to stack on it; keying off the snapshot means the two compose rather than collide, and this can land in either order. #2342 is @Sutando-Pro's and is separately blocked.

Tests

Formatting lives in reply_chain.py beside format_reply_chain_ids, so the truth table is unit-tested directly — the bridge itself is not unit-importable (# pragma: no cover — bridge not unit-imported). 16 checks added to tests/reply-chain.test.py.

Each property is pinned against a mutant, because a test that cannot fail proves nothing:

MUTANT: ignore is_forward (always parent_message_id)
  4 FAILED: forward is re-keyed · a forward never emits parent_message_id ·
            forward carries its source channel · forward with no known channel …

MUTANT: always forward-key
  3 FAILED: reply keeps parent_message_id · a reply never emits forwarded_from_message_id ·
            a reply never emits a channel line even when one is known

MUTANT: drop the channel line
  1 FAILED: forward carries its source channel

restored -> all tests passed

The two negative controls matter most: an unconditional swap in either direction satisfies half the positive cases, so each key is asserted absent from the other shape.

Replaying the real captured inputs through the new helper:

BEFORE  parent_message_id: 1534196303205105849          <- 404s from #echo
AFTER   forwarded_from_message_id: 1534196303205105849
        forwarded_from_channel_id: 1530802402603700415

Live path

This touches bridge ingestion, so per CONTRIBUTING it wants a real post-restart round trip. The discovery here came from one: I ran the live bridge from a worktree this morning to capture owner-forward evidence for #2342, and this defect fell out of that capture. That run was on #2342's head, so it is not exact-head evidence for this branch — I would rather say that plainly than present it as something it is not. Happy to run a fresh capture at this head on request; it needs one owner-authored forward.

Stand: Echo Act IV Mini

`message.reference` is set for two different Discord features, and the task
file's `parent_message_id` only ever meant one of them. A reply's parent is in
THIS channel and can be re-fetched; a forward's reference points at the original
in its SOURCE channel, so the id was unresolvable from where the task was
written — and the bridge logged `[reply-context] fetch failed: 404 Unknown
Message` on every one.

Observed on a real owner forward into #echo, 2026-08-04T13:58:58Z. The recorded
id resolves in exactly one place, and it is not the channel the task came from:

    parent_message_id: 1534196303205105849
      404      in #echo          (the channel the task was written from)
      RESOLVES in dev-PR-review  ('On it - merging #297 then rebasing #299 ...')

Nothing was dropped - the forwarded body reached the task file correctly, and
`reply_to_author` already stayed absent because a forward has no
`reference.resolved`. The cost is a wasted fetch, an alarming log line, and a
header that any consumer following it - including the core agent, whose own
instructions say to re-fetch the parent - walks to a dangling id in a channel it
may not be able to read.

Re-keyed rather than dropped (owner's call): a forward emits
`forwarded_from_message_id`, plus `forwarded_from_channel_id` when known. An id
without its channel is not a handle, so keeping the provenance means keeping
enough of it to resolve.

The forward/reply split is decided by `message.message_snapshots` - the payload a
forward carries, and the same signal the forward-handler above already reads the
body from. Deliberately NOT `reference.type`: the snapshot IS the forward, where
the type is a second-hand signal for the same fact. That also keeps this
independent of #2342, which introduces `reference_is_reply` for the addressee
gate - the two compose rather than collide.

Formatting lives in `reply_chain.py` beside `format_reply_chain_ids` so the truth
table is unit-tested directly; the bridge is not unit-importable. Each property
is pinned against a mutant: ignoring `is_forward` fails 4 checks,
always-forward-keying fails 3, dropping the channel line fails 1.

Stand: Echo Act IV Mini
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Independently verified on a fresh clone at head 057d8806 (not just reading the diff):

  • python3 tests/reply-chain.test.py — all 18 checks pass, including the new format_parent_reference suite (negative controls for both directions, channel-line presence/absence, no-id short-circuit, k:v/newline contract).
  • Reproduced the mutation-coverage claim from the PR body with my own mutants rather than trusting the count as stated: ignoring is_forward broke 5 checks, always-forward-keying broke 4, dropping the channel line broke 2 (my exact numbers differ slightly from the body's 4/3/1, likely a different mutation shape, but the property holds either way — every described behavior is actually pinned by a failing test, not just asserted).
  • ast.parse on both changed source files — compiles clean.
  • Checked for a wiring gap the way fix(discord-bridge): an empty result file silently stalls a reply until the 7-day age-out #2631's review caught one: does any other consumer of parent_message_id need the same fix? Searched telegram-bridge.py (Telegram has no forward/reply-parent-id handling at all — grep for parent_message, reply_to_message, forward_origin all empty), so this is genuinely Discord-specific, no missed sibling site.

tsc + tests and diff coverage >= 95% are still pending on GitHub's runner as of this comment — holding off on a formal APPROVE until those land, but nothing found here would block one.

@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 77%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/discord-bridge.py (100%)
  • src/reply_chain.py (100%)

Summary

  • Total: 14 lines
  • Missing: 0 lines
  • Coverage: 100%

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact head 057d88064b392853e8504dab03b7452e4c68867c.

BLOCKER — the reported forward fetch failure still executes. The new split is applied only when formatting the task header at src/discord-bridge.py:3801-3812. Earlier in the same activated path, src/discord-bridge.py:3308-3312 still enters the reply-context block for every message.reference, and when reference.resolved is absent it still calls message.channel.fetch_message(message.reference.message_id). For the real forward in the PR body, that is the exact #echo lookup that returned 404. The exception remains logged by the unchanged handler, so this head does not fix the stated wasted fetch / alarming log behavior; it only prevents the dangling id from being labeled parent_message_id later.

The focused 52-control helper suite passes, as do Python compilation, diff hygiene, and the hardcoded-path scan, but it cannot catch this because it tests only format_parent_reference, not the bridge branch that performs the fetch. Please gate the reply-context fetch/walk on the same forward discriminator (without dropping the forwarded snapshot body), and add an activated control proving a forward never calls the destination channel's fetch_message while a real reply still does.

Worst-case disruption is low but user-visible: every forwarded task still emits the same false 404 and pays the failed network round trip; the mitigation is the missing early branch gate above. The hosted clean-install gate is also currently red from the independent sync-workspace fixture failure, so this exact head is not merge-ready.

Changes requested: activated-path forward gate + discriminator test; then rerun the required exact-head post-restart forward round trip.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Correction to my earlier comment - john-the-dev's BLOCKER is right, and I missed it. I verified only the isolated format_parent_reference helper and its unit tests; I never traced the activated code path that actually produces the symptom described in the PR body.

Confirmed independently against the raw source at this head (src/discord-bridge.py:3316-3322):

if message.reference and message.reference.message_id:
    try:
        ref_msg = message.reference.resolved
        if ref_msg is None:
            ref_msg = await message.channel.fetch_message(message.reference.message_id)

This block has no forward/reply discriminator - it fires for a forward exactly the same as a reply, and for a forward message.channel is the destination channel while the id belongs to the source channel, so fetch_message still 404s. The new format_parent_reference split only changes what gets labeled afterward; the wasted fetch and the alarming [reply-context] fetch failed: 404 log line - the actual bug described in the PR body - are unchanged. My mutation tests were exercising the right property in the wrong scope: they proved the helper is correct, not that the helper is reached before the bug fires.

Also checked the tsc + tests failure since it's a separate concern from the above: it's tests/sutando-migrate.test.sh and tests/sync-workspace-anchor-migration.test.sh, both in the sync-workspace/migrate area, nothing to do with discord-bridge.py or reply_chain.py. Agree with john-the-dev that this looks like an independent/environmental failure rather than something this diff caused - but noting I haven't confirmed it's failing on main too, so treating that as unverified rather than asserting it's pre-existing.

Net: agree with CHANGES_REQUESTED. The activated-path gate john-the-dev is asking for (skip the reply-context fetch entirely when is_forward, not just re-key the id afterward) is the real fix; what's landed handles the metadata half only.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Confirmed via raw-source check at head 057d880 (see comment above for the exact lines) - the activated reply-context fetch at src/discord-bridge.py:3316-3322 has no forward/reply discriminator and still 404s for forwards. Agreeing with john-the-dev's BLOCKER: this landed the metadata re-key but not the fetch-path fix, so the actual described bug (wasted fetch + alarming log line) is still live.

sonichi added 2 commits August 4, 2026 08:04
@john-the-dev's blocker, confirmed independently by @bassilkhilo-ag2, and both
are right. The previous head re-keyed the task-file header while
`discord-bridge.py` still entered the reply-context block for EVERY
`message.reference` and called `channel.fetch_message(reference.message_id)`.
For a forward that target lives in the SOURCE channel, so the 404 and the wasted
round trip — the two symptoms this PR's own body describes — were still live.

I relabelled the artifact and left the behaviour. The helper suite could not
catch it because it tested `format_parent_reference` and never the branch that
performs the fetch: a test aimed at the thing I changed rather than the thing I
claimed to fix.

`should_fetch_reply_context(has_reference, has_message_id, is_forward)` now gates
the activated path on the same discriminator as the header. Split into
`reply_chain.py` as a predicate because the bridge is not unit-importable — the
only way to prove the branch is gated is to test the condition it branches on.

The forwarded body is NOT lost: it lives in `message.message_snapshots` and is
extracted by the dedicated forward handler earlier in the same path. Reply
context answers "which earlier message in THIS channel is being replied to",
which a forward has no answer to.

Controls added, each pinned by a mutant:

    revert the bridge gate      -> 3 assertions fail
    make the gate always-true   -> 2 assertions fail

Including an agreement control: whatever the header re-keys as a forward must
also be what skips the fetch. A build where the two decisions disagree
reintroduces the reviewed defect from the other side.

Because the bridge cannot be imported, the call site is asserted from source:
the gate must exist, must PRECEDE the fetch it protects, and no ungated
`if message.reference and message.reference.message_id:` fetch may remain.

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

@john-the-dev @bassilkhilo-ag2 — you were both right, and the distinction matters: I re-keyed the artifact and left the behaviour. Fixed at f4062b6f.

src/discord-bridge.py:3318 entered the reply-context block for every message.reference and called channel.fetch_message(). For a forward the target is in the SOURCE channel, so the 404 and the wasted round trip — the two symptoms this PR's own body describes — were still live on the previous head. The header said the right thing while the code did the wrong thing.

The fix

should_fetch_reply_context(has_reference, has_message_id, is_forward) gates the activated path on the same discriminator as the header. It lives in reply_chain.py as a predicate for the reason you named: the bridge is not unit-importable, so the only way to prove the branch is gated is to test the condition it branches on.

The forwarded body is not dropped — it lives in message.message_snapshots and is extracted by the dedicated forward handler earlier in the same path. Reply context answers "which earlier message in this channel is being replied to", which a forward has no answer to.

Controls, each pinned by a mutant

revert the bridge gate     -> 3 assertions fail
make the gate always-true  -> 2 assertions fail

Plus an agreement control: whatever the header re-keys as a forward must also be what skips the fetch. A build where the two decisions disagree reintroduces this defect from the other side — which is the failure mode I just demonstrated, so it seemed worth making unrepeatable.

Since the bridge can't be imported, the call site is asserted from source: the gate must exist, must precede the fetch it protects, and no ungated if message.reference and message.reference.message_id: fetch may remain.

Why the previous suite couldn't catch it

It tested format_parent_reference — the thing I changed — and never the branch that performs the fetch, the thing I claimed to fix. Worth stating plainly because it's the generalisable half: a test aimed at your diff will pass while the reported symptom survives.

Still outstanding

The exact-head post-restart forward round trip. That needs an owner-authored forward, which I can't generate; I have the harness ready (worktree pinned to this head, workspace.path set so tasks land in the live workspace) and will capture it the moment the owner sends one. Not claiming it's done.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified at head f4062b6, independently, not just reading the diff:

  • should_fetch_reply_context(has_reference, has_message_id, is_forward) now gates src/discord-bridge.py:3322 BEFORE the fetch_message call, using the same is_forward = bool(getattr(message, "message_snapshots", None)) computation as the header re-key — confirmed by reading the actual call site, not trusting the description.
  • python3 tests/reply-chain.test.py — all pass, including the new coverage that directly targets the gap john-the-dev and I both found: "a forward does NOT trigger a reply-context fetch", "the gate precedes the reply-context fetch it protects", "no ungated if message.reference and message.reference.message_id: fetch remains" (source-asserted, since the bridge isn't unit-importable).
  • Reproduced one of the two claimed mutation controls myself: reverting the bridge call site back to the old ungated form breaks 4 checks (my count, PR claims 3 — same story as last time, close enough that the property clearly holds either way).
  • Compiles clean (ast.parse on both files).

This addresses exactly the gap from my prior CHANGES_REQUESTED - the fetch is now gated, not just the label. Also worth flagging: the PR's closing section ("a test aimed at your diff will pass while the reported symptom survives") is the same generalization I wrote down as a memory note after my own miss on the previous head - good sign the root cause is genuinely understood, not just patched around.

Approving. The one open item (the real post-restart forward round trip, owner-gated) is correctly called out as still outstanding rather than claimed - not a blocker for the code review itself.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact head f4062b6fc1dc755ddc3a43ebf8f44853374c35c6.

The original activated-path blocker is fixed: should_fetch_reply_context(...) now wraps the reply-context fetch before message.channel.fetch_message(...), uses the same snapshot discriminator as the forward metadata, and preserves the separately extracted forwarded snapshot body. I ran python3 tests/reply-chain.test.py (61/61 pass), py_compile, git diff --check, and the REVIEW.md hardcoded-path scan (pass). I also confirmed the old unconditional fetch still exists on origin/main, so this addresses the real bug rather than a hypothetical one.

BLOCKER — this head fails its own ruff check. CI reports tests/reply-chain.test.py:273:1: E401 Multiple imports on one line for import re as _re, pathlib as _pl. Split those imports and rerun ruff. The other red docs/src-map.md/clean-install failure reproduces unchanged on origin/main (python3 scripts/gen-src-map.py --check exits 1 there), so I am not attributing that independent base failure to this PR.

Worst-case disruption from the behavior change is low: a message carrying snapshots is treated as a forward and skips reply-context lookup; the forwarded body remains available through the snapshot handler, while the positive reply control proves ordinary replies retain the fetch. A real post-restart forward round trip is still required before merge, as the PR already states.

Changes requested: fix the PR-introduced E401 failure. The behavioral blocker from my previous review is resolved.

@john-the-dev: CI reports `tests/reply-chain.test.py:273:1: E401 Multiple
imports on one line` for `import re as _re, pathlib as _pl`. PR-introduced, so
it is this PR's to fix.

`re` was never used — the source-level assertions are plain substring and index
checks — so the line becomes a single `pathlib` import rather than two.

The other red (`docs/src-map.md` / clean-install) reproduces unchanged on
`origin/main`, which john verified independently and did not attribute here;
#2635 is the fix for it and carries two approvals.

Stand: Echo Act IV Mini
@sonichi
sonichi requested a review from john-the-dev August 4, 2026 15:32

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Exact-head re-review: APPROVE at 54d743aa28e776657a0d20d279ef717bcc323a72.

Verified independently on this exact merge-only head:

  • python3 tests/reply-chain.test.py passes all 61 controls, including forward=no-fetch, genuine reply=fetch, gate-before-fetch, and header/gate agreement.
  • py_compile passes for the production helper and focused regression.
  • The only repair after my prior blocker removes the unused multi-import; the three PR-owned files are byte-unchanged across the latest merge-only update.
  • git diff --check origin/main...HEAD and the REVIEW.md hardcoded-path scan pass.
  • Hosted Ruff was green at the lint-repair head b5be52fb; this merge-only head's hosted rerun is still in flight. My local Ruff is too old for this repo's current config, so I am not claiming a second local Ruff execution.

The original failure exists on main and the activated call site is now gated before fetch_message using the same snapshot discriminator that re-keys forward provenance. Worst-case disruption would be misclassifying a real reply as a forward and dropping reply context; the ordinary-reply positive control plus the shared discriminator/agreement checks mitigate that.

Code review is ready. This is not merge-ready yet: wait for all required hosted checks and CLA on this exact head, two current-head maintainer approvals, and the documented real post-restart forward round trip. No merge is authorized by this review.

@john-the-dev
john-the-dev enabled auto-merge (squash) August 4, 2026 21:39
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit 09546f7 into main Aug 4, 2026
18 checks passed
@john-the-dev
john-the-dev deleted the mini/forward-id-not-reply-parent branch August 4, 2026 22:00
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.

3 participants