Replies to threads you created should always summon you #750

Closed
opened 2026-08-13 22:34:51 +00:00 by coilyco-ops-gaming · 4 comments

When a member replies to a thread that was originally started by sirens-echo, the harness should treat that reply as a direct summon of the agent rather than routing it through normal channel logic.

This ensures the agent is aware of follow-up context in its own threads and can respond appropriately without requiring an explicit @mention on every subsequent message in the thread.


Resolved spec (design pass, 2026-08-15)

Kai's answer to Angie's one-word question is yes. Build the narrow version.

Build

  1. In summonedLocally, summon when the message arrives in a thread whose discordgo.Channel.OwnerID is the bot user.
  2. Leave mention and reply-to-Echo exactly as they are. agent.go:717 already covers reply-to-Echo and is not touched.
  3. Leave the access-policy check ahead of the summon check untouched.
  4. Per-thread-starter scoping was considered and rejected. Any member's message in an Echo-owned thread summons, not only the member the thread was opened for.

No expiry. An Echo-created thread stays summonable for its life. A quiet-period cutoff at 24 hours, at 1 hour, and riding Discord's own archive window were all considered and rejected. This is deliberate, so do not add a timer as a defensive measure.

Accepted consequence, stated so nobody treats it as a regression - in a thread Echo opened, two members talking to each other each summon a turn, and an old thread can wake her on a passing message. Rate limits and the pending cap still apply, so this is throttled rather than unbounded.

Measure before building - whether session.State reliably holds thread channels, or whether ownership costs a REST lookup per message in a thread. Angie flagged this as a real cost question and explicitly did not check it. Measure it, report the number here, and build if it is cheap. If it is a REST call per message, say so before shipping rather than after.

Acceptance

  • An ordinary message with no mention, in a thread Echo created, produces a turn.
  • The same message in a thread a member created does not.
  • Mention and reply-to-Echo behaviour is unchanged. Regression-test both.
  • The access-policy refusal still fires ahead of the summon decision.

Provenance - this arrived as a SANDBOXED member-influenced filing. Kai has now confirmed it as a decision, so it is a specification rather than a report.

When a member replies to a thread that was originally started by sirens-echo, the harness should treat that reply as a direct summon of the agent rather than routing it through normal channel logic. This ensures the agent is aware of follow-up context in its own threads and can respond appropriately without requiring an explicit @mention on every subsequent message in the thread. --- ## Resolved spec (design pass, 2026-08-15) **Kai's answer to Angie's one-word question is yes.** Build the narrow version. **Build** 1. In `summonedLocally`, summon when the message arrives in a thread whose `discordgo.Channel.OwnerID` is the bot user. 2. Leave mention and reply-to-Echo exactly as they are. `agent.go:717` already covers reply-to-Echo and is not touched. 3. Leave the access-policy check ahead of the summon check untouched. 4. Per-thread-starter scoping was considered and rejected. Any member's message in an Echo-owned thread summons, not only the member the thread was opened for. **No expiry.** An Echo-created thread stays summonable for its life. A quiet-period cutoff at 24 hours, at 1 hour, and riding Discord's own archive window were all considered and rejected. This is deliberate, so do not add a timer as a defensive measure. **Accepted consequence, stated so nobody treats it as a regression** - in a thread Echo opened, two members talking to each other each summon a turn, and an old thread can wake her on a passing message. Rate limits and the pending cap still apply, so this is throttled rather than unbounded. **Measure before building** - whether `session.State` reliably holds thread channels, or whether ownership costs a REST lookup per message in a thread. Angie flagged this as a real cost question and explicitly did not check it. Measure it, report the number here, and build if it is cheap. If it is a REST call per message, say so before shipping rather than after. **Acceptance** * An ordinary message with no mention, in a thread Echo created, produces a turn. * The same message in a thread a member created does not. * Mention and reply-to-Echo behaviour is unchanged. Regression-test both. * The access-policy refusal still fires ahead of the summon decision. **Provenance** - this arrived as a `SANDBOXED` member-influenced filing. Kai has now confirmed it as a decision, so it is a specification rather than a report.
Member

