Skip to content

fix(routing): after a re-pin, the token traces and typing follow the turn's locus - #565

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/repin-trace-locus
Oct 19, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/repin-trace-locus

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Problem

An addressed message mid-turn, a follow-up in a channel the turn engaged, or the agent's own channel_open can move a locus or hybrid turn's locus between rounds (repinTurnLocus), and the next round's prose is delivered to the new channel. driveStream computed the channel it shows once, when the stream started, and never read the locus again. So for the rest of the stream:

  • the inference:tokens and inference:content_block traces named the old channel. That's what After a mid-turn re-pin, token and content-block traces keep naming the old channel #555 reports: trace consumers that tag streamed text by channelId put the re-pinned round in the wrong conversation;
  • the typing indicator stayed on the old channel until the stream ended;
  • the turn-start comment still said the locus is frozen and that mid-turn injections must not redirect the agent's prose, which the re-pin made untrue.

Fixes #555.

Changes

  • The typing indicator follows the turn's locus. driveStream keeps the channel it shows as a variable. At the model's content in each round (its tokens and block events, not the tool results a boundary reports as content), and at a refusal retry, followTurnLocus checks it against the turn's locus. When the locus has moved, in locus or hybrid mode, it stops typing on the old channel and starts it on the new one. The locus moves only between rounds, so this happens at the round after a move. A response with nothing in it moves nothing.
  • Across the turn's streams. A stream that hands its turn to a successor (a budget restart, a guard retry) leaves its indicator running. TurnRouting now records the channel the turn's indicator was last shown in, and a start, or a move, that shows it elsewhere stops that one first (showTurnTyping). Before, a re-pin at such a boundary left the old channel's indicator running beside the new one until the turn ended.
  • The traces name the same channel, read as each is emitted, as on main. So from the round after a move they name the new locus. In hybrid mode they name the locus whether or not a >>> envelope sends the text elsewhere: both the traces and the indicator name the conversation the turn is attending.
  • explicit mode is unchanged: its typing and traces show the trigger channel.
  • Text: src/types/trace.ts's two channelId docs say what the field now names. The turn-start comment says what moves the locus, and the typing comment says which exits stop the indicator.

What hosts and servers newly see, after a locus moves mid-stream in locus or hybrid mode:

  • the next round's inference:tokens and inference:content_block traces carry the new channel;
  • the typing indicator stops on the old channel and starts on the new one, at that round's first content or its refusal retry, or at the start of the stream that continues the turn after a budget restart or a guard retry.

A turn whose locus doesn't move, and an explicit turn, see no change.

What each part is for

  • One value for the indicator and the traces, as on main, where the traces named "the channel this stream shows typing in". Both now say which conversation the turn is attending. Where a hybrid round's text was published is delivery's to say, and the traces don't claim it.
  • Moving at the model's content, not at every event. A round's usage or terminal event arrives after a channel_open too, and, by our reading of membrane 0.5.87, so do the tool results it reports as content in XML tool mode, or for a result with an image. Those belong to the round before, and would otherwise start typing in the channel just opened for a final response with nothing in it.
  • Moving at a refusal retry too. The retry's trace, an empty inference:tokens, comes before the retried round's first content, so it names the round's channel only if the move happens there.
  • A record on the turn, not only on the stream, because a stream that hands the turn on doesn't stop its indicator, and the stream that continues the turn can't otherwise know where it showed.

Tests

CI runs the typecheck, the build and the whole suite on Node 20 and 24. The runs below were on Linux with Node 24.22.0, on the tree of e1f83494. Since then the branch has changed only src/types/trace.ts's doc comments and the second changelog fragment, and merged main 15aadc96. At that merge the typecheck passed again, and so did present-while-acting (69/69, with main's three new tests), explicit-prose-routing, channel-lifecycle-order, channel-registry-routing, trunk-channel-routing and tool-result-guard. CI doesn't run the red-first or the removals.

CI at 126f0dbd: run 39235670668, on GitHub's merge of this head with main 15aadc96, passed its four Build & Test legs and the packed install, and the Changelog check (run 39235670519) passed. The Build & Test legs ran 4,830 tests on Ubuntu and 4,832 on macOS, 27 of them skipped, which is 10 more than main's own run at 15aadc96 (39235309716: 4,820 and 4,822): the ten tests this change adds.

  • The changed and nearest files pass, and the typecheck is clean: present-while-acting 66/66, explicit-prose-routing 89/89, tool-result-guard 146/146, tool-wrapper-prose-guard-integration 18/18, channel-registry-routing 24/24, trunk-channel-routing 23/23, batched-wake-locus 5/5, silent-heartbeat 7/7, prose-stream-router 23/23.
  • New tests:
    • test/present-while-acting.test.ts, on its re-pin rig, where someone addresses the agent from a DM during round 1's tool and round 2 answers:
      • in locus and in hybrid mode, round 2's traces and typing move to the DM;
      • in hybrid mode while a >>> destination holds, round 2 is delivered to the destination, and its traces and typing move to the DM;
      • a stream with no block events still moves them;
      • after a refusal that comes before the retried round's first content, the retry's trace names the DM;
      • a budget restart that re-pins its turn stops the typing its earlier stream left in the old channel, before it shows in the DM;
      • channel_open moving the locus moves the next round's traces and typing (the existing test, extended);
      • an empty final response after a channel_open starts no typing in the opened channel, also when the tool's result is reported as content, as in XML tool mode;
      • a locus turn that starts with nowhere to speak shows typing where its channel_open moves it, and stops nothing else.
    • test/explicit-prose-routing.test.ts: an explicit turn's typing and traces stay on its trigger channel across its rounds.
  • Red-first, with main's src/framework.ts and this branch's tests: the eight new and changed tests that need the change fail. The two empty-final-round tests and the explicit test pass, as their controls.
  • Removals, one piece at a time, each caught:
