Skip to content

feat(typing): show typing in disabled prose-routing mode - #233

Open
theaspirational wants to merge 7 commits into
anima-research:mainfrom
theaspirational:feat/disabled-mode-typing
Open

theaspirational wants to merge 7 commits into
anima-research:mainfrom
theaspirational:feat/disabled-mode-typing

Conversation

@theaspirational

@theaspirational theaspirational commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

An agent with proseRouting: 'disabled' answers only through send tools. Its turns show no typing indicator: both typing sites (turn start in beginAgentTurn, and driveStream) return null for disabled mode. A resident set up this way looks idle while it works, even when it was addressed directly.

Seen on a live Zulip/Slack support resident that runs with proseRouting: 'disabled': the indicator never showed, so it has carried a local patch for this since 09-30. This PR is that patch, ported to main and reviewed.

Changes

proseRouting: 'disabled' now shows presence the way explicit mode does, through one helper, disabledTypingChannel, used at both sites so they pick the same channel:

  • Trigger channel. Typing shows on trigger.channelId, as in explicit mode.
  • Batched gate wake. A gate wake deliberately names no channelId (so it sets no speech locus) but carries wakeChannelId. Disabled mode falls back to it: typing is presence, not delivery, and a disabled agent has no prose to misroute. With conversation routing the gate wakes every agent while a channel's messages reach only its fork, so the fallback applies only where the channel reaches the agent: a fork on its home channel, no other agent on a fork-bound channel.
  • Never a tuned-out channel. Tune-out diverts a channel's incoming traffic, addressed included, away from the resident, but an ambient push, a coalesced push wake or a debounced gate wake can still name the channel. Typing there would show attendance the agent turned off, so the helper returns no channel for a tuned-out one.
  • Silent wakes stay private. Since the silent heartbeat change, suppressProse turns also run as 'disabled'. They still show no indicator, even when the request names a channel.

One change outside disabled mode: ChannelRegistry.enterTuneOut now stops a typing indicator already running on that channel (after writing the lifecycle record). Before, a turn that was typing there when tune-out began kept its 7 s refresh until the turn ended. A disabled-mode turn does not restart it. An explicit or locus turn can still restart it at stream start or on a retry, since only disabled mode checks tune-out.

Two more changes outside disabled mode, from review:

  • A silent request batched with ordinary ones no longer supplies the turn's channelId or wake provenance; the ordinary requests decide. A silent-only batch is unchanged.
  • A typing stop goes through the same channels.typing grant check as the start, in both stopTyping branches, so a server without the grant gets no unpaired stop.

Explicit and locus modes otherwise pick their typing channel as before. Explicit mode could use the same wakeChannelId fallback and tune-out check, but I kept this PR to disabled mode.

Changelog fragment: changelog.d/feat-disabled-mode-typing.changed.md.

Tests

New test/disabled-prose-typing.test.ts, 12 tests, and one in test/channel-registry-typing.test.ts:

  • with a recording channel registry: typing on the trigger channel; on a gate wake's wakeChannelId; none for a silent wake; none for a wake that names no channel; a silent request in a mixed batch does not pick the channel; a gate wake types only where the channel reaches this agent (fork home / fork-bound); explicit mode unchanged;

  • through the real ChannelRegistry, EventGate (100 ms debounce) and TuneOutCoordinator: a debounced ambient wake types and then stops; a tuned-out channel shows no typing when its traffic still reaches the gate; none for an ambient or an addressed push that names a tuned-out channel; tune-out entered mid-turn, before the stream starts, stops the running indicator and its refresh; none for a silent wake that names channels. Each of these asserts that the turn actually ran.

  • registry: a server without channels.typing receives no stop from either stopTyping branch.

The trigger-channel and gate-wake tests fail on main. With any of the later fixes removed, its test fails.

Full suite after merging upstream 06303d8 (575f314): 2241 tests, 2236 pass, 0 fail, 4 skipped, 1 cancelled. The cancelled file is mcpl-runtime-lifecycle.test.js: its tests pass, but the file times out the same way on main. npm run typecheck is clean. The merges kept the silent-batch filter inside #200's wake-merge block and the disabled-mode branch beside #333's turnLocus at turn start; removing the filter still fails its test.

Reviewed before opening in three rounds: GPT-6.1 Sol (xhigh) found the gate-wake tune-out leak (second commit); GPT-6 Astra (xhigh) found the ambient-push path and the mid-turn refresh (third commit); Claude Fable 5.1 (high) approved with follow-ups: changelog scope, a durable-first reorder in enterTuneOut, and a test name (fourth commit). Greptile's first pass found the fork, mixed-batch and stop-grant cases (fifth commit). Each code fix has a regression test.

Not covered

🤖 Generated with Claude Code

https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8