Claiming. Angie (ENG), seat claude. 20 minutes from this comment, after the one minute buffer.

New, unclaimed, and squarely in the admission path. Checking two things during the buffer before I write anything:

Whether the harness can already tell. A reply in a thread Echo created is a fact the runtime may or may not hold at summon time. If it does not, this is a different and larger change than it reads.

Whether widening summon here is safe. The summon check is one of seven admission checks and it decides what Echo answers at all. Scoped to threads Echo itself created it is narrow, but I want to state the blast radius rather than assume it, because a summon that fires without a mention is the shape that floods a channel.

Recording that this arrived as a SANDBOXED filing, so the request is member-influenced. That does not make it wrong, and I am treating the described behaviour as a report rather than a specification.

Open pull requests checked first: none.

**Claiming. Angie (ENG), seat `claude`. 20 minutes from this comment, after the one minute buffer.** New, unclaimed, and squarely in the admission path. Checking two things during the buffer before I write anything: **Whether the harness can already tell.** A reply in a thread Echo created is a fact the runtime may or may not hold at summon time. If it does not, this is a different and larger change than it reads. **Whether widening summon here is safe.** The summon check is one of seven admission checks and it decides what Echo answers at all. Scoped to threads Echo itself created it is narrow, but I want to state the blast radius rather than assume it, because a summon that fires without a mention is the shape that floods a channel. Recording that this arrived as a **SANDBOXED** filing, so the request is member-influenced. That does not make it wrong, and I am treating the described behaviour as a report rather than a specification. Open pull requests checked first: none.
Member

Feasible, and I am not building it, because it widens what Echo answers at all. Angie (ENG), seat claude. Releasing the claim, adding consult.

What already works

A reply to one of Echo's messages summons her today, with no mention, agent.go:717:

if message.ReferencedMessage != nil && message.ReferencedMessage.Author != nil {
    return message.ReferencedMessage.Author.ID == botID, false
}

So the follow-up case this asks about is covered whenever the member uses Discord's reply. What is not covered is an ordinary message in the thread, addressed to nobody.

Feasible, and here is the mechanism

threadForReply starts the thread on the member's message, so the parent is not Echo's and cannot be the signal. Thread ownership can be: discordgo.Channel.OwnerID is the user who created the thread, which is Echo's bot user for every thread she opens.

The runtime already reads the channel for other reasons (agent.go:683, agent.go:1375, thread.go:89), so the lookup is not new. The change is a few lines in summonedLocally.

Why I am not writing them

This widens the summon contract, which is one of seven admission checks and the one deciding what Echo answers at all. Scoped to her own threads it is narrow, and it is still a change from "answer when addressed" to "answer everything here".

Concretely, in a thread Echo opened, two members talking to each other would each summon a turn. Rate limits and the pending cap still apply, so this is throttled rather than unbounded, but the first-order behaviour is that Echo joins a conversation nobody addressed.

That may well be what you want in her own threads. It is a product decision rather than a defect, and this arrived as a member-influenced SANDBOXED filing rather than as your instruction, so I am not treating it as one.

What would make it mine

One word. If the answer is yes, the narrow version is: summon when the message is in a thread whose OwnerID is the bot, leaving mention and reply-to-Echo exactly as they are, and leaving the access-policy check ahead of it untouched. One claim.

Worth pairing with a decision on whether an Echo-created thread should also stop summoning after some quiet period, because a thread is durable and this makes it permanently live.

Not in scope, and worth stating

I did not check whether session.State reliably holds thread channels or whether this costs a REST lookup per message in a thread. That is a real cost question and I would measure it before building, not before you decide.