Piece removed Tests that fail
followTurnLocus acting only in locus and hybrid mode 1 explicit-prose-routing ("a silent turn's resume shows no locus…")
showTurnTyping stopping the channel the turn last showed 6 rig
Starting the new one 9 rig
Recording it on the turn 6 rig
The turn's early indicator showing it through the record 1 rig (the budget restart)
The stream updating the channel it shows 7 rig
Calling followTurnLocus 7 rig
Calling it on tokens as well as blocks 1 rig (no block events)
Calling it at a refusal retry 1 rig (the refusal before content)
Calling it only on the model's content and retries 2 rig (the empty final round, both ways)
Leaving out tool-result tokens 1 rig (the empty final round, results reported as content)
Leaving out tool-result blocks 1 rig (the same)

driveStream's own start of the indicator also goes through the record. By our reading nothing can move the locus between the turn's start and the stream's, so its channel is the turn start's, and with it taken out, present-while-acting, explicit-prose-routing and four of the nearest files still pass.

Not verified

  • No live run against connectome-host or a Discord server. The tests stub the registry and read what it's sent.
  • The arrival re-pin at a turn's start isn't driven through a stream here. It writes the same routing state the check reads, before the stream exists.

Out of scope

Each of these is by our reading of the code.

  • The live outgoing stream (channels/outgoing/chunk and complete) still goes to the channel the stream started in after a re-pin, and in hybrid mode a later stream of the turn starts at the locus whatever >>> destination or hold governs delivery. fix(streaming): the outgoing stream carries only what delivery publishes, and a script's calls count as its round's #360 replaces that stream with one fed by delivery.
  • A trace consumer that keeps the first channel it sees for an activation won't follow the move. connectome-host's TTS relay adopts the first channelId of an activation and keeps it.
  • Typing is per channel, not per agent. When one agent's locus leaves a channel where another agent's turn is typing, that indicator stops too. On main already, a turn's end stops every channel's typing, other agents' included.
  • An error-policy retry's typing. The failed stream's own end stops every channel's typing after its successor has started, so the successor shows none until its locus moves.
  • The built-in DiscordModule's own typing keepalive, under autoTyping, keeps the reply channel it resolved when the inference started.
  • The re-pin and channel_open notices tell a hybrid agent its plain speech now lands at the new locus, even while a >>> destination or hold governs it.

Merge notes


  • Changelog fragments added — changelog.d/555-repin-traces-follow-locus.fixed.md, and changelog.d/555-trace-channel-follows-locus.changed.md for trace consumers

Reported by Anarchid in #555. Written by Milo-2653 (Claude Opus 5.5), contributing through nissa-seru, on design calls by Ada-1017 (Claude Opus 5.5). Petra-1012 (Claude Opus 5.5) read and approved the source; its last two changes are her suggestions.

Checked and opened by Fiona-2757 (Claude Opus 5.5).

🤖 Written with Claude Opus 5.5 through the Rookery's tooling.

…turn's locus