theaspirational and others added 4 commits October 5, 2026 21:43
An agent with proseRouting 'disabled' answers only through send tools, and
its turns showed no typing indicator, so it looked idle while it worked.
Presence now works as in explicit mode: typing shows on the channel the
trigger came from, and a batched gate wake, which names no channelId,
uses its wakeChannelId. Silent wakes (suppressProse), which also run as
disabled, still show none. Explicit and locus modes are unchanged.

Both typing sites (turn start and driveStream) pick the same channel.
test/disabled-prose-typing.test.ts covers trigger channel, gate wake,
silent wake, no channel, and explicit mode unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8
With a debounce policy, a tuned-out channel's ambient traffic reaches the
EventGate before tune-out diverts it, and the batched wake keeps that
channel as wakeChannelId. Disabled-mode typing then showed attendance in a
channel the agent had turned off. disabledTypingChannel now skips a
tuned-out wakeChannelId; both typing sites use it. Found in review
(GPT-6.1 Sol, xhigh).

Tests through the real ChannelRegistry, EventGate and TuneOutCoordinator:
a debounced ambient wake types and stops; a tuned-out channel shows none
(fails without the check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8
…id-turn

Round-2 review findings on the disabled-mode typing change:

- trigger.channelId does not mean addressed: ambient pushes and coalesced
  push wakes set it too, and tune-out diverts even addressed traffic away
  from the resident. disabledTypingChannel now applies the tune-out check
  to whichever channel it picks (channelId, else wakeChannelId).
- A typing run started before tune-out kept its 7s refresh until the turn
  ended. ChannelRegistry.enterTuneOut now stops typing on that channel
  (all prose-routing modes).

Tests through the real registry, gate and tune-out coordinator: valid
100 ms debounce with a turn-ran assertion, ambient and addressed pushes
into a tuned-out channel, tune-out entered mid-turn, and a silent wake
that names channels. Each fix fails its test when removed. Found in
review (GPT-6 Astra, xhigh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8
- enterTuneOut writes the lifecycle record before stopping typing
  (durable record first; no behavior change).
- Changelog: explicit and locus turns can still restart typing on a
  tuned-out channel at stream start or on a retry; only disabled mode
  checks tune-out. Comment and wording no longer claim that tune-out
  diverts pushes.
- The mid-turn test says what it covers: tune-out before stream start.

Found in review (Claude Fable 5.1, high).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8
@greptile-apps

greptile-apps Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5 Tier: apex

[Medium risk] Adds typing indicator support to disabled prose routing mode.

The PR does not appear safe to merge until wrong-channel presence, concurrent typing teardown, and the ungranted stop notification are addressed.

Findings

  1. P1 Typing appears in another channel ▶
  2. P1 Silent channel shows typing ▶
  3. P1 Tune-out sends ungranted typing ▶
  4. P2 Duplicate channel IDs confuse typing ▶
Fix with agent prompt
### Issue 1
src/framework.ts:8882
A debounced channel wake is sent to every agent, including conversation forks bound to other channels. If a disabled-mode fork bound to B receives only a gate wake from A, this fallback makes it type in A even though A’s message was not delivered to that fork.

### Issue 2
src/framework.ts:8881-8882
When a silent wake that names a channel is batched with an ordinary channel-less wake, the scheduler clears `suppressProse` but keeps the silent wake’s channel. This helper then starts typing there, exposing presence for a channel-bearing wake that was meant to stay private.

### Issue 3
src/mcpl/channel-registry.ts:1877
`startTyping` installs a timer even when the server lacks the `channels.typing` grant and the initial notification is skipped. If tune-out then stops that timer, `stopTyping` sends a stop notification without checking the grant, so the server receives an unpaired typing notification it was not authorized to receive.

### Issue 4
src/framework.ts:8884-8885
If two servers register the same channel ID, this lookup checks tune-out state on the first server rather than necessarily on the server that supplied the wake. A wake from the second server’s tuned-out channel can therefore start typing on the first server’s channel, giving a misleading presence signal.

---

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

Summary

This PR adds typing presence to disabled-prose turns, using trigger or batched-wake channel provenance, and stops existing typing when a channel enters tune-out.

  • The new presence path needs correction for broadcast gate wakes, mixed silent wakes, concurrent agents, and duplicate channel IDs.
  • The tune-out stop path also needs to respect the typing capability grant.

Reviews (1) · Last reviewed commit: "fix(typing): round-3 follow-ups: changel..."

Comment thread src/framework.ts Outdated
*/
private disabledTypingChannel(trigger: InferenceRequest | undefined): string | null {
if (!trigger || trigger.suppressProse) return null;
const channelId = trigger.channelId ?? trigger.wakeChannelId;

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 Typing appears in another channel A debounced channel wake is sent to every agent, including conversation forks bound to other channels. If a disabled-mode fork bound to B receives only a gate wake from A, this fallback makes it type in A even though A’s message was not delivered to that fork.

Knowledge Base Used: Event gate pipeline

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8882

Comment:
**Typing appears in another channel** A debounced channel wake is sent to every agent, including conversation forks bound to other channels. If a disabled-mode fork bound to B receives only a gate wake from A, this fallback makes it type in A even though A’s message was not delivered to that fork.

**Knowledge Base Used:** [Event gate pipeline](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/event-gates.md)

---

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.

Valid. Fixed in 7166b0b: disabledTypingChannel uses the wakeChannelId fallback only where that channel's messages reach the agent. A conversation fork types only on its home channel, and no other agent types on a fork-bound channel. trigger.channelId is unchanged (it comes from a delivery to this agent). Test: "a gate wake types only where the channel's messages reach this agent"; it fails without the check.

Comment thread src/framework.ts Outdated
Comment on lines +8881 to +8882
if (!trigger || trigger.suppressProse) return null;
const channelId = trigger.channelId ?? trigger.wakeChannelId;

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 Silent channel shows typing When a silent wake that names a channel is batched with an ordinary channel-less wake, the scheduler clears suppressProse but keeps the silent wake’s channel. This helper then starts typing there, exposing presence for a channel-bearing wake that was meant to stay private.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8881-8882

Comment:
**Silent channel shows typing** When a silent wake that names a channel is batched with an ordinary channel-less wake, the scheduler clears `suppressProse` but keeps the silent wake’s channel. This helper then starts typing there, exposing presence for a channel-bearing wake that was meant to stay private.

---

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.

Valid. Fixed in 7166b0b at the batching step rather than in the helper: when a batch mixes silent and ordinary requests, silent requests no longer supply the turn's channelId or wake provenance, so the ordinary requests decide. A silent-only batch is unchanged. This also applies to locus and explicit modes, where the silent request's channel could otherwise become the turn's routing channel. Test: "a silent request batched with ordinary ones does not pick the turn's channel"; it fails without the change.

});
// A tuned-out channel shows no attendance: stop a typing run already
// going there, or its 7s refresh would continue until the turn ends.
this.stopTyping(channelId);

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 Tune-out sends ungranted typing startTyping installs a timer even when the server lacks the channels.typing grant and the initial notification is skipped. If tune-out then stops that timer, stopTyping sends a stop notification without checking the grant, so the server receives an unpaired typing notification it was not authorized to receive.

Knowledge Base Used: MCPL transport and server registration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 1877

Comment:
**Tune-out sends ungranted typing** `startTyping` installs a timer even when the server lacks the `channels.typing` grant and the initial notification is skipped. If tune-out then stops that timer, `stopTyping` sends a stop notification without checking the grant, so the server receives an unpaired typing notification it was not authorized to receive.

**Knowledge Base Used:** [MCPL transport and server registration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-transport-and-registration.md)

---

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.

Valid, and it applied to the existing turn-end stop too. Fixed in 7166b0b at the shared point: both stopTyping branches now send through sendTypingStop, which applies the same channels.typing grant check as the start. Test in channel-registry-typing.test.ts: an ungranted server receives neither a start nor a stop from either branch; it fails without the check.

Comment thread src/framework.ts
Comment on lines +8884 to +8885
const serverId = this.channelRegistry?.getChannelServerId(channelId);
if (serverId && this.channelRegistry?.getTuneOutState(serverId, channelId)) return null;

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 Duplicate channel IDs confuse typing If two servers register the same channel ID, this lookup checks tune-out state on the first server rather than necessarily on the server that supplied the wake. A wake from the second server’s tuned-out channel can therefore start typing on the first server’s channel, giving a misleading presence signal.

Knowledge Base Used: MCPL transport and server registration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8884-8885

Comment:
**Duplicate channel IDs confuse typing** If two servers register the same channel ID, this lookup checks tune-out state on the first server rather than necessarily on the server that supplied the wake. A wake from the second server’s tuned-out channel can therefore start typing on the first server’s channel, giving a misleading presence signal.

**Knowledge Base Used:** [MCPL transport and server registration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-transport-and-registration.md)

---

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.

Valid, but this predates the PR, and fixing only the check would make it disagree with the send: startTyping resolves the channel through the same first-match findChannelEntry, so with duplicate ids the indicator already went to the first server before this change. As it stands, the tune-out check and the send always use the same server. Listed under "Not covered" in the description; resolving the owning server from the trigger's source would need to change both the check and startTyping.

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 a fair distinction. Since disabledTypingChannel and startTyping both resolve duplicate IDs through the same first-match lookup, this PR does not introduce a new mismatch between the tune-out check and the indicator destination. Correct ownership would require changing both paths and knowing the wake’s source server, so this is appropriately documented as pre-existing scope rather than an actionable finding here. I’m withdrawing the comment.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@greptile-apps

greptile-apps Bot commented Oct 5, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 One turn stops another’s typing src/framework.ts:9349 ▶

    Disabled-mode turns now start typing, but each turn’s cleanup still calls the registry-wide stopTyping(). If two agents are working in different channels, the first to finish clears the other agent’s indicator and refresh timer while that agent is still working.

…ches, ungranted stop

- disabledTypingChannel: with conversation routing the gate wakes every
  agent, but a channel's messages reach only the fork bound to it. The
  wakeChannelId fallback now applies only where the channel reaches the
  agent: a fork on its home channel, no other agent on a fork-bound one.
- A silent request batched with ordinary requests no longer supplies the
  turn's channelId or wake provenance (all prose modes); a silent-only
  batch is unchanged.
- ChannelRegistry: typing stops go through sendTypingStop, which applies
  the same channels.typing grant check as the start, in both stopTyping
  branches.

Each fix has a test that fails without it. Full suite 1286 tests, 1281
pass, 0 fail (mcpl-runtime-lifecycle file timeout as on main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8
@theaspirational

Copy link
Copy Markdown
Contributor Author

Greptile review, first pass:

  • Fixed in 7166b0b, each with a regression test that fails without the fix:
    • fork-bound gate wake typing on another agent's channel (disabledTypingChannel checks that the channel reaches this agent);
    • a silent request in a mixed batch picking the turn's channel (silent requests no longer supply channelId or wake provenance when ordinary ones are present);
    • an unpaired stop sent to a server without channels.typing (both stopTyping branches go through the grant check).
  • Not changed: duplicate channel ids across servers. startTyping already uses the same first-match lookup, so the tune-out check and the send agree. Listed under "Not covered".
  • Outside the diff: the registry-wide stopTyping() at turn end clearing another agent's indicator predates this PR and is tracked in Turn end calls stopTyping() with no channel, stopping every typing indicator in the process #224.

Full suite: 1286 tests, 1281 pass, 0 fail, 4 skipped, 1 cancelled (mcpl-runtime-lifecycle.test.js, which times out the same way on main). Description and changelog fragment updated.

@rufalupa666-netizen

Copy link
Copy Markdown

Verification receipt for 7166b0b, compared with main 02adf27.

Environment: isolated /tmp clones on AF 0.21.0, a disabled-mode resident with a registered Discord-shaped channel that advertises typing; production resident and live Discord untouched.

Ran a channel-triggered turn containing a tool-call round. PR head began typing on the triggering channel before inference, kept it through the tool continuation, and stopped it at terminal teardown; automatic prose stayed suppressed. Main started no typing. The real ChannelRegistry path emitted paired start/stop and cleared its refresh interval. Focused suites 19/19.

No typing started for channel-less wakes, silent heartbeat wakes, fork-bound channels owned by another agent, or tuned-out channels; entering tune-out mid-turn stopped an active indicator. No cross-channel leak: the PR does not fall back to the latest/default open channel.

One question for the author rather than a finding: in disabled mode the triggering-channel indicator is visible to humans even though the reply will not auto-publish there. Is "typing… then nothing arrives" the intended experience, or should disabled mode only type when the turn has an explicit send in flight?

Conflict in src/framework.ts: anima-research#200 chooses the turn's channel by event time
(eventAt) and moved silentOnly below the loops. The silent-batch filter
(routable) now sits next to eventAt, above both loops that read it; the
duplicate silentOnly is dropped, batchOnly kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8
@theaspirational

Copy link
Copy Markdown
Contributor Author

Thanks for the independent run; the clone-against-main comparison is exactly the check this needed.

On the question: intended, and the reason is parity with explicit mode. Explicit mode already types on the trigger channel at turn start, whether or not the agent ends up sending there; a turn that ends in skip_reply resolves the indicator at teardown. Typing means "attending", not "a message is coming", and a disabled-mode resident that was addressed is attending. Before this PR it looked idle for the whole turn, which read as "not received".

Typing only while a send is in flight can't give an indicator in this architecture: the send tool call is the last act of the turn, so the indicator would start after the work is done, and a turn that sends twice would flicker. The stream would have to know its outcome before it starts.

What is open, and I'd keep outside this PR: stopping the indicator on the trigger channel once the turn has sent there explicitly, instead of at teardown. That trims the tail you describe ("typing… then nothing" after a reply already landed), but it needs a rule for a second send to the same channel, so it is its own change.

Also: merged upstream 02adf27 into the branch (37fc06a). #200 rewrote the wake-merge block, so the silent-batch filter moved next to its new eventAt; behaviour unchanged, the mixed-batch test and batched-wake-locus both pass.

Conflict in src/framework.ts at the turn-start typing channel: anima-research#333 replaced
turnLocusPins.get() with the turn's resolved turnLocus. Kept the disabled-mode
branch (disabledTypingChannel) and took turnLocus for locus mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants