@mentions in replies should surface the replied-to message as primary subject, not channel head #579

Closed
opened 2026-08-13 16:19:30 +00:00 by coilyco-ops-gaming · 3 comments

When a user @'s Sirens Echo in a reply to another message, the harness currently presents the most recent message in the channel as context rather than the specific message being replied to.

Expected behavior: The primary subject presented should be the message being replied to, since that's what the @mention is addressing.

Current behavior: The harness treats it as if responding to the latest channel message, losing the reply context entirely.

This matters because replies are how users direct questions at specific prior statements. Without preserving the replied-to message as primary subject, responses can address the wrong topic or miss the actual question being asked.

When a user @'s Sirens Echo in a reply to another message, the harness currently presents the most recent message in the channel as context rather than the specific message being replied to. **Expected behavior:** The primary subject presented should be the message being replied to, since that's what the @mention is addressing. **Current behavior:** The harness treats it as if responding to the latest channel message, losing the reply context entirely. This matters because replies are how users direct questions at specific prior statements. Without preserving the replied-to message as primary subject, responses can address the wrong topic or miss the actual question being asked.
Member

Confirmed from the code, and the harness already reads the replied-to message for two other purposes — just not this one — Angie (ENG, claude seat). Read at 1837954. Research, not a claim.

Current() is what becomes the turn's subject, and it carries nothing about the reply:

func (t *discordMessageTurn) Current() TranscriptEntry {
	return TranscriptEntry{
		Author:      displayName(t.message),
		Content:     t.message.ContentWithMentionsReplaced(),
		Counterpart: counterpartOf(t.message),
		Attachments: attachmentTypes(t.message),
	}
}

History() then fetches recent channel messages. So the replied-to message reaches the model only if it happens to fall inside the history window, positioned as one ordinary message among others rather than as the thing being addressed. If the member replies to something older than the window, it does not reach the model at all.

Your description is accurate.

The mechanism already exists twice in this file

t.message.ReferencedMessage is read in two other places:

Summon detection, at agent.go:677 — a reply to Echo's own message counts as addressing Echo, which is why replying works as an invocation at all.

Trace lookup, at agent.go:1265, with a comment that is exactly your argument in another context:

TraceLookup reads the referenced message too, because the id a member wants is usually in the notice they replied to rather than in what they typed.

Substitute "the question a member is asking" for "the id a member wants" and that is this issue. The harness already knows that a reply's meaning lives partly in what it replies to; it applies that knowledge to trace ids and to summons, and not to the subject of the turn.

So this is not new plumbing. The field is present on the message, already used, and needs no API call — Discord delivers ReferencedMessage inline on the event.

What needs deciding rather than building

How it reaches the model. TranscriptEntry has no slot for "this is what the member is replying to". Options are a new field rendered distinctly, or placing it as the immediately-preceding history entry so ordinary conversational reading does the work. The first is explicit and changes the prompt shape; the second is cheaper and relies on the model inferring adjacency.

Whether it should override recency or supplement it. Your title says primary subject. A reply usually is the subject, but a member who replies to an old message and then asks an unrelated question exists too.

The truncated case. Discord does not always populate ReferencedMessage — a reply to a very old message can arrive with only MessageReference and an id, which is why the summon path at agent.go:680 has a fallback that fetches it. Whatever lands here needs the same fallback or an explicit decision to skip that case.

Labelling headless — the decisions above are shape rather than policy, and an agent can take this end to end. Not claiming it; I have three changes in flight in this file's neighbourhood today and #552 is what happens when I add a fourth.

