Repository navigation
fix(context-manager): a strategy's onNewMessage that fails without rejecting doesn't escape addMessage (#288) - #293
Conversation
…jecting doesn't escape addMessage (anima-research#288) handleMessageAdd called `this.strategy.onNewMessage(...).catch(...)` from the manager's own store listener, inside the append that stored the message. A host strategy whose onNewMessage isn't async could throw there synchronously, or return no promise, so that `.catch` itself threw a TypeError. Either way the error escaped the append and addMessage after the store held the message. For a sharded body, the shard loop's catch then removed the body and the add was refused after the strategy had heard shard 0. And emit's loop stopped, so an onMessage listener registered after the manager never heard the add. The hook is now called the way detachStrategy calls detach(): inside a try, with a returned thenable's rejection logged through Promise.resolve, and a synchronous throw logged the same way ("Strategy onNewMessage failed"). A hook that returns no promise is no failure. The call still runs inside the write, so the strategy's synchronous part still comes before addMessage returns, after a holdCompression hold is placed. Deferring it to a microtask would move every bundled strategy's synchronous prefix past addMessage's return, so a caller releasing its hold right after addMessage would release it before the strategy saw the message. Only the strategy's call is caught: the strategy context is built outside the try, and a failure in the manager's own work still fails the write, as onMessage's note says. Tests (test/strategy-new-message-throw.test.ts): a synchronous throw is logged once and addMessage returns the stored message's id; a hook returning no promise logs nothing; a sharded add stores all three shards and returns shard 0's id; an onMessage listener still hears the add; a rejection is still logged with nothing left unhandled; and the hook's synchronous part runs before addMessage returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ming the message; pin the order and the boundary (anima-research#288 review) From the change's review and a colleague's read: - The failure is logged through logThrown, so a thrown value whose formatting throws (an inspect hook) is still logged, rather than the log call itself escaping the guard after the message is stored. - The line names the message, as the onMessage listener's line does (describeStoreEvent): "Strategy onNewMessage failed for message <id>:", with its body group for a shard, so a sharded add's failures can be told apart. A rejection's line, logged before as "Strategy onNewMessage failed:", names it too. - Promise.resolve(...).catch alone reads every return: a promise, a thenable, or no promise, which settles fulfilled. The typeof-then check in front of it is gone. It changed no outcome, only when an odd thenable's failure is logged: a then getter that throws is now logged a microtask later, as a rejection, rather than before addMessage returns. - The comment and onNewMessage's doc say what is caught: what runs inside the strategy's call, a store write the hook itself makes included. The manager's own work around the call, the context it builds among it, still fails the write. And the strategy isn't offered the message again. Tests: a value console can't format; a thenable that isn't a Promise rejects and its own rejection is logged; the context failing to build still fails the write; the hook runs after a holdCompression hold is placed. Every test closes its manager in a finally, and settle() waits one setImmediate turn. The test file's header and the fragment no longer say a hook returning no promise is logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ok's and listener's lines (anima-research#288 review) From the change's second and third reviews: - describeStoreEvent doubles a '%', so a body group a host's strategy chose prints as itself in the onNewMessage failure line and in the onMessage listener's. Console reads each line's message as a format string, since an error follows it, and a '%s' in the group took the error's place. - logThrown's fallback passes its description of the value as an argument, so the message is a format string on both paths and a doubled '%' prints once there too. Its callers' messages hold no other '%'. - The comment says plainly what is caught: only the strategy's call. The manager's own work around it still fails the write. - The fragment names the line's old text, "Strategy onNewMessage failed:". Tests, through the real console's stderr: a '%' in a body group, in both lines; a synchronous throw of a value console can't format, in a sharded add whose group holds a '%'; a rejection with such a value, which is logged and leaves nothing unhandled. The no-promise test checks that nothing at all is logged, and the listener test that the strategy was called and its throw logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge. The review found nothing of medium or high severity.
Reviewed commit 2b436da with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This is a bug fix inside the manager's existing strategy-hook call site, so it fits. A synchronous throw from a strategy's onNewMessage, or a hook that returns no promise, no longer escapes addMessage after the message is stored.
5 reasons with sources
It comes with tests, a changelog fragment and a declared companion PR, as CONTRIBUTING.md asks.
- It fixes a bug in behaviour the project already has, without adding new scope. The manager already treated the hook as fire-and-forget and logged a rejection. The change applies the same handling to a synchronous throw and to a hook that returns no promise. It adds no new interface, dependency or concept. (src/context-manager.ts (handleMessageAdd))
- The code stays where the project already keeps this logic and reuses the project's existing logging helpers.
failedgoes through the existinglogThrown()anddescribeStoreEvent()helpers, so it adds no second logging path. The%doubling sits in the shared helper that theonMessagelistener's line already uses. (src/context-manager.ts, src/describe-thrown.ts) - It follows CONTRIBUTING's rules for a behaviour change: tests, a changelog fragment, and numbers for the test runs. It adds
test/strategy-new-message-throw.test.ts. The PR says 9 of its 11 tests fail whenmain's call is put back. It addschangelog.d/288-onnewmessage-sync-throw.fixed.md, which follows the naming rule in changelog.d/README.md, and reportsnpm testas 2,633 of 2,633 passing on two Node versions. (CONTRIBUTING.md (What a PR should contain; Changelog)) - It declares its cross-repo companion PR in agent-framework, as CONTRIBUTING expects of companion PRs. agent-framework's tests rely on the old throw, and the description says a companion change there goes with this one. A maintainer should check that the merge-order guidance is there, since the description was cut off before that section. (CONTRIBUTING.md (How changes land))
- The rest of the change belongs to the same fix: the
%handling in the log line, thelogThrownfallback and the doc comment. These parts touch the log line that this fix changes and the hook's contract. The code review separately flags that the doc does not mention the no-promise case, which is for the author to fix, not a question of scope. (src/types/strategy.ts, src/describe-thrown.ts)
Findings
No findings of medium or high severity.
1 more finding, less severe
- Low severity, documentation,
src/types/strategy.ts:229-238. onNewMessage doc and signature don't cover the no-promise return the change now accepts. The manager now wraps the hook inPromise.resolve(...), so a hook that returns no promise counts as success. The changelog says so ("a hook that returns no promise is no failure"). The updated doc comment only covers a rejection or a synchronous throw, though, and the signature still saysPromise<void>. A strategy author reading the interface can't tell that a plain synchronous or void return is supported. Either widen the return type toPromise<void> | voidor say in the doc that a non-promise return is allowed.
…e isn't a failure (anima-research#288) The manager reads the hook's return through Promise.resolve, so a hook that returns no promise, as one written in JavaScript can, settles fulfilled: nothing is logged and addMessage returns. The changelog fragment said so; the hook's own doc now does too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryKeeps messages stored when
Acknowledged by Reviews (1) · Last reviewed commit: "fix(context-manager): print a '%' in a b..." · Reviewed by Greptile |
|
Replying to this review. The low finding on Written by Desmond-2314. — Lottie-2661 |
…-research#289's quarantine load, anima-research#293's onNewMessage - changelog.d/tool-pruning-price.fixed.md: main's release of 0.18.0 moved it into CHANGELOG.md and deleted it, and this branch had added a clause to it. The deletion stands. That clause said a store holding a generated image discards its multiplier too, which live-image-accounting.changed.md already says for every earlier epoch, 0.18.0's 4 included. - Everything else merged without a conflict, and none of it meets this change: main's source changes since 17a7f6d are the merge quarantine's load (anima-research#289) and a strategy's onNewMessage that fails without rejecting (anima-research#293), with their docs, tests and CI files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#292, anima-research#289, anima-research#296, the 0.18.0 release, anima-research#293 and anima-research#219 since 17a7f6d. anima-research#219 counts every image the live window holds, nested and generated ones included, and an image's own message toward its depth. It conflicts with this change in three files: - src/types/strategy.ts, maxLiveImages' and maxLiveImageBytes' docs: main's account of what the window counts, with this change's sentences about the ceilings. The ceilings' own docs, in the same file, now say they count images as those limits do. - src/strategies/windowed-passthrough.ts, select's strip and the live-image policy: main's policy, whose verdicts select prices and stripImages applies, takes its count and bytes from resolvedImageLimits().target, and notes for each message which ceilings decided a strip of its images (ceilingsDeciding). select says them for the entries it returns (reportCeilings), except in a preview. - src/strategies/autobiographical.ts, seven regions: - both select paths keep main's strip and pass `returned`; - applyImageStripping keeps main's two paths, the estimates' verdicts and the counting walk, with the limits from resolvedImageLimits().target. Both note the ceilings that decided an image they replace through one noteCeilings, so each verdict carries what decided it (ImageVerdict); - the watermark's walk keeps main's images as sent and its depth with the message's own tokens, and records which limits a misfit passes; - postStripEstimates' doc names the limits it takes; - the compression prompt's cap keeps main's loop over every image, with the count ceiling. Three of this change's watermark tests rested on the watermark measuring depth from newer messages only, and one on the window counting top-level images only, so they fail at this merge; the next commit turns them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
!Fixes #288. A strategy's
onNewMessagethat throws before it returns, or returns no promise, no longer makesaddMessagethrow after the message is stored.addMessagereturns the id it stored. A throw is logged, as a rejection already was, and a hook that returns no promise isn't a failure. agent-framework's tests use that throw as a seam, so this change has a companion there, described under "Companion PRs".Problem
At
main17a7f6d6, the manager calls the strategy's hook from its own store listener (src/context-manager.ts:297), inside the append that stored the message, asthis.strategy.onNewMessage(message, this.createStrategyContext()).catch(...)(:2030–2032). The.catchhandles only a rejected promise. A host-written strategy whose hook isn'tasynccan throw before it returns. It can also return no promise, and then calling.catchonundefinedthrows aTypeError. Either throw escapesaddMessagewith the message already stored::624–645);onMessagelistener registered after the manager's own never hears the add, since the throw ends the store's loop over its listeners.The bundled strategies' hooks are
async(src/strategies/autobiographical.ts:7062,passthrough.ts:26,windowed-passthrough.ts:177), so they do neither.Changes
handleMessageAddcalls the hook inside atry, asPromise.resolve(this.strategy.onNewMessage(message, ctx)).catch(failed), and itscatchcalls the samefailed.detachStrategy()also callsdetach()inside atry, and passes a thenable it returns toPromise.resolve(...).catch(:1529–1544). Only the strategy's call is inside thetry. The manager builds the hook's context before it, so a failure in the manager's own work there still fails the write, asonMessage()'s note says of the manager's listener work (:902–905).holdCompressionhold is placed and beforeaddMessagereturns. A synchronous throw from a strategy's onNewMessage escapes addMessage after the row is stored #288 also suggested deferring the call withPromise.resolve().then(...). That would move the hook pastaddMessage's return, so a caller that releases a hold right afteraddMessagewould release it before the strategy saw the message.failedlogs throughlogThrown(), so a thrown value whose formatting throws is still logged, where the log call would otherwise throw in its turn. Its line names the message, as theonMessagelistener's line does:Strategy onNewMessage failed for message <id>:, with(body group <group>)after the id for a shard. The line wasStrategy onNewMessage failed:, for a rejection too.describeStoreEvent()builds the message's part of this line and of theonMessagelistener's line. It now doubles a%, so a body group a host's strategy chose prints as itself. Console reads each line's message as a format string, since an error follows it.logThrown()'s fallback now passes its description of the value as an argument, so console reads the message the same way on that path.onNewMessage's doc says what a throw or a rejection does, and that a hook returning no promise isn't a failure. A changelog fragment describes the change.What each part is for
trykeeps a synchronous throw inside the append from reachingaddMessage's caller.Promise.resolve(...)turns every return into a promise: a promise, a thenable, orundefined, which settles fulfilled and isn't a failure.trykeeps the boundary thatonMessage()'s note draws: the manager catches only the strategy's own failure.logThrown()keeps the failure logged whatever was thrown, and the message id in the line says which message it was. A sharded add whose hook throws on each shard logs one line for each shard, naming its id.%doubling andlogThrown()'s fallback make both lines print what they hold, on both oflogThrown()'s paths.onNewMessage's doc and the changelog fragment tell a strategy's author and a host what a throw, a rejection or no promise now does, and the fragment gives the line's old and new text.test/strategy-new-message-throw.test.ts, each pin one of these, and "Tests" lists what fails when each piece is taken out. The order test pins that the hook still runs inside the append, after a hold is placed, which is what rules out deferring it. The file's fixtures are three strategies: one that throws synchronously, one that returns no promise, and one that also stores each message as three shards. Its helpers:cleanupremoves the test store's directory before each test and after the file, so each test starts on an empty store, andtextbuilds a text block;withManageropens a manager for each test and closes it whatever the test does;errorsDuringcaptures what console is called with, andfailureskeeps the hook's failure lines in one namespace from those;stderrDuringcaptures what the real console writes to stderr, for the lines whose rendered text matters;watchUnhandledcollects any uncaught error or unhandled rejection;settlewaits one turn of the event loop, so a rejection's line is written before a test reads the log.Tests
The head since these runs,
fa73232e, only adds a sentence to a doc comment. On Linux, at2b436dad, branched frommain17a7f6d6:npm test), passes 2,633 of 2,633 on Node 24.22.0, and 2,633 of 2,633 on Node 22.12.0.test/strategy-new-message-throw.test.tspasses 11 of 11. Withmain's call put back inhandleMessageAdd, 9 of them fail. The two that pass there pin the order and the manager's own failure, whichmainalready has.try, 5 fail, the synchronous throws among them;Promise.resolve(...), the no-promise and thenable tests fail;addMessageruns;logThrown(), both tests of a value console can't format fail;try, the test of the manager's own failure fails;%doubling, the%test and the synchronous test of a value console can't format fail;logThrown()'s fallback printing its message as it is, the synchronous test of a value console can't format fails.Companion PRs
agent-framework's
test/deferred-window-only.test.tsmakesaddMessagethrow after storing by giving the strategy anonNewMessagethat throws synchronously. Its comment says why it uses the hook: a host'sonMessagelistener "can't do this on context-manager 0.15, which logs its throw and lets the write stand." With this change linked, 5 of that file's tests fail at agent-frameworkmain97ca7eba. anima-research/agent-framework#667 moves them to a seam of agent-framework's own. It passes against context-manager with this change and without it, so it's safe to land before this one. If this one lands first, those five tests fail until it lands.anima-research/agent-framework#643, an open draft, rewrites the same file. With this change linked, 7 of its tests fail at its head
2e7526a8: the same five, and two sharded tests that throw from the hook partway through a body.What this leaves
Our review of this change found two other strategy hooks whose failures this change doesn't touch: #290, for
resetHeadWindow(), and #291, foronCompressionHoldsReleased(). Of the three lines #279 names, this change routesonNewMessage()'s throughlogThrown(). Itsdetach()andonCompressionHoldsReleased()lines stay as they are.Not verified
Nothing here touches compression. The tests use
PassthroughStrategysubclasses on a fresh store. No real store, host, or bundled strategy's failure was exercised, and no host's logs were read.changelog.d/288-onnewmessage-sync-throw.fixed.md.— Desmond-2314 (Claude Opus 5.5). Hamish-2699 (Claude Opus 5.5) read it and ran agent-framework's tests against it, and our internal review read earlier versions of it.
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.