An addressed re-pin at a tool boundary, or the agent's own channel_open,
moves a locus or hybrid turn's locus between rounds, and the next round's
prose is delivered there. driveStream computed the channel it shows once,
when the stream started, so for the rest of the stream the
inference:tokens and inference:content_block traces named the old channel,
and the typing indicator stayed there (anima-research#555). The turn-start comment still
said the locus is frozen.

driveStream now keeps that channel as a variable, and followTurnLocus
compares it with the turn's locus at the model's content in each round (its
tokens and block events, not the tool results a boundary reports as
content) and at a refusal retry. When the locus has moved, in locus or
hybrid mode, it moves the indicator there, and the traces, which read the
same value, name the new channel. A response with nothing in it moves
nothing, so a channel_open just before an empty final response doesn't
start typing in a turn that is ending. Explicit mode keeps its trigger
channel.

A stream that hands its turn to a successor (a budget restart, a guard
retry) leaves its indicator running. TurnRouting now records the channel
the turn's indicator was last shown in, and showTurnTyping, which every
start of the indicator and followTurnLocus go through, stops that one first
when it shows the indicator elsewhere. So a re-pin at such a boundary no
longer leaves the old channel typing until the turn ends.

Both name the conversation the turn is attending: in hybrid mode they
follow the locus whether or not a >>> envelope sends the text elsewhere.
The live outgoing stream is unchanged.

Tests: the re-pin rig's locus and hybrid re-pins, a pinned hybrid turn, a
stream with no block events, a refusal before the retried round's content,
a budget restart that re-pins, channel_open, an empty final response with
and without its tool's result reported as content, and a locus turn with
nowhere to speak at its start; an explicit turn's typing and traces across
its rounds.

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

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge, with no actionable issues found.

Summary

Makes typing and each trace’s channelId follow a moved turn locus in locus and hybrid modes.

  • Typing and trace labels follow a turn when its locus moves.

Known limits acknowledged by nissa-seru and excluded from findings:

  • Outgoing chunks still use the stream’s starting channel; a later hybrid stream also starts at the locus despite a held destination. Deferred to fix(streaming): the outgoing stream carries only what delivery publishes, and a script's calls count as its round's #360.
  • Consumers that keep an activation’s first channel, including the host’s TTS relay, will not follow later moves.
  • Typing is shared per channel, so stopping it can affect another agent.
  • Existing error-policy retry cleanup can stop the successor’s typing.
  • DiscordModule’s autoTyping keepalive retains its original reply channel.
  • Re-pin and channel_open notices can describe the locus as the delivery channel despite a held hybrid destination.

Reviews (1) · Last reviewed commit: "fix(routing): after a re-pin, the token ..." · Reviewed by Greptile

main's new tests in test/present-while-acting.test.ts, for channels a
server declared inbound-only, sit where this branch's tests for typing
after a channel_open do. Both sides' added tests are kept whole, this
branch's first; nothing else conflicted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit f455971 into anima-research:main Oct 19, 2026
6 checks passed
tel-0s added a commit that referenced this pull request Oct 19, 2026
…ge entry for bounded jobs, the batched-wake hold

What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round
speech), #250 (surgery marks opt-in), #463 (the workspace edit planner),
#168 (non-conversational marker tags: a reaction, edit or deletion never
retargets the publish locus, apart from an edit the coalescer marks as the
agent's first view), #558 (every ephemeral agent's usage in one shared
entry), #570 (the batched-wake tests hold the gate's batch), #569, #565,
#566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs.

Conflicts in five files, resolved by composition:

- src/agent.ts: the request strips the framework's token-estimate stamp
  (main) and then presents the agent's own turns under its name (ours).
- src/framework.ts: `ephemeralCandidates` keeps this branch's record
  (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and
  createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only
  (main's second argument is unread) with main's `readsDeferredWrite`
  spread; the re-pin rule's doc is main's with the `speech.locus` sentence;
  the turn-start flush is main's `flushForStart`, which gains this branch's
  `releaseLandedMail` so held mail whose message lands is released.
- src/mcpl/channel-registry.ts: the three Dendrite options
  (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside
  main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but
  records no publish locus — admission does, through `noteDelivered`, and
  the framework's three `noteDelivered` sites now apply main's marker rule
  (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is
  never the latest delivered inbound either; `channel_publish`'s default
  comment says the same.
- test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps
  its shape and gains main's `holdBatches`, reading the rig's framework;
  every test that pairs two sends holds the batch, the two ambient-only
  tests included.
- test/present-while-acting.test.ts: `createFramework` takes `speech`,
  `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests
  (traces and typing after a re-pin, the budget restart, the inbound-only
  arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs
  to, beside this branch's default-rule test.

Three seams the suite found, all at the registry's reply anchor, which main
sets where it accepts a message and this branch sets where admission
delivers one:

- #168's marker rule now lives in `noteDelivered` (tags and the coalescer's
  first-view mark are passed by the framework's three delivery sites), so
  the registry's unit tests exercise it as the framework calls it.
- `buildChannelContext` reported no `incoming` when the speech fallback was
  null, and no context at all when nothing was open and no outgoing channel
  resolved — under `speech.locus: 'delivery'` that hid the reply anchor
  whenever the latest inbound wasn't a speech fallback. The anchor now
  depends only on `takesGlobalFallback` (an ephemeral run has none), and a
  context with an anchor is returned whatever else it lacks.
- A coalesced acceptance's anchor is committed when the acceptance is
  acknowledged, not when the coalescer delivers: an acceptance whose commit
  fails after its delivery moves no anchor, and the retry the receipt
  answers commits the one the first attempt parked (`parkedAnchors`, keyed
  by server and event id, bounded). #157's tests pinned both halves.

Test adaptations beyond the conflicts: #157's two re-pin tests and
outgoing-stream's re-pin test run under the legacy locus rule, as the others
do; two compiled tests main deleted (`mcpl-awareness-barrier`,
`prose-stream-router`) were still in `dist/test` and failing from there —
removed, since tsc doesn't clean outputs whose sources are gone.

Two lines git doesn't show, named by nissa-seru's review of #546:

- #558 reads `config.subconscious.name` for the shared usage entry's
  exemptions; a forks reader has no agent of its own, so the name is read
  only when `reader !== 'forks'` (the union has none otherwise).
- resumeAgent makes its agent with `new Agent`, so it adds the agent to
  `ephemeralAgents` itself: a resumed job's usage counts in the shared
  entry, not under its own name.

Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

After a mid-turn re-pin, token and content-block traces keep naming the old channel

1 participant