**Confirmed from the code, and the harness already reads the replied-to message for two other purposes — just not this one — Angie (ENG, claude seat).** Read at `1837954`. Research, not a claim. `Current()` is what becomes the turn's subject, and it carries nothing about the reply: ```go func (t *discordMessageTurn) Current() TranscriptEntry { return TranscriptEntry{ Author: displayName(t.message), Content: t.message.ContentWithMentionsReplaced(), Counterpart: counterpartOf(t.message), Attachments: attachmentTypes(t.message), } } ``` `History()` then fetches recent channel messages. So the replied-to message reaches the model **only if it happens to fall inside the history window**, positioned as one ordinary message among others rather than as the thing being addressed. If the member replies to something older than the window, it does not reach the model at all. Your description is accurate. ## The mechanism already exists twice in this file `t.message.ReferencedMessage` is read in two other places: **Summon detection**, at `agent.go:677` — a reply to Echo's own message counts as addressing Echo, which is why replying works as an invocation at all. **Trace lookup**, at `agent.go:1265`, with a comment that is exactly your argument in another context: > TraceLookup reads the referenced message too, because **the id a member wants is usually in the notice they replied to rather than in what they typed.** Substitute "the question a member is asking" for "the id a member wants" and that is this issue. The harness already knows that a reply's meaning lives partly in what it replies to; it applies that knowledge to trace ids and to summons, and not to the subject of the turn. **So this is not new plumbing.** The field is present on the message, already used, and needs no API call — Discord delivers `ReferencedMessage` inline on the event. ## What needs deciding rather than building **How it reaches the model.** `TranscriptEntry` has no slot for "this is what the member is replying to". Options are a new field rendered distinctly, or placing it as the immediately-preceding history entry so ordinary conversational reading does the work. The first is explicit and changes the prompt shape; the second is cheaper and relies on the model inferring adjacency. **Whether it should override recency or supplement it.** Your title says *primary subject*. A reply usually is the subject, but a member who replies to an old message and then asks an unrelated question exists too. **The truncated case.** Discord does not always populate `ReferencedMessage` — a reply to a very old message can arrive with only `MessageReference` and an id, which is why the summon path at `agent.go:680` has a fallback that fetches it. Whatever lands here needs the same fallback or an explicit decision to skip that case. Labelling `headless` — the decisions above are shape rather than policy, and an agent can take this end to end. Not claiming it; I have three changes in flight in this file's neighbourhood today and https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/552 is what happens when I add a fourth.
Member

Claiming now. Angie (ENG, claude seat claude-macos-…-ee99), 17:12Z, for 20 minutes.

I declined this 20 minutes ago citing three changes in flight in this file's neighbourhood. That reason has expired: #584 is closed as superseded, #595 merged, and #612 touches admitMessage, handleMessage, and failTurn, which are different regions of agent.go from the ones this needs.

Deciding the three open questions, since I labelled this headless

I said these were shape rather than policy. Taking my own word for it and settling them here before building, so the decisions are reviewable separately from the diff.

How it reaches the model. Explicitly, attached to the current entry, rather than injected as the preceding history item. Adjacency-inference is cheaper and it is also indistinguishable from coincidence: the model cannot tell an entry placed there deliberately from one that happened to be recent, which is precisely today's defect wearing a different hat.

Override or supplement. Supplement. The member who replies to an old message and then asks something unrelated is real, and a harness that overrides recency answers the wrong question for them with total confidence. Naming what is being replied to lets the model weigh it. Deciding for the model is how a harness gets this wrong in the other direction.

The truncated case. Reuse the fallback the summon path already has at agent.go:680. Skipping it would leave the exact case the issue is worst for - a reply to something old, which is when ReferencedMessage is least likely to arrive inline and when the history window is least likely to contain it.

The one thing I will check before committing to it

Whether this changes agent/rendered/*.prompt.txt. It should not, because the reply reference is turn content rather than system prompt, but the snapshots are gated in pre-commit and I would rather find that now than in the hook.

**Claiming now. Angie (ENG, claude seat `claude-macos-…-ee99`), 17:12Z, for 20 minutes.** I declined this 20 minutes ago citing three changes in flight in this file's neighbourhood. That reason has expired: https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/584 is closed as superseded, https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/595 merged, and https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/612 touches `admitMessage`, `handleMessage`, and `failTurn`, which are different regions of `agent.go` from the ones this needs. ## Deciding the three open questions, since I labelled this headless I said these were shape rather than policy. Taking my own word for it and settling them here before building, so the decisions are reviewable separately from the diff. **How it reaches the model.** Explicitly, attached to the current entry, rather than injected as the preceding history item. Adjacency-inference is cheaper and it is also indistinguishable from coincidence: the model cannot tell an entry placed there deliberately from one that happened to be recent, which is precisely today's defect wearing a different hat. **Override or supplement.** Supplement. The member who replies to an old message and then asks something unrelated is real, and a harness that overrides recency answers the wrong question for them with total confidence. Naming what is being replied to lets the model weigh it. Deciding for the model is how a harness gets this wrong in the other direction. **The truncated case.** Reuse the fallback the summon path already has at `agent.go:680`. Skipping it would leave the exact case the issue is worst for - a reply to something old, which is when `ReferencedMessage` is least likely to arrive inline and when the history window is least likely to contain it. ## The one thing I will check before committing to it Whether this changes `agent/rendered/*.prompt.txt`. It should not, because the reply reference is turn content rather than system prompt, but the snapshots are gated in pre-commit and I would rather find that now than in the hook.
Member

Built. #617, gate green.

What the model now sees

Recent conversation, oldest first:
- alice: the plank market crashed on tuesday
- carol: anyone up for a raid tonight

Replying to alice: the plank market crashed on tuesday

The request that follows is from bob.

The carol line stays. That is the supplement decision holding: the addressed message is named, recency is not overridden, and the model weighs them.

The three decisions, as built

Named, not adjacent. The line is explicit rather than the addressed message being slipped in as the last history entry.

Supplement, not override. Both the reference and the recent conversation render.

Truncated case handled. resolveReplyTo uses ReferencedMessage when the Gateway delivers it inline, which is most replies and costs nothing, and falls back to a fetch otherwise. The fetch draws on a.lookups, the same budget the other gate-forced REST calls use, so a channel full of old replies cannot become one lookup per message. A reference that cannot be read is treated as an ordinary message rather than as a failure.

Resolved in handleMessage before the turn runs, because Current() takes no context and runTurn calls Current() before History(), so resolving it inside the history fetch would have arrived too late to reach the prompt.

What the existing tests caught, and review would not have

My first version made ReplyTo a *TranscriptEntry. That panicked two MCP tests:

panic: AddTool: tool "turn": input schema:
  ForType(community.TurnInput): computing element schema:
  cycle detected for type community.TranscriptEntry

TranscriptEntry generates the MCP turn tool's input schema, and a self-referential type is a cycle the generator refuses. ReplyTo is now a distinct ReplyReference, which renders through the transcript suffixes anyway, so a replied-to agent still reads (an agent, not a person) and a replied-to image still reads (with 1 attachment this service cannot read: image/png).

I would not have found that by reading. Recording it because it is an argument for the schema-generating type being exercised by a test at all.

The check I promised

ward exec prompt-dump reports both snapshots current. This is turn content rather than system prompt, so agent/rendered/*.prompt.txt does not move.

Not covered

Only one level renders. A reply to a reply does not walk the chain, and I did not build that because the second level is a claim about what someone else was addressing rather than about what this member is.

**Built. https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/617, gate green.** ## What the model now sees ``` Recent conversation, oldest first: - alice: the plank market crashed on tuesday - carol: anyone up for a raid tonight Replying to alice: the plank market crashed on tuesday The request that follows is from bob. ``` The `carol` line stays. That is the supplement decision holding: the addressed message is named, recency is not overridden, and the model weighs them. ## The three decisions, as built **Named, not adjacent.** The line is explicit rather than the addressed message being slipped in as the last history entry. **Supplement, not override.** Both the reference and the recent conversation render. **Truncated case handled.** `resolveReplyTo` uses `ReferencedMessage` when the Gateway delivers it inline, which is most replies and costs nothing, and falls back to a fetch otherwise. The fetch draws on `a.lookups`, the same budget the other gate-forced REST calls use, so a channel full of old replies cannot become one lookup per message. A reference that cannot be read is treated as an ordinary message rather than as a failure. Resolved in `handleMessage` before the turn runs, because `Current()` takes no context and `runTurn` calls `Current()` **before** `History()`, so resolving it inside the history fetch would have arrived too late to reach the prompt. ## What the existing tests caught, and review would not have My first version made `ReplyTo` a `*TranscriptEntry`. That panicked two MCP tests: ``` panic: AddTool: tool "turn": input schema: ForType(community.TurnInput): computing element schema: cycle detected for type community.TranscriptEntry ``` `TranscriptEntry` generates the MCP turn tool's input schema, and a self-referential type is a cycle the generator refuses. `ReplyTo` is now a distinct `ReplyReference`, which renders through the transcript suffixes anyway, so a replied-to agent still reads `(an agent, not a person)` and a replied-to image still reads `(with 1 attachment this service cannot read: image/png)`. I would not have found that by reading. Recording it because it is an argument for the schema-generating type being exercised by a test at all. ## The check I promised `ward exec prompt-dump` reports both snapshots current. This is turn content rather than system prompt, so `agent/rendered/*.prompt.txt` does not move. ## Not covered Only one level renders. A reply to a reply does not walk the chain, and I did not build that because the second level is a claim about what someone else was addressing rather than about what this member is.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#579
No description provided.