**Feasible, and I am not building it, because it widens what Echo answers at all. Angie (ENG), seat `claude`. Releasing the claim, adding `consult`.** ## What already works A reply **to one of Echo's messages** summons her today, with no mention, `agent.go:717`: ```go if message.ReferencedMessage != nil && message.ReferencedMessage.Author != nil { return message.ReferencedMessage.Author.ID == botID, false } ``` So the follow-up case this asks about is covered whenever the member uses Discord's reply. What is **not** covered is an ordinary message in the thread, addressed to nobody. ## Feasible, and here is the mechanism `threadForReply` starts the thread on the **member's** message, so the parent is not Echo's and cannot be the signal. Thread **ownership** can be: `discordgo.Channel.OwnerID` is the user who created the thread, which is Echo's bot user for every thread she opens. The runtime already reads the channel for other reasons (`agent.go:683`, `agent.go:1375`, `thread.go:89`), so the lookup is not new. **The change is a few lines in `summonedLocally`.** ## Why I am not writing them **This widens the summon contract**, which is one of seven admission checks and the one deciding what Echo answers at all. Scoped to her own threads it is narrow, and it is still a change from *"answer when addressed"* to *"answer everything here"*. Concretely, in a thread Echo opened, two members talking to each other would each summon a turn. Rate limits and the pending cap still apply, so this is throttled rather than unbounded, but the first-order behaviour is that Echo joins a conversation nobody addressed. That may well be what you want in her own threads. **It is a product decision rather than a defect**, and this arrived as a member-influenced `SANDBOXED` filing rather than as your instruction, so I am not treating it as one. ## What would make it mine One word. If the answer is yes, the narrow version is: **summon when the message is in a thread whose `OwnerID` is the bot**, leaving mention and reply-to-Echo exactly as they are, and leaving the access-policy check ahead of it untouched. One claim. Worth pairing with a decision on whether an Echo-created thread should also **stop** summoning after some quiet period, because a thread is durable and this makes it permanently live. ## Not in scope, and worth stating I did not check whether `session.State` reliably holds thread channels or whether this costs a REST lookup per message in a thread. That is a real cost question and I would measure it before building, not before you decide.
Member

Still unimplemented as of b740233 on main (2026-08-18 22:09). Verified against the source, not the thread. Raised again by Kai today, who reports Dowel never answering comments in threads and has now asked for this three times.

Current state, checked rather than assumed

  • OwnerID appears nowhere in the Go source.
  • summonedLocally is unchanged and still has exactly three paths: a DM, a mention of the bot user or a role it holds, and a Discord reply whose referenced message the bot authored.
  • This issue has not been touched since the design pass on 2026-08-15.

So the resolved spec in the body above is still the plan of record and nothing has been built against it.

The measurement precondition is now satisfied

The spec says to measure whether session.State reliably holds thread channels, or whether ownership costs a REST lookup per message, and to report the number here before building. That was never reported, and it is the one thing the spec said had to happen first.

It is cheap:

  • resolveChannel (agent.go:763) already resolves cached-state-first with a REST fallback.
  • thread.go:162 already calls session.State.Channel(...).IsThread() on the message path with no API call.

The ownership read is the same cached lookup the message path already performs. Cached hit in the common case, with the existing REST fallback only on a miss. That precondition is met and should not block the build again.

Why this keeps reading as done

Three things landed tonight whose titles describe this and do not implement it:

  • #975 feat(coalesce): give the batching lane the Discord summon path
  • #983 fix(coalesce): tell the model a folded ask is several comments
  • #976 turn the coalescing lane on

The coalescing lane sits downstream of the summon gate. summonedLocally decides at agent.go:598; submitSummon runs at agent.go:966. Coalescing only sees messages that already summoned.

Concretely: three mentions in a thread now fold into one answer covering all three. Three plain comments in a thread are still rejected at the gate and coalescing never sees them. Same words, opposite half of the pipeline.

#987 fix(turns): a dropped turn always tells the member is the same kind of near-miss. It covers turns that started, and a message that never summons never starts one.

One addition worth making to the spec before it is built

The spec's accepted consequence is that any member's message in a bot-owned thread summons a turn. On the August 19 lane that includes other builders' agents posting in a shared channel, so two agents in a Dowel-owned thread can summon each other in a loop, on camera, on a lane already at a 32.9s median turn (#932).

Suggest the auto-summon path ignore messages authored by other bot accounts. Mention and reply-to-own-message keep working for bots, so nothing an agent deliberately addresses to Dowel is lost. That is a small clause the current spec does not have.

Freeze status

Not one of the six on #929, and not on the #981 demo board, so as written it is behind August 20. Kai can lift it for this item in one comment if she wants it for the stream.

The observability half, which is why this took three asks to surface, is filed separately.

**Still unimplemented as of `b740233` on main (2026-08-18 22:09). Verified against the source, not the thread.** Raised again by Kai today, who reports Dowel never answering comments in threads and has now asked for this three times. ## Current state, checked rather than assumed * `OwnerID` appears **nowhere** in the Go source. * `summonedLocally` is unchanged and still has exactly three paths: a DM, a mention of the bot user or a role it holds, and a Discord reply whose referenced message the bot authored. * This issue has not been touched since the design pass on 2026-08-15. So the resolved spec in the body above is still the plan of record and nothing has been built against it. ## The measurement precondition is now satisfied The spec says to measure whether `session.State` reliably holds thread channels, or whether ownership costs a REST lookup per message, and to report the number here before building. That was never reported, and it is the one thing the spec said had to happen first. It is cheap: * `resolveChannel` (`agent.go:763`) already resolves cached-state-first with a REST fallback. * `thread.go:162` already calls `session.State.Channel(...).IsThread()` on the message path with no API call. The ownership read is the same cached lookup the message path already performs. Cached hit in the common case, with the existing REST fallback only on a miss. That precondition is met and should not block the build again. ## Why this keeps reading as done Three things landed tonight whose titles describe this and do not implement it: * #975 `feat(coalesce): give the batching lane the Discord summon path` * #983 `fix(coalesce): tell the model a folded ask is several comments` * #976 turn the coalescing lane on The coalescing lane sits **downstream** of the summon gate. `summonedLocally` decides at `agent.go:598`; `submitSummon` runs at `agent.go:966`. Coalescing only sees messages that already summoned. Concretely: three mentions in a thread now fold into one answer covering all three. Three plain comments in a thread are still rejected at the gate and coalescing never sees them. Same words, opposite half of the pipeline. #987 `fix(turns): a dropped turn always tells the member` is the same kind of near-miss. It covers turns that started, and a message that never summons never starts one. ## One addition worth making to the spec before it is built The spec's accepted consequence is that any member's message in a bot-owned thread summons a turn. On the August 19 lane that includes **other builders' agents** posting in a shared channel, so two agents in a Dowel-owned thread can summon each other in a loop, on camera, on a lane already at a 32.9s median turn (#932). Suggest the auto-summon path ignore messages authored by other bot accounts. Mention and reply-to-own-message keep working for bots, so nothing an agent deliberately addresses to Dowel is lost. That is a small clause the current spec does not have. ## Freeze status Not one of the six on #929, and not on the #981 demo board, so as written it is behind August 20. Kai can lift it for this item in one comment if she wants it for the stream. The observability half, which is why this took three asks to surface, is filed separately.
Member

Built and landed on main as 7f764c8. Angie (ENG), seat claude. Removing autonomy/async-consult.

The measurement you asked for, before shipping rather than after

It costs no REST call. discordgo's State caches thread channels, so State.Channel answers from memory:

  • GUILD_CREATE carries guild.Threads and GuildAdd writes every one into channelMap (state.go:103).
  • THREAD_CREATE and THREAD_UPDATE route to ChannelAdd under TrackThreads, which defaults on (state.go:1083).
  • Both events ride the GUILDS intent, which this service already requests, so nothing new is subscribed.
  • Channel.OwnerID is on the cached object, populated on both paths.

So the ownership check reads memory on the path that matters, including every thread Echo opens herself, since THREAD_CREATE lands as she creates it.

The state miss is where I made a call you did not specify. A channel the state has never seen reports unowned rather than falling back to a lookup. The summon decision is the one gate where a REST call per message would be unbounded, and the two gates that can already force a call are admission-bounded for exactly that reason. Mention and reply-to-Echo still decide for such a thread, so an unseen thread is quieter rather than deaf. TestThreadOwnershipReadsCachedGatewayState pins the measurement so it cannot quietly stop being true.

Built as specified

summonedLocally summons when Channel.OwnerID is the bot user. No per-thread-starter scoping, so any member's message in an Echo thread summons. No expiry, so the thread stays summonable for its life, and there is no timer to find and delete later.

agent.go:717 reply-to-Echo is untouched, and so is the access-policy check ahead of the summon decision.

Acceptance

All four, as tests:

  • An unmentioned message in an Echo-created thread summons - TestMessageInServiceOwnedThreadSummons, including one member answering another.
  • The same message in a member-created thread does not - TestMessageInMemberOwnedThreadDoesNotSummon.
  • Mention and reply-to-Echo unchanged - regression cases in both new tests plus TestReplyToServiceMessageSummons and TestDirectMessagesSummonWithoutAMention, unmodified.
  • The access-policy refusal still fires first - untouched in admitMessage, which evaluates the policy before calling summonedLocally.

just gate green.

Documented

The accepted consequence is written down as a decision rather than left to be rediscovered: in mentions the summon list is now five things rather than four, and threads says plainly that two members talking in an Echo thread each summon a turn, throttled rather than bounded, with no quiet period.

The docs folder is at its 40-page cap, so this went into the existing pages rather than a new one.

Not in this commit

Kai also asked for a reply whose whole answer is an emoji reaction. That is not this issue, so it is filed and landed separately as #991.

**Built and landed on `main` as `7f764c8`. Angie (ENG), seat `claude`. Removing `autonomy/async-consult`.** ## The measurement you asked for, before shipping rather than after **It costs no REST call.** discordgo's `State` caches thread channels, so `State.Channel` answers from memory: * `GUILD_CREATE` carries `guild.Threads` and `GuildAdd` writes every one into `channelMap` (`state.go:103`). * `THREAD_CREATE` and `THREAD_UPDATE` route to `ChannelAdd` under `TrackThreads`, which defaults on (`state.go:1083`). * Both events ride the `GUILDS` intent, which this service already requests, so nothing new is subscribed. * `Channel.OwnerID` is on the cached object, populated on both paths. So the ownership check reads memory on the path that matters, including every thread Echo opens herself, since `THREAD_CREATE` lands as she creates it. **The state miss is where I made a call you did not specify.** A channel the state has never seen reports *unowned* rather than falling back to a lookup. The summon decision is the one gate where a REST call per message would be unbounded, and the two gates that can already force a call are admission-bounded for exactly that reason. Mention and reply-to-Echo still decide for such a thread, so an unseen thread is quieter rather than deaf. `TestThreadOwnershipReadsCachedGatewayState` pins the measurement so it cannot quietly stop being true. ## Built as specified `summonedLocally` summons when `Channel.OwnerID` is the bot user. No per-thread-starter scoping, so any member's message in an Echo thread summons. No expiry, so the thread stays summonable for its life, and there is no timer to find and delete later. `agent.go:717` reply-to-Echo is untouched, and so is the access-policy check ahead of the summon decision. ## Acceptance All four, as tests: * An unmentioned message in an Echo-created thread summons - `TestMessageInServiceOwnedThreadSummons`, including one member answering another. * The same message in a member-created thread does not - `TestMessageInMemberOwnedThreadDoesNotSummon`. * Mention and reply-to-Echo unchanged - regression cases in both new tests plus `TestReplyToServiceMessageSummons` and `TestDirectMessagesSummonWithoutAMention`, unmodified. * The access-policy refusal still fires first - untouched in `admitMessage`, which evaluates the policy before calling `summonedLocally`. `just gate` green. ## Documented The accepted consequence is written down as a decision rather than left to be rediscovered: in [mentions](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/src/branch/main/docs/sirens-echo-mentions.md) the summon list is now five things rather than four, and [threads](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/src/branch/main/docs/sirens-echo-threads.md) says plainly that two members talking in an Echo thread each summon a turn, throttled rather than bounded, with no quiet period. The docs folder is at its 40-page cap, so this went into the existing pages rather than a new one. ## Not in this commit Kai also asked for a reply whose whole answer is an emoji reaction. That is not this issue, so it is filed and landed separately as #991.
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#750
No description provided.