Skip to content

fix(agent): protocol-shape, image-delivery, quota-status and residue defects on both compatibility surfaces - #167

Closed
maxff77 wants to merge 51 commits into
Rfym21:mainfrom
maxff77:fix/agentic-parity
Closed

fix(agent): protocol-shape, image-delivery, quota-status and residue defects on both compatibility surfaces#167
maxff77 wants to merge 51 commits into
Rfym21:mainfrom
maxff77:fix/agentic-parity

Conversation

@maxff77

@maxff77 maxff77 commented Sep 10, 2026

Copy link
Copy Markdown

Agentic parity work on both compatibility surfaces, driven by measurement against real Claude Code traffic rather than by inspection. 41 commits, 43 files, +12.5k/-187. Tests go 678 → 1030, all green.

The honest summary first: most of this fixes defects that were reproducibly wrong. Two commits implement a mitigation whose benefit I could not demonstrate, and I say so explicitly below rather than dressing it up.

Bugs fixed, each reproducible before and verified after

Anthropic protocol shape

  • stop_reason reported tool_use on a turn upstream truncated with length, so clients executed a call the model was cut off mid-emitting. max_tokens now wins.
  • Outbound tool_use.id used the call_ prefix while the inbound path already minted toolu_ — two id namespaces in one controller. /v1/chat/completions still emits call_.
  • With no tools array (or tool_choice: "none"), history folding was skipped, so an assistant message carrying only tool_use rendered as empty content and the whole assistant turn vanished while its tool_result survived with the nonexistent role "tool". Claude Code's compaction requests have exactly this shape.
  • Inbound thinking / redacted_thinking blocks were dropped, discarding the model's own record of why it made the previous call.

Images — an image inside a tool_result never reached the model. The image was uploaded and delivered correctly; the folded text claimed the read had returned nothing, so the model was told in words it got nothing while an unreferenced image sat in files[]. Also: an upload-cache entry that vanished mid-read shipped null, and the cache TTL was a guess rather than the URL's own signed expiry.

Quota and errors — Qwen's RateLimited ("upper limit for today's usage") surfaced as HTTP 500 on /v1/messages and 502 on the OpenAI path, so a client could not distinguish quota exhaustion from a server fault and retried against a wall. Both now return 429 in the native shape for their surface. Separately, the OpenAI turn gate manufactured a 429 out of a correct answer via an over-strict wrapper unwrap, and a dead account could be re-picked immediately after being marked dead.

ResiduestripToolCallResidue ran at four sites on the Anthropic path and none on the OpenAI one, which computed residue spans and discarded them. Measured leak rate in real traffic: 20 turns in 29,352.

Test gatenpm test printed 944/970/975/983/986 on byte-identical trees, every time with fail 0 and exit 0, so a run 40 tests short was indistinguishable from a pass. tools/test-gate.js pins the expected count in tests/expected-counts.json and fails loudly on a short run. Every count in this PR is a per-file sum, not one aggregate run.

Invariant hardeningHARVEST_MEDIA_CAP was two independent literals in two files with nothing relating them; halving one left the entire suite green. Now one exported constant with a test that fails when the twin scans diverge.

The part I could not prove

Two commits number folded tool calls so results are addressable, and inject a ledger of already-executed calls. They were built on the hypothesis that duplicate tool calls come from the model being unable to tell which [TOOL RESULT: Read] answered which call.

That hypothesis did not survive measurement, and neither did two successors. A labelled dataset of 15,336 real tool calls across 192 sessions, six independent analyses and twelve adversarial validators produced: label collision is a correlate, not a cause (a call carrying 40 colliding same-name calls did not repeat on unfixed code, and a live differential probe — "five Reads, what did the second return" — passes identically with and without the numbering); the 90 KiB externalisation threshold does not discriminate (duplicates are 18/18 above it and first-time calls 163/163); retry-after-empty-result is refuted with the sign reversed.

The intervention class has since been measured directly, 364 times, by a client-side hook that is a strictly stronger version of the ledger — it lands inside the tool_result the model just requested, in 95 bytes. Conditioned on the population it fires in: RR 1.08 (CI 0.90–1.42) train, 0.96 holdout. Null.

So the byte cap went back from 12000 to 6000 (40df21e), and a test now pins what the model actually reads of the ledger after prompt-budget truncation, rather than what was written. The numbering is kept — it is protocol-quality-correct on its own terms and reverting carries more churn risk than keeping — but it should not be extended on the strength of its premise.

Worth recording for anyone tempted to add hard suppression: ~41% of repeated bare-path Reads have an intervening Edit/Write to that file. Suppressing repeats would break correct behaviour.

Testing

  • 678 → 1030 tests, 127 suites, 0 fail. No test deleted anywhere in the range (cumulative diff: 6990 insertions, 15 deletions, all assertion edits inside surviving tests).
  • npx eslint src tests tools clean.
  • Verified live against real Qwen on a staging deployment with a 236-account pool: 12/12 protocol probe cells on both API paths, 3 runs each; image-delivery invariant 15/15; the toolu_/call_ split confirmed on the wire by running the same probe against builds with and without these commits.
  • New probes under tools/dev-probes/ are instrumentation, not tests. Several of them exist to record that a design cannot reproduce the bug it was built for.

Notes for review

  • Both API paths move together throughout; src/utils/tool-prompt.js, agent-turn.js and chat-helpers.js are shared, and controllers/anthropic.js / utils/openai-agent-runtime.js are twins.
  • No new environment variable is required. .env.example documents the ledger cap.
  • The <tool_call> angle form is never re-taught — Qwen's platform intercepts it and injects Tool <name> does not exists into the model's context. Pinned by tests.

🤖 Generated with Claude Code

PEDRO LOBATO CARCAMO and others added 30 commits September 7, 2026 16:42
…text 500

Two independent defects kept Qwen from ever seeing an image sent by an agent
client, even though the model itself handles images fine.

1. Claude Code delivers images only inside Anthropic `tool_result` blocks
   (verified by capturing a real session). `flattenAnthropicMessages` filtered
   that block's content with `.filter(b => b?.type === 'text')`, destroying the
   image before any upload was attempted. Images now ride a `media`
   side-channel on the tool message — they cannot travel in `content`, which
   must stay a string for `foldToolMessages` — and are re-attached to the
   current turn after folding. `resultContent` is unchanged, byte for byte.

2. The upstream returned HTTP 500 whenever an image sat in `content[]` while
   `files[]` also carried the externalized agent-context `.txt`. Probes
   isolated it as a shape problem, not a size one: 107 KiB without an image
   and 61 KiB with one both returned 200, while 108 KiB with one returned 500.
   Images now travel in `files[]` as `{type:'image', url}` — the only entry
   shape this repo has proven upstream, used by the `image_edit` path.

The `files[]` split is gated on chat_type: `t2i`, `t2v` and `image_edit` reach
a controller that reads `messages[0].content` directly and would otherwise
degrade to text-to-image, silently dropping the user's input image.

Only images are re-routed. Video has no upstream-proven `files[]` shape, so it
stays in `content[]` and behaves exactly as before.

Verified against the live upstream: the tool_result shape and the >92160-byte
image request both return 200 and the model reads the image; text-only bodies
are byte-identical to before. 623 unit tests pass, and each of the guards is
pinned by a test that fails when the guard is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code sends a pasted image as [{text},{image}] and then appends a
text-only meta message ("[Image: source: <local cache path>]"), so the
image is never the last message. parserMessages' multi-message branch
only uploads media from lastMessage, and extractTextFromContent erases it
from every earlier one, so the image died with no upload attempt and no
log. Proven from the real transcript plus staging logs: zero image/jpeg
uploads in the window, and the model's own reasoning complained it could
not reach a file path -- text that exists only in the meta message.

Extend the current-turn backward scan in buildInternalRequest, which
already collects the tool_result media side-channel, to also pull
image_url items out of the content[] of non-last messages in the turn and
strip them from the carrier. The existing re-attach then hands them to
parserMessages for upload. The turn boundary is unchanged, so images from
earlier turns stay unattached.

Also fixes the same defect's other trigger: an image block placed before
the prompt, which flatten emits as its own message.

Verified against a local instance (qwen3.8-max, 446-byte magenta PNG):
paste shape returns "Magenta", an image-only carrier is described, an
earlier-turn paste is still not re-attached, and both OpenAI-path shapes
already worked. Suite: 628 pass / 0 fail.
…nnot drop the image

parserMessages only uploads media from the LAST message (chat-helpers.js:396);
every earlier message goes through formatHistoryMessages -> extractTextFromContent,
which drops non-text items with no log. So an image that is not last simply
disappears — no upload attempt, no warning.

Real clients put it exactly there. Captured live on 2026-09-08 with a transparent
proxy in front of /v1/chat/completions (OpenClaw -> Qwen2API), the agent request
that produced the answer looked like this:

  1:user:text+image_url+image_url        the user's screenshot
  ...
  7:user:text+image_url                  "Attached image(s) from tool result:"
  8:user:text                            OPENCLAW_INTERNAL_CONTEXT, text only

The last message is text, so nothing was uploaded: 3/3 agent requests in that
capture uploaded zero images, while a sibling request in the same minute whose
last message WAS the image uploaded fine. The model then answered from whatever
image was still in its context, which is what the user saw as a hallucination.

Fix mirrors anthropic.js#buildInternalRequest, which already solved the same
defect for Claude Code's paste shape: scan back to the turn boundary, lift the
media items off the non-last carriers, and re-attach them to the last message
after foldToolMessages so parserMessages uploads them.

Scope guards kept from that twin: only the current turn is harvested (history
images are still not re-attached), the last message is never touched, and a
media-free request produces a byte-identical upstream body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t message

The first cut of the harvest broke on any assistant message. In a tool loop the
assistant speaks several times inside ONE user turn — every intermediate step
carries tool_calls — so from the second step onward the image fell back out of
the scan window and the follow-up request went out without it.

Observed live on 2026-09-08 18:54. The model had clearly seen the screenshot:
it called web_search with the exact video titles visible in it
("midudev GPT-6 es Salvaje", "GPT-6 Astra vs Fable 5.1"). That call was rejected
by schema validation (it sent `queries`, the schema wants `query`), which added a
second assistant step. The next request harvested nothing, and the final answer
was invented from data sitting in the ~39 KB system prompt.

Boundary is now the last assistant message WITHOUT tool_calls, i.e. a real final
answer. Intermediate tool-call steps are inside the turn.

Also dedupe harvested media by URL, seeded from the media already on the last
message: one image legitimately appears several times in a turn (the user's
message and OpenClaw's "Attached image(s) from tool result:" carrier), and
without this it would be sent upstream twice. Carriers are still stripped even
when they contribute nothing, so no base64 leaks into the externalized context.

anthropic.js#buildInternalRequest has the same any-assistant boundary and is
likely wrong the same way for multi-step Claude Code tool loops — untested,
left alone deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e turn

buildInternalRequest's backward scan broke on ANY assistant message. Inside one
user turn the assistant speaks once per tool step, so the scan only ever saw
messages after the last tool step: a user-pasted image died at the FIRST tool
call, and a tool_result image died from the second assistant turn onward.

Measured against the real upstream before this change (/v1/messages,
qwen3.8-max, 446-byte magenta PNG, upload delta counted per request):

  image last, no tools       uploads=1   model answered "magenta"
  image + 1 tool round-trip  uploads=0   model answered "no image was provided"
  image + 2 tool round-trips uploads=0   same

One tool call was enough, and Claude Code calls tools constantly — so the paste
fix in a783f92 only ever covered turns where the model answered directly.

Boundary is now the last assistant WITHOUT tool_calls (a real final answer);
intermediate tool steps are inside the turn. This is the same rule 5019f04
applied to the OpenAI twin in chat-helpers.js#harvestCurrentTurnMedia.

Dedupe by media URL ships in the same commit, never after it: widening the
window makes "user pastes an image, then Read reads the same file" reach both
the content[] copy and the tool_result media side-channel, which without dedupe
becomes two files[] entries — two uploads and the image twice in the prompt.
The seed is taken only from the last flat message's content[], never from its
.media: in a normal Read turn that last message IS the tool message carrying
the image, and seeding from it would suppress the only copy.

The .media branch deliberately keeps no lastFlatIndex guard. The last tool
message's content is a string, so parserMessages can pick up nothing from it;
the single-step Read turn passes only because of that asymmetry.

Tests: the four tool-loop cases fail on the previous code and pass on this one;
the dedupe case fails if the boundary is widened without it. Regression guards
cover same-turn parallel tool_use, the previous-turn boundary, two parallel
Reads of different images, and the media side-channel never reaching the body.

653 tests, 0 fail (run with --test-concurrency=1; the parallel runner silently
drops whole files and reports a lower count).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cap the harvest

Four defects found by an adversarial audit of e3bd245/5019f04, each reproduced by
a runnable test before being accepted.

1. Dedupe seeded from the PRE-fold last message. foldToolMessages replaces a
   role=tool array body with a stringified one, so the copy that seeded the set
   was destroyed moments later while the surviving copy had already been
   suppressed. Harvest no longer dedupes; attachMediaToLastMessage does, seeded
   from the post-fold last message. It also registers as it filters, so two
   carriers of the same image collapse to one (the audit's version filtered only
   against the initial seed and would have let both through).

2. attachMediaToLastMessage silently no-oped when the last message's content was
   neither string nor array. {role:'assistant', content:null, tool_calls:[...]}
   is the canonical OpenAI shape and arrives verbatim whenever nothing folds
   (tool_choice:'none', non-t2t chat types, no tools) — the harvest had already
   stripped the image off its carrier, so it was dropped. Terminal else added.

   Same fix rescues a last role=tool message with array content: it is now inside
   the harvest window (willBeFolded), because folding would otherwise JSON
   stringify a few hundred KB of base64 into the [TOOL RESULT] text block with
   files[] left empty.

3. The harvest ran for every chat_type. For t2i/t2v it turned a plain-string
   prompt into an array, which breaks '@16:9' size sniffing and pays for an
   upload the image controller never reads. Now an allowlist (t2t, search,
   image_edit) rather than a denylist: routes/chat.js sends deep_research and
   every unknown type to the same controller, so a denylist would silently admit
   anything added later. image_edit stays in — the harvest is what puts its input
   image into files[].

4. The scan was unbounded: with no final-answer assistant anywhere in the array
   the whole history was harvested and every past image re-uploaded. Capped at 4
   items (not messages — a legitimate turn spans many messages) in both twins.
   Honest framing: the shape that needs this is not in any captured traffic.

Also corrects two comments that justified the strip by claiming it keeps base64
out of the externalized context document. It never could: getMessageTextContent
and extractTextFromContent are text-only, so media on a history carrier is
invisible to that document. 5019f04's commit message repeated the same false
claim. The real base64-as-prose leak is foldToolMessages, fixed by (2).

659 tests, 0 fail (--test-concurrency=1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ting a tool call

Two silent-data-loss defects, each reproduced before being accepted.

flattenAnthropicMessages had no final else in its user-block dispatch. Any block
that is not text/image/tool_result — document (PDF), search_result,
server_tool_use — vanished, and the model answered using only the sentence
wrapped around it. An `image` block whose source shape we cannot forward
(source:{type:'file',file_id}, a documented Anthropic feature) hit the same
silent path via a bare `if (imageItem)`.

Worse, when EVERY block of a user message was unhandled the message itself
disappeared from the flattened array. With history that makes parserMessages
treat the previous ASSISTANT message as "# Current message" — the model answers
its own last reply. Alone it throws inside parserMessages, the throw is
swallowed, and the upstream prompt becomes the literal string
'聊天历史处理有误…'.

Unhandled blocks now leave a visible breadcrumb in the text and are collected
into one WARN per request. A user message that produced no output at all keeps
its slot as an empty user message. Deviation from the audit's recommendation: it
proposed a 400 for that case, but `content: []` is spec-legal, so preserving the
slot fixes both failure modes without changing the API contract. thinking and
redacted_thinking stay silently dropped — they carry no user intent and cannot
be replayed to Qwen.

consumeTrailingCloser only recognised a bare closer when the keyword was written
in full, so a stream that died mid-closer (`[END TOOL C` + EOF) released the
fragment as prose. That is not cosmetic: the leaked "\n[END " makes the next
[TOOL CALL] fail the "trigger must be the first content" gate, so a real tool
call is silently dropped. It now consumes a dangling closer prefix at end of
stream, reusing isDanglingCloserPrefix, which only accepts canonical spellings
filling the rest of the line — a lone '[' stays prose because it is genuinely
ambiguous, and prose that merely looks closer-ish ('[END]', '[NOTE] done') is
untouched. Not applied to consumeMandatoryBracketCloser: that gate is what stops
a quoted payload from synthesizing a call.

669 tests, 0 fail (--test-concurrency=1; verify the count matches expectations —
the runner under-reports silently, including in serial mode).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n it was cut

Two independent problems in how the live prompt is built after the context is
externalized as a Qwen document.

buildRecentAgentHistory hardcoded `entries.slice(length - 4, -1)` plus the latest
entry: exactly five rounds, whatever the budget said. The weighted allocation
above it was therefore decorative for the one section that can actually absorb
history. Measured on a 60-round, 44 KB envelope with a 49152-byte cap:

  before   4559 bytes delivered   9.3% of the cap    5 of 60 rounds kept
  after   46017 bytes delivered  93.6% of the cap   60 of 60 rounds kept

It now fills the budget backwards from the newest round, keeping whole JSONL
lines — a half-truncated line is unparseable and more misleading than absent.
When even the newest round overflows the cap it falls back to the old head-tail
truncation, so the current round is always represented, and a compaction marker
is emitted whenever anything was dropped so the model knows it is not seeing
everything.

The allocator itself also became two-pass: `remainingBytes -= budget` subtracted
the ALLOCATED quota rather than the amount used, so a small section swallowed
its own unused headroom, and whatever was left landed on the last section — the
current message, which is short and inelastic — where it died. Now each section
takes min(weighted quota, what it actually needs) and the surplus is handed out
in elasticity order, recent history first. On its own this changed nothing
measurable (the fixed five-round slice capped the demand), but leaving it
one-pass would have re-introduced the ceiling the moment anything else grew.

sendChatRequest now returns contextCompacted / contextExternalized /
contextSerializedBytes, and both controllers emit
X-Qwen2API-Context-Compacted: <original bytes> when the attachment failed and
the context was reduced. That path returns a normal 200, so without the header
a client cannot tell that the model saw a fraction of what it sent.

Not done from the audit's item 6, and why: it proposed splitting the upload/parse
retry in externalizeOversizedAgentContext's catch, but that catch contains no
retry at all — it falls straight through to compaction, so the premise does not
hold against this code. The AGENT_CONTEXT_PARSE_* knobs are also left out: they
size a timeout whose real-world frequency has not been measured.

673 tests, 0 fail (--test-concurrency=1). The two budget tests fail on the
previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…per request

parserMessages built a fresh CacheManager on every call, i.e. every HTTP request.
A user turn is several requests — the agent tool loop — so the same image was
uploaded whole each time. Measured on staging 2026-09-08: 6 uploads of a
114440-byte image inside 77 seconds, plus 3 of a 206032-byte one; ~830 KB and
~10 round trips wasted per turn, each burning an account-rotation slot.

The cache is now a module-level singleton, bounded at 512 entries, with entries
expiring 10 minutes after they are written.

On why a time bound rather than a measurement: file_url is minted by the STS
endpoint BEFORE any bytes are uploaded (upload.js:151-157), so we have no lower
bound on how long one stays valid, and "shorter than any plausible OSS lifetime"
is an unsupported claim. The bound that does hold is that this repo already
ships the infinite-TTL version of the same bet as its recommended Docker
deployment: CACHE_MODE=file with ./caches mounted (README.md:207, 222-234)
writes the same file_url to disk and reuses it forever, across restarts. A
10-minute in-memory map is strictly more conservative than that.

The honest cost, since it is a real regression in one dimension: today is
self-healing — every request re-uploads, so a dead URL cannot persist. After
this, a URL that dies inside the window is reused silently, because a cache hit
skips the upload and the `return null` in normalizeMediaContentItem, the only
image-drop detector on this path, never fires. The TTL is what bounds that at 10
minutes. That is why it is a constant: making it refreshing (LRU) or
configurable removes the only cap on the age of a URL handed upstream. Entries
are never refreshed, so Map insertion order is age order and plain FIFO already
evicts the oldest.

Deliberately not done: no account-scoped key (upload and chat accounts are
already independent getAccount() calls that differ with 2+ accounts, so
cross-account referencing happens on 100% of image requests today — if it were
broken images would already be broken, and keying by account only lowers the hit
rate); no file_id/evict-on-failure plumbing (multi-file change guarding an
unmeasured failure the TTL already caps); no env var; and CACHE_MODE=file is
left alone — it is broken only without the documented volume mount, which is a
different one-line bug for a different commit.

chat-helpers.js now holds a module reference to upload.js rather than a
destructured binding, so the uploader can be replaced in a test without hitting
the network. imgCacheManager is exported solely so tests can call clear():
node --test isolates per file, not per test, and a module-level cache otherwise
leaks state between cases in the same file.

678 tests, 0 fail (--test-concurrency=1). All five new tests fail on the
previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t and upload cache

Nine commits, each with a test that fails without it. Everything below was
measured against the real Qwen upstream on the staging container, not inferred
from unit tests.

WHAT WAS BROKEN
parserMessages uploads media only from the LAST message; every earlier message
goes through extractTextFromContent, which drops non-text items with no log and
no upload attempt. Real clients never leave the image last — Claude Code appends
a text-only meta message, OpenClaw appends a runtime-context block, and any tool
round-trip pushes it further back. So the model was asked about screenshots it
had never been sent, and answered from whatever image was still in its context.

MEASURED, BEFORE -> AFTER (5 runs each, uploads counted per request)
  anthropic, image + 1 tool call    0/5 uploads, "no image was provided"  ->  5/5, 4/5 correct
  anthropic, image + 2 tool calls   0/5 uploads                            ->  5/5, 5/5 correct
  openai, real OpenClaw shape       0 uploads (3/3 captured requests)       ->  5/5 uploads
  agent context budget              4559 B, 9.3% of cap, 5 of 60 rounds     ->  46017 B, 93.6%, 60 of 60
  same image across 4 requests      4 uploads                               ->  1

The 4/5 is not a residual defect: the no-tool control scores the same, so that
one miss is model non-determinism, not transport.

ALSO FIXED
- Unsupported Anthropic content blocks (document/PDF, unforwardable image
  sources) vanished silently; a message made only of them disappeared entirely,
  which made the previous ASSISTANT reply become "# Current message".
- A stream dying mid-closer released "[END " as prose, which made the NEXT
  [TOOL CALL] fail its gate — a real tool call dropped in silence.
- Context externalization failure returned 200 with a fraction of the context
  and no signal; now X-Qwen2API-Context-Compacted says so.

KNOWN TRADE-OFF, ACCEPTED DELIBERATELY
The upload cache is the one place correctness was traded for cost. Today every
request re-uploads, so a dead URL cannot persist; from now on a URL that dies
inside the 10-minute window is reused silently, because a cache hit skips the
upload and the only image-drop detector never fires. The TTL is the sole bound
on that, which is why it is a constant and must not become refreshing or
configurable. Justified by the fact that CACHE_MODE=file — the recommended
Docker deployment — already caches the same URLs on disk forever.

DELIBERATELY NOT DONE
- The agent-turn gate still rejects prose+wrapper and exhausts to a 429. It is a
  designed contract with its own test, and real OpenClaw traffic passes it.
- CACHE_MODE=file never creates its caches/ directory. Real one-line bug, broken
  only without the documented volume mount. Separate commit.
- The double-<agent_final> wrapper leak. Cosmetic.
- Account-scoped cache keys, file_id eviction plumbing, and a TTL env var: each
  rejected with reasons in 264d562.

NOT VERIFIED
No human has run a real Claude Code or Nova session against the last three
commits; the evidence there is probes and upload counts, not a live session.
Production (7860) does not have any of this and is out of our hands.
The existing dev-probes all cover image delivery and context size. None
covers the tool protocol, which is what Claude Code actually runs on.

Cells A-F run against /v1/messages, G mirrors them against
/v1/chat/completions. B is the one that matters: five Read calls with
different paths in one synthetic session, then "what did the SECOND one
return?" - it fails if the model re-reads instead of using the result,
which is the 63.7% duplicate class.

D, E and F are read off the responses A/B/C already paid for, so the
probe spends four model calls per path and eight in total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Medido sobre 192 sesiones reales de Claude Code que pasaron por este proxy
(15.337 bloques tool_use): 1.451 llamadas duplicadas entre turnos, y en 925
de ellas (63,7%) habia otra llamada a la MISMA herramienta con argumentos
distintos entre la original y la repeticion. foldToolMessages tiraba el
call id y etiquetaba cada resultado solo con el nombre, asi que un turno con
veinte Read producia veinte bloques identicos `[TOOL RESULT: Read]`: el
modelo no podia saber que resultado contestaba a que llamada, y volvia a leer.

La historia foldeada pasa a llevar un ordinal monotono por request:
`[TOOL CALL #n]` y `[TOOL RESULT #n: <name>]` comparten numero, con el
tool_call_id como enlace. Un resultado que no reclama ninguna llamada se queda
en la forma sin numero: inventarle uno lo haria apuntar a otra llamada.

El ordinal existe SOLO en la historia. El marcador vivo que el prompt pide
emitir sigue siendo `[TOOL CALL]` sin atributos, y el prompt lo refuerza
("Never write a number in a marker you emit"). Aun asi, si el modelo imita el
ordinal la llamada no se pierde: el trigger es un prefijo y el payload se
recupera igual (pinchado en tests).

Frontera de inyeccion: neutraliseResultMarkers exigia un ":" pegado a RESULT,
asi que no reconocia la forma numerada — un cuerpo de resultado (contenido no
confiable) podia falsificar `[TOOL RESULT Rfym21#3: X]` y suplantar la respuesta de
una llamada real. Ahora se desarma cualquier `[` seguido de TOOL RESULT.

npm test: 678 baseline + 8 nuevos = 686, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Medido sobre 192 sesiones reales de Claude Code (15.337 bloques tool_use):
1.451 llamadas duplicadas entre turnos, y 526 de ellas (36,3%) sin ninguna
colision de nombre — el modelo simplemente reemitio una llamada que ya habia
hecho. No era confusion de correlacion (eso lo arregla la numeracion de
foldToolMessages): era que ni buildToolSystemPrompt ni buildAgentTurnDirective
tenian una sola regla contra repetir, mientras que todas las que si tenian
empujan a emitir mas llamadas.

buildToolHistoryLedger(messages, {maxEntries, maxBytes}) construye el bloque
"# Already executed this task": una linea por par distinto nombre +
canonicalJson(args), con el ordinal de foldToolMessages y un digest del
resultado de <=120 caracteres. Nada se suprime — repetir es a veces correcto,
releer un archivo despues de editarlo es la conducta buena — solo se hace
visible y direccionable.

Acotado por los dos lados porque se inyecta en CADA request y compite contra
el umbral de externalizacion de 90 KiB: 40 entradas y 6 KB por defecto, las
mas recientes primero, con una nota explicita cuando la lista quedo recortada
(sin ella, "no esta en el ledger" se leeria como "no se llamo nunca", que es
justo la conclusion falsa que dispara el duplicado).

Argumentos y digests son contenido NO confiable que vuelve al prompt, asi que
el renglon entero pasa por neutraliseResultMarkers: canonicalJson escapa
comillas y saltos pero no los corchetes, de modo que un argumento con
`[TOOL RESULT Rfym21#2: Read]` llegaria literal y podria hacerse pasar por la
respuesta de otra llamada. Para que folding y ledger usen una unica regla,
neutraliseResultMarkers se muda a agent-turn.js (la hoja del grafo) y
tool-prompt.js la importa; el cuerpo no cambia.

Una regla nueva en buildToolSystemPrompt y una clausula nueva en
buildAgentTurnDirective, ambas con la excepcion en la misma linea
("unless a preceding action could have changed it") para no prohibir el
repetido legitimo. La numeracion del ledger y la de la historia foldeada
quedan clavadas juntas por test: si se desincronizan, el ledger dice Rfym21#3 y la
historia llama Rfym21#3 a otra llamada, que es peor que no numerar.

npm test: 700 tests / 0 fail (686 previos + 14 nuevos).
El ledger de la Task 2 no servia de nada mientras nadie lo llamara. Ahora se
inyecta en los dos caminos con el mismo orden de ensamblado:
toolPrompt -> ledger -> envelope (historia + mensaje actual) -> directive.

Va pegado al protocolo de herramientas porque es parte de ese contrato: sin el
prompt delante seria una lista de ordinales sueltos, y tiene que leerse antes
de la historia que documenta. Y vive en el PREFIJO, que parseAgentEnvelope
(utils/request.js) nunca externaliza — dentro del bloque de historia el
contrapeso desapareceria justo en las conversaciones largas, que son
exactamente las que repiten llamadas.

Se arma sobre los mensajes PRE-FOLD en ambos lados (`flat` antes del fold en
anthropic.js#buildInternalRequest, `messages` antes del suyo en
chat-middleware.js#processRequestBody). Despues de foldToolMessages la llamada
ya es texto dentro de un string (`[TOOL CALL Rfym21#1]`), sin tool_calls ni
tool_call_id que recorrer: un ledger armado tarde sale vacio y el bloque
desaparece sin que nada lo delate. Los tests clavan ese fallo silencioso
comprobando que la entrada trae los argumentos y el digest reales.

Gated en hasTools en los dos caminos: sin protocolo de herramientas el bloque
no tiene contrato que lo explique. Cubierto para tools ausentes y para
tool_choice "none", que es el otro modo en que hasTools cae a false.

npm test: 704 tests / 94 suites / 0 fail (700 previos + 4 nuevos).
Nota: la corrida en paralelo dio 703/93 una vez —
`node --test --test-concurrency=1` reproduce 704/94/0 de forma estable y es lo
que hay que mirar cuando el numero baja sin fallos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los tres createToolCallLedger() son por-intento: nada en el servidor comparo
jamas una llamada saliente contra los tool_use que ya venian en el array de
mensajes. Por eso los 1.451 duplicados entre turnos medidos sobre 192 sesiones
reales de Claude Code no dejaron una sola linea de log — el servidor no sabia
que esas llamadas ya habian corrido.

createToolCallLedger acepta ahora {seed} (extractHistoryToolCalls sobre los
mensajes PRE-fold, con los mismos ordinales que foldToolMessages escribe en
`[TOOL CALL #n]`) y expone wasInHistory(call).

La restriccion que manda: una entrada sembrada NO SUPRIME. Marca la llamada
como ya vista para poder registrarla; suprimir romperia la relectura legitima
despues de un edit, que es conducta correcta. La decision de emitir no cambia
ni un byte — el pin tests/openai-agent-turn-cutoff.test.js:600 pasa sin tocar.
Lo unico nuevo es un logger.warn etiquetado AGENT por repeticion historica, con
nombre y ordinal y jamas el payload (tests/tool-prompt.test.js:1503,1774).

Sembrado en las dos rutas: anthropic.js lo saca de buildInternalRequest y lo
pasa por ctx a las ramas de streaming y no-streaming; chat-middleware.js lo deja
en req.tool_history_calls y chat.js lo pasa al runtime OpenAI.

11 tests nuevos en tests/tool-repetition.test.js (704 -> 715, 0 fail, en serie).
…call was emitted

mapAnthropicStopReason checked hasToolCalls before the length/max_tokens
branch, so a turn the upstream cut off mid-emission still reported
stop_reason "tool_use". The client reads that as "the model finished
asking for the tool, run it" and executes a call whose arguments may be
truncated. The native API reports max_tokens there: the turn did not end.

This is precedence, not suppression — the tool_use blocks already emitted
still ship on the wire, only the stop_reason framing them changes. Both
call sites (streaming and non-streaming) go through the shared mapper, so
they move together.

content_filter/refusal with an emitted call deliberately keeps reporting
tool_use; same family, but out of this change's scope and noted in the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/v1/messages was emitting `call_<24 hex>` — the shape the shared constructor
(tool-prompt.js createToolCallObject / buildEmitted) mints for the OpenAI wire.
The native Anthropic API uses `toolu_`, and this controller already generated
`toolu_` for the INBOUND direction (flattenAnthropicMessages, when a client
tool_use block arrives without an id), so the two directions lived in different
id namespaces inside the same file.

The rewrite goes at this route's emission boundary, never in the shared
constructor: /v1/chat/completions must keep emitting `call_`, and that shape is
part of its contract. Both Anthropic emission sites are twins and changed
together — the streaming `emitToolUse` content_block_start and the non-stream
loop that assembles `content[]`.

The relabel keeps the same 24 hex, so two distinct calls in one turn (fresh
UUIDs) stay distinct; an id of any other shape cannot be relabelled without
risking a collision, so it gets a freshly minted one instead.

flattenAnthropicMessages deliberately does NOT go through the rewriter. There
the id is the client's own (`toolu_01LhEfp5…`, base62, not 24 hex) and it is the
key linking a tool_use to its tool_result — rewriting it would break that
correlation. It only shares the minting helper.

tests/anthropic-native-parity.test.js gains five tests: toolu_ on a single call,
toolu_ plus uniqueness across two calls in one turn (stream and non-stream), no
`call_` anywhere on the Anthropic wire, and the OpenAI twin still minting
`call_` with unique ids — that last one is the guard that the change stayed
local. The stale pin at anthropic-native-toolcall.test.js:381 asserted `call_`
on the Anthropic wire and now asserts `toolu_`; its intent (fresh ids, never a
platform function_id) is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`stripToolCallResidue` tenía cuatro llamadores en anthropic.js y CERO en el camino
OpenAI: openai-agent-runtime.js calculaba `residueSpans` dentro de settledTextRound y
los tiraba al suelo — el objeto attempt no los exponía y ningún llamador los leía. Por
eso un `[END TOOL CALL]` huérfano salía como texto visible del asistente (20 casos
medidos sobre 192 sesiones reales de Claude Code).

El attempt expone ahora `residueSpans`, ya rebasados a coordenadas de `visibleText`, y
chat.js#prepareAgentOutput los pela por posición en la entrega — un único embudo para
streaming y no-streaming. El rebase es lo que hace útil al resto: el gate sólo entrega
prosa envuelta en <agent_final> (agentTurnAcceptBareFinal=false), así que el único
residuo entregable pasa por el desenvuelto de parseAgentControlText y, sin rebasar, el
pelado posicional no encontraría un solo span. Falla cerrado en las dos direcciones
(tramo contiguo y no ambiguo, cada span revalidado contra el destino): ante la duda se
entrega el residuo antes que morder la respuesta.

La DETECCIÓN no se toca: `attempt.visibleText` sigue byte a byte como salió del parser,
así que containsOrphanProtocolResidue enciende el reintento malformed_protocol igual que
antes; lo que cambia es lo que se entrega en la segunda pasada "tal cual". Y como el
pelado es posicional y nunca una búsqueda, un bloque encercado que cita el mismo marcador
no registra spans y llega intacto.

Límite conocido, no cubierto por esta capa: en streaming con la config por defecto el
cuerpo del <agent_final> sale en vivo por on_content_delta mientras se genera, y ahí el
residuo ya está en el cable — misma limitación que anthropic.js con sus text deltas
inline. Los spans se filtran contra lo ya emitido para no reenviar el turno entero detrás.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tools

foldToolMessages was gated on hasTools. A request with no `tools` array (or
`tool_choice: 'none'`) skipped folding, so an assistant message carrying only a
`tool_use` block kept `content: ''`; formatSingleMessage (chat-helpers.js) drops
any message whose text is empty, so the entire assistant turn vanished from the
JSONL history while its `tool_result` survived as a line with the nonexistent
role "tool" — the model saw a result with no call that asked for it. Claude
Code's compaction and summarisation requests have exactly that shape.

The history is now folded according to what it CONTAINS, not what this request
declares. This is rendering, not protocol: the tool system prompt, the executed
call ledger and the agent turn directive stay gated on hasTools, so a
tools-off request recovers its readable history without learning to call tools.

The predicate is chat-helpers.js#willBeFolded, now exported rather than
rewritten: it already exists for the media sweep and its contract is literally
"does foldToolMessages rewrite this message?", aligned with the fold's two
branches. A third copy would drift.

Tests: 4 in tests/anthropic-native-parity.test.js — both turns render in order
with the right roles with no tools array and with tool_choice none; the
protocol prompt, ledger and directive stay absent; a history with no tool
blocks is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Con extended thinking + tools, Claude Code reenvia el bloque `thinking` junto
al `tool_use` que produjo. `flattenAnthropicMessages` lo tiraba, borrando el
registro que el propio modelo dejo de POR QUE hizo esa llamada — justo lo que
alimenta la clase de duplicado que ataca este plan.

La rama `assistant` ni siquiera tenia clausula: el bloque se caia del if/else
sin dejar rastro (ni siquiera una linea en droppedBlockTypes). Ahora el
razonamiento se renderiza delimitado por [THINKING] / [END THINKING] delante
del texto y, tras foldToolMessages, delante del bloque de llamada: se lee en
orden cronologico penso -> dijo -> llamo.

Tres cuidados, cada uno con su razon:
- Neutralizacion: el texto es contenido que vuelve al prompt y puede citar
  marcadores. Pasa por neutraliseResultMarkers (la misma regla que el fold y
  el ledger) y ademas se defusa [END THINKING], porque un delimitador que el
  cuerpo puede escribir no delimita nada.
- Tope de 1200 chars por mensaje, recortando por la CABECERA: la decision que
  produjo la llamada esta al final del razonamiento, asi que quedarse con el
  principio tiraria exactamente el porque que veniamos a rescatar.
- redacted_thinking rinde un placeholder, nunca los bytes opacos; la signature
  no viaja.

La rama `user` sigue tirando el bloque a proposito: segun la spec el
razonamiento vuelve en turnos de assistant, en rol user no hay intencion de
usuario que preservar, y esa decision ya estaba fijada por
image-passthrough.test.js:387. La asimetria queda documentada en ambos sitios
y con un test que guarda el limite.

Efecto colateral correcto: un turno de assistant que solo llevaba thinking
producia content:'' y desaparecia entero de la historia (misma clase de bug
que la tarea 8); ahora sobrevive.

Tests: 737 -> 743 (+6), 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…teaches

T1 numbers the folded call block (`[TOOL CALL #n]`), which the model reads on
every round. The natural imitation mirrors the ordinal onto the closer, and the
parser was blind to exactly that — because of the space T1 writes:

  "[END TOOL CALL]"     orphan=true   spans=["[END TOOL CALL]"]
  "[END TOOL CALL#7]"   orphan=true   spans=["[END TOOL CALL#7]"]
  "[END TOOL CALL Rfym21#7]"  orphan=false  spans=[]        <- leaked

TOOL_CALL_CLOSE_BRACKET_RE's decoration class `[^\s[\]]{0,16}` excludes
whitespace, so it could never reach the '#'. Consequence on BOTH paths:
`[END TOOL CALL Rfym21#3]` was delivered to the client as visible assistant text,
stripToolCallResidue had no span to remove (there is deliberately no second
delivery-layer scan), and containsOrphanProtocolResidue returned false, so the
malformed_protocol retry at anthropic.js:1404,:2070 and
openai-agent-runtime.js:600,612,640,756 never fired. That is a regression in
the exact metric the plan measures (protocol text leaked to the user).

The fix is one bounded ordinal arm, mirrored in all three places that spell the
closer out: the anchored regex, the bare arm (a stream dying on
`[END TOOL CALL Rfym21#3` requires "nothing left after the match", and `Rfym21#3` always
remained), and isDanglingCloserPrefix's literal table (`[END TOOL CALL #` at
EOF). Only a digit run is tolerated, never a word: `[END TOOL CALL Rfym21#3 and the
answer is 42]` still parses as prose, keeping the rule at tool-prompt.js:103-105
(better to leak a closer than to eat the model's answer). Whole-text and
streaming stay in lockstep. The synthetic-rescue boundary is unchanged: the
write side (neutraliseResultMarkers) already breaks the head char of any
`[...TOOL CALL` in a result body regardless of what follows.

probe-agent-loop.js cell F scanned for literal markers, so it would have
reported "clean" on the very form that leaked; it now matches the numbered
family too and prints the exact leaked text, flagged ORDINAL-IMITATED. That
cell is the live acceptance gate for whether Qwen imitates the ordinal at all.

Tests: 3 new regression tests + 2 extended closer matrices fail without this
fix. 2 further tests guard the widening itself (prose is never eaten; the
injection boundary still holds) and the TOOL_CALL_CLOSE_MAX literal mirror.

Suite: 678 baseline + 65 (T1-T9) = 743 before; +5 added here = 748 expected,
748 observed, 0 fail (stable over 6 consecutive runs).
…e a result

Dos defectos alcanzables desde una request normal por la ruta ya cableada
(chat-middleware.js / anthropic.js), ambos verificados extremo a extremo.

1. Una entrada forjada desde `arguments`. renderLine colapsaba el NOMBRE y el
   digest, pero a los argumentos solo les aplicaba truncateChars, y la rama de
   parseo deja `parsed` como el string CRUDO cuando los argumentos no son JSON
   — el sintoma medido que motiva el plan: el modelo emite argumentos
   malformados, vuelven como historia en el turno siguiente y se auto-forjan —
   o cuando el JSON decodifica a un string. Los saltos literales sobrevivian y
   cada uno abria otro renglon con la forma exacta de una entrada legitima,
   bajo una leyenda que le dice al modelo que esos resultados ya corrieron y
   los reuse. neutraliseResultMarkers no lo tapaba: reescribe `[` y `<`, nunca
   saltos. Se colapsa esa rama en construccion. Solo esa: colapsar tambien la
   salida de canonicalJson fundiria `echo  hi` con `echo hi`.

2. Un digest viejo colgado de un ordinal sin contestar. En una repeticion, la
   entrada avanzaba al ordinal nuevo y se quedaba con el digest del viejo, asi
   que releer despues de editar — el escenario que JUSTIFICA no suprimir
   repeticiones — rendia `Rfym21#3 Read {a.txt} -> CONTENIDO PRE-EDICION` cuando en
   la historia foldeada no existe ningun [TOOL RESULT Rfym21#3]. La misma correlacion
   falsa que Task 1 elimina. Ahora byCallId guarda el ordinal de CADA llamada,
   el digest se adjudica a esa instancia (y solo avanza a una mas nueva, no al
   ultimo resultado procesado) y el renglon nombra la instancia contestada
   cuando difieren.

3. El presupuesto documentado era ASCII. Medido: ~215 B/entrada y ~26 entradas
   en ASCII, ~460 B y ~12 en CJK. Degrada sin mentir (la nota de omision se
   dispara igual), pero el comentario era 2x optimista para un producto
   bilingue con upstream chino.

Tests: 7 nuevos en tests/tool-repetition.test.js. 4 fallan contra el codigo
previo (las dos ramas de forja, la repeticion sin contestar y los resultados en
desorden); 3 son guardas contra sobre-corregir (el JSON bien formado no se
colapsa, la repeticion CONTESTADA se renderiza limpia, el tope de bytes aguanta
CJK). Suite: 748 base + 7 = 755, 101 suites, 0 fail (paralelo y serial coinciden).
T3 wired the block correctly — order, position, gating and pre-fold
construction all verified — but adversarial review found the block itself
stating falsehoods, and stating DIFFERENT falsehoods on each path. The
caption tells the model "these calls already ran and their results are
above; reuse a result instead of repeating its call", so every false line
in it is a direct push toward the duplicate the plan exists to remove.

1. A result carrying an image was reported as no result at all, and the
   two paths disagreed on how. Claude Code's Read puts the image inside
   tool_result.content; each path moves it somewhere different before the
   ledger sees it — Anthropic to the `media` bypass leaving content:''
   (rendered `-> (empty)`), OpenAI as an item in the content array that
   harvestCurrentTurnMedia empties (rendered `-> []`). Read is the most
   repeated tool in the measurement (802 of 1,451). Fixed by
   summariseToolResultContent: text is text, non-text items are COUNTED
   and announced as `(1 image)` / `(N images)`, never serialized (the old
   JSON.stringify put the base64 data URI in the prompt, cut at 120 chars).
   Both paths now build the ledger before their own media step, so the two
   render the same line for the same logical call.

2. The legacy functions API listed every call as resultless. The call-side
   walk supports `assistant.function_call`, but the result side keyed only
   on tool_call_id, which `role:'function'` never carries — the branch was
   dead and its messages were dropped one line later, while
   foldToolMessages did write their `[TOOL RESULT: Read]` two lines below.
   Now an id-less call registers in a per-name FIFO and a result with no
   tool_call_id resolves through it. Only the ABSENCE of an id falls back
   to the name: a mismatched id is a mismatch, not an absence, and still
   adjudicates nothing.

3. The ledger put tool-derived text ahead of the envelope headers for the
   first time. parseAgentEnvelope splits on indexOf for the history header
   (first wins) and lastIndexOf for the current-message header (last
   wins), so a result containing either literal moved the cut and the
   poisoned tail was parsed as genuine JSONL. neutraliseResultMarkers now
   breaks the leading `#` of both, the same way it already breaks `[` and
   `<`, one ASCII byte for another so the byte caps stay exact. Ordinary
   markdown headings are untouched.

4. The Anthropic path JSON-escaped its whole prefix. ensureAgentCurrentEnvelope
   short-circuits when it already sees a history marker; a request whose
   history fits in one message has none, so applying the envelope AFTER the
   prefix wrapped the tool protocol and the ledger inside `# Current
   message` with literal \n — reachable with a one-message request, not a
   hypothetical. The envelope now runs before the prefix, mirroring the
   OpenAI twin. With history present the assembled content is byte-identical
   to before, verified on both paths, with and without `system`, with and
   without tools.

Tests: 11 added to tests/tool-repetition.test.js. 7 fail against the
previous source (both digest shapes, the base64 leak, the legacy link, the
two header-smuggling cases and the escaped prefix); 4 are guards against
over-correcting — a wrong id is still not rescued by name, markdown
headings survive, and the two paths are compared to EACH OTHER for both a
text result and an image result, which no earlier test did. A fixture with
a non-empty `system` now pins the three-part Anthropic prefix order.

Not fixed, stated deliberately: foldToolMessages still renders an
image-only result as `null` (Anthropic) / `[]` (OpenAI) inside the folded
history. That predates this branch, and a symmetric repair needs the media
twin scans in chat-helpers.js to leave the count behind — those two scans
must change in lockstep and belong to the image-delivery invariant, not
here. The ledger, which is the block that asserts the results are usable,
is now correct and identical on both paths.

Also not changed: the 6 KB ledger cap. It measures 5,986 B on 30 Read
calls (6.5% of the 90 KiB externalization threshold). That cost buys the
counterweight in exactly the long conversations that produce duplicates,
because the prefix is never externalized; the digest is a 120-char pointer
to a result the model must still read above, not a copy of it. Retuning it
belongs to Task 11, against live evidence.

Suite: 755 before + 11 = 766 expected, 766 observed, 101 suites, 0 fail
(identical in parallel and serial). eslint clean on all four files.
…livering nothing

T7 ported the delivery-layer strip from anthropic.js:2278 but not the residue-only
guard that immediately follows it (anthropic.js:2269), whose in-code comment says the
order is load-bearing: strip first, then judge emptiness, and never ship an
empty-content message. Porting half of it moved the OpenAI path from one frozen-matrix
violation to the other: `<agent_final>[END TOOL CALL]</agent_final>` stripped to empty
and went out as HTTP 200 with content "" and finish_reason "stop" — a silent dead turn
for an agentic client, where /v1/messages returns 502 for the same bytes. Measured on
the parent commit the same input delivered "[END TOOL CALL]", so the commit traded a
raw-protocol leak for an empty success.

- prepareAgentOutput now reports `residueOnly` — spans registered, no tool calls, body
  non-blank before the peel and blank after — and both handlers route it to a 502
  `invalid_tool_call`, matching the twin's failure class and detail. On the stream path
  it fires only when nothing has gone out on the content channel; once text is live the
  gate's 422 already owns that case.
- The peel now applies stripAgentTags after stripToolCallResidue, the full pair from
  anthropic.js:2278: a nested <agent_final> survives unwrapExactTag (anchored at the
  end, so it only consumes the outer wrapper) and was reaching clients raw — measured at
  3 of 29,352 real turns. Kept inside the `spans.length > 0` guard like the twin, so a
  zero-residue round is still byte-for-byte what it is today; stripping tags
  unconditionally would break the stream discount when an unstripped nested tag already
  went out live and would re-send the whole turn behind it.
- Both the delivered content and the discount comparison go through one
  peelDeliverableText, so they cannot drift apart and duplicate the answer.

Opposite direction pinned too: prose plus a stray closer stays a 200 with the closer
removed and never escalates to 502. Four of the seven new tests fail against the
pre-fix source with the exact reported symptoms (200 vs 502, finish_reason "stop" vs an
error event, and the literal "x  <agent_final>y</agent_final>"); the other three are
pass-both-ways controls.

Serial suite: 766 baseline + 7 added = 773 tests, 101 + 2 = 103 suites, 0 fail.
Verificacion adversarial de la Tarea 8. El arreglo Anthropic era correcto pero
la tarea quedaba incompleta contra la restriccion global del plan ("ambos
caminos cambian juntos"), y abria una via nueva para falsificar un ordinal.

1) EL GEMELO (chat-middleware.js). `foldToolMessages` estaba dentro de
   `if (hasTools)` y reproducia el defecto letra por letra en
   /v1/chat/completions: sin `tools` (o con `tool_choice: 'none'`) el assistant
   que solo lleva `tool_calls` tiene `content: null`, formatSingleMessage lo
   descarta y EL TURNO ENTERO desaparecia, mientras su resultado sobrevivia
   como una linea JSONL con el rol inexistente "tool". Medido en HEAD antes de
   tocar nada. El fold pasa a la misma puerta que el gemelo Anthropic
   (`hasTools || some(willBeFolded)`), entre la cosecha de medios y el
   recolgado. El supuesto bloqueo (image-passthrough.test.js) no existia: la
   asercion de ese pin sobrevive intacta, solo su comentario estaba caducado.
   Verificado ademas que t2i / t2v / deep_research / image_edit / search dan
   salida identica byte a byte antes y despues, salvo la historia reparada.

2) COLISION DE ORDINALES (tool-prompt.js). Desde que la historia se pliega
   tambien sin tools, un resumen de compactacion puede citar los marcadores que
   le enseñamos; el cliente lo reenvia como mensaje de texto plano en la
   peticion siguiente, esta vez CON herramientas, y ese `[TOOL RESULT Rfym21#1: X]`
   inventado convivia con el Rfym21#1 real — dos bloques reclamando la misma llamada,
   uno falso. Es la colision que la Tarea 1 existe para eliminar. Ahora el fold
   defusa los marcadores de todo texto que no escribio el mismo: mensajes de
   paso y el texto libre del assistant que precede a sus propias llamadas (este
   ultimo llevaba sin defusar desde la Tarea 1). Solo se toca texto; los items
   de media pasan intactos y un mensaje limpio conserva su identidad.

   La defensa va en la ENTRADA, no en la entrega: sin `hasTools` no se
   construye parser (anthropic.js:1107), asi que no hay residueSpans que
   recortar, y crearlos obligaria a correr el parser de herramientas sobre
   peticiones que no declararon ninguna — riesgo mayor que la fuga. Defusar a
   la vuelta cubre ademas cualquier otro origen (transcripcion pegada, fichero
   citado). La decision queda pinchada con su test.

3) Un resultado vacio dice `(empty)`, no `null`: la herramienta corrio y no
   devolvio nada, no devolvio JSON null. Solo se nota ahora que el mensaje ya
   no desaparece. El pin de tool-prompt.test.js conserva su proposito (el
   bloque sigue visible) con el literal actualizado.

Tests: 12 nuevos (5 gemelo OpenAI, 6 colision, 1 vacio-vs-null); 10 de los 12
fallan contra el fuente previo. 773 (HEAD ab89650, medido) + 12 = 785 tests /
106 suites / 0 fail. Lint limpio en los seis ficheros tocados.
…ops being per-message

Tres agujeros del primer intento de la Tarea 9, medidos, no supuestos.

1. El delimitador era forjable desde tres de los cuatro canales. La defusa vivia
   dentro del cuerpo del `thinking`, asi que el texto HERMANO del mismo mensaje y el
   cuerpo de un `tool_result` (ficheros, paginas, salida de comandos: el canal MENOS
   confiable) escribian `[END THINKING]` crudo en la historia y cerraban un bloque que
   no era suyo. El unico test que lo pinchaba corria contra un fixture sin bloque de
   texto: no podia fallar.

   El brazo THINKING NO puede vivir en `neutraliseResultMarkers`: esa regla se aplica
   tambien al contenido de un mensaje `assistant` (foldToolMessages), que SI lleva
   delimitadores nuestros — metido ahi, defusaba el delimitador REAL. Vive en
   `neutraliseUntrustedBody`, para cuerpos de los que nada es nuestro; el texto hermano
   usa el brazo suelto, porque el resto de sus marcadores ya los defusa el fold.
   Cubre `[/THINKING]`, `[END_THINKING]`, `[END\nTHINKING]`, `[THINKING: por que]`,
   y respeta la prosa (`[thinking about lunch]` no se toca).

2. El tope era POR MENSAJE, que no acota el agregado. Medido sobre la peor sesion del
   plan: el prefijo de 76 mensajes pasaba de 78.025 a 94.443 bytes y CRUZABA
   AGENT_CONTEXT_FILE_THRESHOLD_BYTES (92160), con lo que la peticion se externalizaba
   como documento y, si la subida falla, se trunca la conversacion — un cambio hecho
   para reducir duplicados provocaba el truncado que los produce.

   El presupuesto pasa a ser POR PETICION y no es una fraccion fija del umbral sino el
   HUECO que de verdad queda, gastado de lo NUEVO a lo VIEJO (el razonamiento que
   explica la llamada a punto de repetirse es el reciente). Misma medicion despues:
   n=76 -> 78.243 (218 B, no cruza), n=120 y n=1614 -> delta 0, byte a byte como antes
   de la tarea. Techo absoluto 12 KiB.

3. El recorte cortaba por unidades UTF-16 y partia pares subrogados. No llega crudo al
   wire (JSON.stringify escapa la mitad huerfana), pero el modelo lee el literal
   `\ud83d` en mitad del razonamiento. `trimLoneSurrogates` tambien arregla
   `truncateChars`, que tenia el mismo defecto en el digest del ledger.

Los topes citados salen de medir 6.249 bloques `thinking` reales de 1.544 sesiones
(p50=235, p90=755, p99=3.076, max=19.502; el 4,8% pasa de 1.200), no de estimar.

Dos decisiones deliberadas quedan pinchadas en tests en vez de discutidas: la retencion
NO se ata a `hasTools` (las peticiones de compactacion de Claude Code llegan sin tools y
con la historia entera), y un turno final que solo lleva razonamiento pasa a ser el
`# Current message` en vez de evaporarse.

Gemelo OpenAI: `foldToolMessages` es compartido, asi que la defusa en el cuerpo de un
resultado aterriza en /v1/chat/completions por construccion; hay un test que lo pincha.

Tests: 785 base + 14 nuevos = 799 / 111 suites / 0 fail (serie y paralelo). 10 de los 14
fallan contra el codigo anterior (verificado revirtiendo las fuentes); los otros 4 son
pines de decision. Sin verificar contra Qwen real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…op probe

Both the try and the catch assign `args`, so the `= null` initializer was
never read. `npx eslint src tests tools` is now clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
probe-agent-loop.js cannot answer whether the model stops repeating itself.
Its cells B and C passed before the correlation fix landed — C vacuously,
because the model emitted no calls at all — so the synthetic probe has no
headroom. Static analysis shows the addressing information is now present for
every duplicate case; it does not show the model uses it.

This harness replays real history instead. Every point where the recorded
session re-issued a call it had already made is a natural experiment: we know
what the real model did with that exact context. Replay the prefix, classify
what comes back as REPEATED / MOVED_ON / ANSWERED / ERROR.

Two facts about the reference transcript that the plan's corpus-level numbers
hide, and that the measurement arm has to know:

  - it holds ONE strict-immediate repeat, not 326. That figure is the
    whole-corpus total (192 sessions, 15,337 calls). The single strict onset is
    preceded by a task-notification, not by a tool_result, so `--mode strict`
    yields zero usable scenarios here. `--mode cross` (177 eligible) is the arm
    with statistical power and is the default.
  - Claude Code writes one JSONL record per content block, not per message.
    Read naively, no tool_use is ever preceded by its own thinking and no onset
    is ever preceded by a tool_result — 0/179 eligible. Records are regrouped by
    message.id first, which recovers 177/179.

Late-session prefixes run to ~340 KiB, far past the 90 KiB externalization
threshold, and that path is a different subsystem. A prefix that fits the budget
is sent verbatim; otherwise the request is the opening task plus a contiguous
tail anchored at the earlier identical call, so the duplicate target and the
result that answered it are always present. Every record says which shape it
used. Largest request in the default sample is 42.4 KiB.

Selection is deterministic — no RNG — so both arms see identical inputs.
Verified by node --check, eslint, and --dry-run; --dry-run --out dumps the exact
request bodies, all 20 of which were checked for role alternation, resolvable
tool_result ids, and presence of the duplicate target. No upstream calls spent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The harness was mechanically correct and inferentially broken. A design
review found the experiment measured the wrong population, with half the
treatment provably disabled on a quarter of the cells, at a sample size
that cannot reach significance, using a metric that rewards the model for
going quiet. Every fix below costs no quota and several SAVE it.

Population. The default transcript is now the Read-heavy Qwen2API session
instead of the lohari one. Root cause 1 addresses results that cannot be
told apart; lohari's repeats are 63% retry-after-empty-Bash-output, which
that fix cannot address by construction, and its ledger is evicted on
9/20 cells. Measured on the fitting onsets: Read 69/Bash 14 with 6% empty
here, versus Bash 69/Read 10 with 63% empty there. lohari stays available
as a second, separately reported stratum; the header forbids pooling them.

Selection. --max-calls was applied by slicing AFTER the stride, so
`--limit 200 --max-calls 30` silently returned the earliest 30 onsets
(everything before call#321) instead of 30 spread across the session; the
ceiling now applies during selection. Selection is stratified across gap
quartiles and capped at --per-target scenarios per target file, because
both reference sessions put 68-80% of their onsets on ONE file and an
even stride returned ~3 situations sampled 20 times. The cap is strict:
when it binds the run returns fewer scenarios and says so, since topping
the sample back up from the same file restores the count while destroying
the independence the cap buys.

Recording. Four things could not be reconstructed after the fact and are
now recorded per cell: ledgerAnchorPresent (computed with the real
controller flatten, so a cell whose ledger evicted the anchor is
distinguishable from a treated one), residue, timestampMs, and the FULL
response text rather than a 500-char slice. Also collisions -- the
root-cause-1 dose -- and mutationBetween, since re-reading after an edit
is correct behaviour and must be separable from the failure under test.

Metric. The headline is now REPEATED/(REPEATED+MOVED_ON): the raw rate
falls if the model merely stops calling tools, and the post-fix prompt
pushes that way, so a lazier model would have scored as a win. Raw and
tool-emission rate are printed alongside. A new TRUNCATED verdict is
excluded from both denominators -- and is keyed on output TOKENS, not on
stop_reason, because stop_reason is itself changed by the truncation
precedence fix: keying on it would let a fix under test decide the
classification and bias the very comparison being made.

Arms. --base-url-b interleaves A and B per scenario instead of running
one arm to completion then the other, so account rotation and upstream
drift spread across both instead of loading onto the second. --repeat
gives a within-arm noise floor. Paired discordance and an exact two-sided
McNemar are computed in-harness, printed next to the realised cluster
count so the p-value is read as descriptive.

Also: --result-cap 1200 (was 400) so the p90 real result of 980 B
survives, with a neutral ellipsis replacing a banner that announced
missing content and was itself a reason to re-fetch; --stream to measure
the shipped path; --profile to inspect a transcript's population before
spending anything; and the pre-registration, the power limits and the
known confounds stated in the header before any number is collected.

tests: 799 baseline + 22 new = 821, 0 fail. The instrument had none.
Ran the pilot the design review demanded before the paired A/B, and it
came back the way the review feared. 24 upstream requests, qwen3.8-max,
pre-fix service at 309da59 against post-fix at 785486d:

  pre-fix   95b7b0c1, budget 48, collisions p50=6      0/8 REPEATED
  pre-fix   33f8544e, budget 90, collisions p50=44     0/8 REPEATED
  post-fix  95b7b0c1, same 8 byte-identical cells      0/8 REPEATED

The null is not the vacuous one that made probe cell C worthless.
Tool-emission was 100% in every arm: the model engaged on every cell and
picked a DIFFERENT call. The instrument was audited against exactly that
artifact before the result was believed -- prefixSigs held 20-52
signatures per cell and the about-to-be-repeated signature WAS in the
set, so a repeat would have been caught. The absence is real.

The cause is structural, not a tunable. Every runnable onset is windowed:
0 of 99 fitting onsets in 95b7b0c1 and 4 of 94 in 33f8544e survive as a
full prefix even at the 90 KiB ceiling, and those 4 have gap<=6 and
collisions<=3, so they carry no headroom by construction. Recorded
prefixes run to ~340 KiB and the proxy externalises above 90 KiB, which
is a different subsystem. The window strips the accumulated task state,
and it shows in the output: pre-fix cells replied `cd … && ls
package.json` and `cat package.json`, a model re-orienting in a repo it
no longer has the history for rather than continuing the paging loop that
produced the duplicate. MOVED_ON on this sample does not mean "used the
numbered result", it means the replay changed the behavioural regime.

So the duplicate-rate claim stays UNPROVEN, and the header now says so
instead of inviting the next agent to spend 40 requests comparing 0%
against 0%. A runtime WARNING fires whenever every selected cell is
windowed, which on both reference transcripts is always.

What the 24 requests did buy:
  - No regression, two-sided as pre-registered. 8/8 concordant pairs,
    discordant b=0 c=0. The predicted upward risk -- the ledger PRIMING
    repeats by printing the exact strings -- did not materialise here.
  - The lazy-model risk did not materialise either: the post-fix arm
    emitted MORE calls than pre-fix (11 vs 9) at identical 100%
    tool-emission, so the raw metric was not being flattered by silence.
  - Prompt cost measured on real agentic requests instead of a synthetic
    one: +6679 input tokens over 8 byte-identical bodies, +15.8% versus
    pre-fix. Larger than the +12.7% measured on a bare prompt because the
    ledger grows with tool history.
  - Zero protocol residue and zero errors in either arm.

tests 821 / suites 111 / fail 0 (799 baseline + 22 harness tests, no new
tests here). eslint clean over src, tests and tools.
PEDRO LOBATO CARCAMO and others added 21 commits September 8, 2026 22:24
…ned nothing

An image inside an Anthropic tool_result never reached the model. That is exactly
the shape Claude Code sends when it Reads an image file, so screenshots and image
reads silently did nothing through this proxy.

The image was never lost. Measured 2026-09-08 against real Qwen: it is uploaded,
harvested, moved into files[] and delivered, and the outgoing body for the failing
case is byte-identical to a working control apart from one string. What was lost is
the TEXT that says an image exists.

flattenAnthropicMessages builds resultContent from `.filter(b => b.type === 'text')`,
and a Claude Code image read carries no text block at all (37/37 image-bearing
tool_results across 1,556 real session files have zero text blocks). resultContent is
therefore '' in 100% of real cases, and foldToolMessages renders it as `(empty)`:

  {"role":"user","content":"[TOOL RESULT Rfym21#1: Read]\n(empty)\n[END TOOL RESULT]"}

The model reads the authoritative record of what the tool returned, is told it
returned nothing, and answers NO_IMAGE while an unexplained image rides along in
files[]. The prompt even contradicted itself — the history ledger already printed
`Rfym21#1 Read {"path":"magenta.png"} -> (1 image)` two blocks earlier.

Both media scans stay untouched: the invariant that only the LAST turn's media is
uploaded is intact and still pinned. What changes is only what the result body SAYS.

- flattenAnthropicMessages writes `[1 image returned by this tool]` when it diverts
  image blocks to the media bypass, appending it after any result text.
- harvestCurrentTurnMedia (the twin, per CLAUDE.md) writes the same note when it
  strips media out of a role=tool body, which used to leave the literal `[]`.
- foldToolMessages renders an empty array body as `(empty)`, like an empty string:
  `[]` reads as a real JSON value, not as "the tool returned nothing".
- the ledger digest recognises the note so it counts the attachment once and both
  paths keep rendering the identical `-> (1 image)` line.

The note deliberately does NOT claim the image is attached. Only the last turn's
media is uploaded, and even there URL dedupe or HARVEST_MEDIA_CAP can drop it;
promising an attachment the model cannot see is worse than the `(empty)` it
replaces. It states the checkable fact and agrees with the ledger.

Cost: 0 bytes on every request without a tool_result image (pinned byte-identical);
+30 bytes, measured +6 input tokens, only when one is present.

Live verification against real Qwen (qwen3.8-max, /v1/messages), probe cell H —
the exact Claude Code shape, image-only tool_result, fresh 160x160 magenta PNG:
  HTTP 200 stop=end_turn -> "magenta"
…o score

Three defects, all of which let this probe report the wrong verdict:

1. The oracle matched /magenta/ against the raw answer, and the file being read is
   named `magenta.png`. Any answer that merely repeated the filename scored as
   "the model saw the image". Hit live: a pre-fix run whose text was a reasoning
   dump about `magenta.png` was scored SEES_IMAGE. The filename is now stripped
   before scoring, and NO_IMAGE is checked first.
2. It printed only the first 140 chars, so an answer that never reaches a colour
   was indistinguishable from one that does. Now 500.
3. It scored `content[].type === 'text'` alone. An agentic turn can answer with a
   tool_use block instead, which scored identically to a lost image. stop_reason
   and the tool_use names are now printed for every cell.

Cell I is replaced by the non-confounded control. The old cell declared a `Read`
tool and its history asked the model to read the file, so the model correctly
reasoned it had not read it yet: it measured the agent contract, not image
delivery. The control keeps the history and the image-last shape and drops the
tools, which is what the cell was for.

Post-fix, against real Qwen (qwen3.8-max):
  H)  tool_result con image (Claude Code)    HTTP 200 SEES_IMAGE stop=end_turn -> "magenta"
  I') historia + image ultimo msg, sin tools HTTP 200 SEES_IMAGE stop=end_turn -> "Magenta"
…ot a guess

Measured 2026-09-08 by uploading a real PNG through this service's own upload path:

  x-oss-date = 20260909T042756Z
  x-oss-expires = 300
  x-oss-signature-version = OSS4-HMAC-SHA256

The presigned URL Qwen hands back dies 5 minutes after it is signed. The cache served
it for 10 (IMAGE_CACHE_TTL_MS), and in CACHE_MODE=file — the mode README.md recommends
for the Docker deploy — for ever, across restarts, because that branch was a bare
fs.existsSync with no expiry at all. Past the signature the OSS answers 403
`AccessDenied / Request has expired`, so we hand the upstream a dead URL, no error
surfaces anywhere on our side, and the model answers as if no image had been sent.

The rationale comment claimed we had no lower bound on a Qwen URL's lifetime. We do,
and it is printed in the URL's own query string; the comment is corrected in place.

- presignedUrlExpiryMs parses x-oss-date (ISO basic, which Date cannot parse) plus
  x-oss-expires into an absolute deadline, and returns null when the URL does not say.
- cachedUrlIsUsable prefers that deadline, minus a 30 s margin so the URL survives the
  trip out and the upstream's own fetch. With no signature it falls back to 4 minutes
  from insertion, shorter than the old 10-minute bound on purpose: the whole benefit of
  this cache happens inside one turn (measured: 6 uploads in 77 s).
- Both cache modes check it. In file mode an expired entry is unlinked so addCache can
  rewrite it, which self-heals caches already on disk with no format change.
- A re-cached signature is deleted before being set again, so it goes back to the end
  of the insertion order the FIFO eviction reads as age.

IMAGE_CACHE_TTL_MS stays as the upper bound for a hypothetical unsigned URL.

Pre-fix, both behavioural tests fail exactly where they should: file mode reports a
dead on-disk URL as a hit (true !== false), and default mode reuses a dead URL instead
of re-uploading (1 !== 2).
Measured live against real Qwen (2026-09-08, qwen3.8-max, probe-matrix cell F,
LOG_LEVEL=INFO so the gate's own warn was visible): of 5 gate rejections on
/v1/chat/completions with tools, 3 were `invalid_control` and 2 were
`invalid_tool_call:tool_errors`. Zero were `bare`.

All three invalid_control rounds had the same shape — reasoning prose leaked
into the answer channel, followed by a perfectly well-formed pair:

  "The image is clearly visible - it's a solid magenta/fuchsia color. I can
   directly identify the dominant color without needing any tools.

   <agent_final>Magenta</agent_final>"

The answer is correct and complete. `unwrapExactTag` threw it away because its
regex is anchored at BOTH ends, so only a whole-string wrap parsed; anything
else carrying a tag fell to `invalid_control`, which — alone among the rejection
families — had no give-up budget, so it burned all 3 attempts and surfaced as
HTTP 429. The Anthropic twin already delivers this exact shape with 200
(createAgentTagStripper, whose own comment says judging "prose + wrapper" as
invalid only makes the whole turn fail). This is parity, not new policy.

- parseAgentControlText accepts exactly one well-formed pair with text around
  it and keeps BOTH halves, tags stripped. Keeping the outside text rather than
  just the body is deliberate: the proxy itself prepends image markdown to
  `answer` before this parse, so body-only would delete the image. Unbalanced,
  doubled, and mixed-family shapes still reject — but no longer fatally.
- invalid_control gets the give-up budget intercepted/malformed_protocol already
  have: the last attempt delivers the stripped text with finish_reason stop.
  `bare` and `empty` keep their veto; there the model never declared a close, so
  delivering would fabricate a conclusion (config/index.js:58).
- exhaustedError returns 502, not 429. Nothing here was rate limited, and 429
  made chat.js label it `rate_limit_error` — telling an agentic client to back
  off and retry the whole turn against the account it just failed on.
- The invalid_control retry hint now names the constraint still enforced; the
  old text ("malformed or mixed wrapper") never told the model what to fix,
  which is why all 3 attempts failed identically. Retry-only text, so it costs
  nothing on the per-request prompt budget.
- logger.shouldLog is case-insensitive. This repo's own .env sets
  `LOG_LEVEL=info` lowercase; `levels['info']` was undefined and
  `undefined >= 1` is false, so every log line was silently off — including the
  only trace this failure leaves in production.
- .env.example documents AGENT_TURN_MAX_ATTEMPTS,
  AGENT_TURN_ALLOW_PROSE_WITH_TOOLS and AGENT_TURN_ACCEPT_BARE_FINAL, which
  existed only in src/config/index.js.

Live after the fix: 8 cell-F runs, 0 gate rejections of any kind, 0 HTTP 429,
3 successful 200s carrying exactly the shape that used to be rejected (the
other 5 were account-level WAF 502s, unrelated). Because nothing is rejected,
nothing is retried — which also stops the gate tripling upstream load on a
single account, the thing that was tripping the WAF.

Tests: 854 pass, 0 fail (840 baseline at 3f0f48b + 14 added).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aid it

03b0587 made every tool_result image body say `[1 image returned by this tool]`.
That is true for the turn in progress and false for every turn after it: the two
twin media scans deliberately stop at the last final assistant answer, so a
previous turn's image is never re-uploaded and files[] is empty.

Measured live against qwen3.8-max (two runs per cell, same account, minutes
apart, the outgoing body differing in exactly that one string): on a
previous-turn result the note made the model invent a colour 2/2, while the
`(empty)` it replaced answered NO_IMAGE 2/2. It swapped "I lie that the tool
returned nothing" for "I lie that you can see it" — on a shape ~94x more common
in the user's corpus (37 image tool_results against 3,482 turns that come after
one, since every later request re-sends the same stale result).

The note now has two forms and the position picks one:
  current turn  -> [1 image returned by this tool]
  earlier turn  -> [1 image returned by this tool, not included in this request]
The ledger digest agrees (`(1 image)` / `(1 image, not included)`); leaving it
optimistic left the model believing the optimistic half. The turn boundary is
recomputed in flattenAnthropicMessages from the same rule the scan uses, over
the input shape. NEITHER MEDIA SCAN CHANGED, and `.media` is still set for every
media result, so a drift between the two expressions of the rule can only ever
produce a wrong sentence, never a lost image — pinned end to end by a case
matrix asserting positive-note <=> image-in-files[].

Also here, because they are the same mechanism or the same function:

- The note is no longer whitelisted back out of the digest with a regex over the
  result BODY. A tool result is untrusted text: any fetched page or file could
  assert an unbounded attachment count, and the matched line was deleted from
  the digest rather than kept, so it could also make one of its own lines
  disappear. Both write sites now go through writeToolResultMediaNote, which
  records the exact line on a non-enumerable property; the digest strips only
  that line, only its last occurrence, and takes the count only from there.
  A forged line now stays visible and counts for nothing.

- One writer for both paths means the twins can no longer diverge in text. The
  comments claiming they "write the MISMA nota" were true only for the current
  turn; that is now stated, and enforced by construction rather than by comment.

- flattenAnthropicMessages' tool_result branch kept `text`, diverted `image` and
  dropped everything else with no note, no droppedBlockTypes and no warning, so
  a result whose only block was something else folded to `(empty)`. Over the
  same 1,564 real sessions: 37 image-only results (the case 03b0587 fixed) and
  326 `tool_reference`-only results — 8.8x more frequent, still folding to "the
  tool returned nothing" under a caption that tells the model to reuse it. The
  branch is now symmetric with the top-level image branch 20 lines below: what
  we cannot represent is announced, and an image the bypass cannot convert
  (source.type 'file', base64 with no data) no longer dies in .filter(Boolean).

- Corrected the rationale at toolResultMediaNote: HARVEST_MEDIA_CAP does not
  slice the harvested array (it cuts the traversal) and a dedupe hit means the
  identical URL is already on the last message. Neither can drop a delivered
  image; the earlier-turn case is the real and sufficient reason.

Tests: 874 pass, 0 fail, 116 suites (854 baseline at a9c9f57 + 20 added: 2 here,
7 for the block types, 7 for the forgery, 4 for the cache read in the next
commit). Every new assertion was run against a9c9f57 first: 19 fail there, and
the behavioural ones fail for the stated reason — the previous-turn body says
`[1 image returned by this tool]` with files[] empty, `tool_reference` folds to
`(empty)`, and a forged `[9 images returned by this tool]` renders as
`line one line three (9 images)`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… ship null

3f0f48b taught the upload cache to expire, and expiry in file mode means
`fs.unlinkSync` — the first code in src/ ever to remove a cache entry. Before
it, "does it exist" and "read it" could not disagree. The upload path asked both
separately:

    if (cacheIsExist(sig)) return item(getCache(sig).url)

and shipped whatever `.url` came back. When the entry dies between the two calls
— a second PM2 worker in CACHE_MODE=file, which is what the README recommends
for Docker, or simply the TTL crossing in between — `getCache` returns
`{status: 404, url: null}` and the assembled body carries
`{"type":"image","image":null}`: an image the model never sees, with no error on
our side. Reproduced by stubbing the two calls to disagree.

One lookup now, and its status is checked. This also drops the second
readFileSync/statSync that file mode paid on every hit, since getCache already
calls cacheIsExist internally.

Tests: +4 (the three disagreement shapes — vanished, unreadable, present but
empty — plus a real-hit regression that the cache is still used). All four fail
at a9c9f57, the first with "un fallo de lectura del cache tiene que volver a
subir". Full suite 874 pass / 0 fail / 116 suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The acceptance gate for this bug has now shipped twice with an oracle that could
not fail, and the second attempt was the one that was supposed to fix the first.

- The fixture was named magenta.png and the image was magenta. 5e62b00 stripped
  that filename from the ANSWER, but it still travels in the PROMPT — inside the
  folded call and inside the ledger line `Rfym21#1 Read {"path":"magenta.png"} -> ...`
  — so a model echoing the name still scored as having seen the image. Measured:
  with that filename, the cell where the image is provably never uploaded scored
  SEES_IMAGE 2/2. Fixtures are now `file_7f3a.png` (no colour at all) and, in
  the new HN cell, `azul.png` over magenta pixels, so filename-echo is directly
  observable rather than merely excluded.

- Every cell asserted delivery, and cell H is structurally incapable of failing
  the way the fix failed: it only ever gets more confident. New cell J is the
  same conversation one turn later, where the twin scans deliberately do not
  re-upload and files[] is empty (verified offline through buildInternalRequest:
  H -> files=1, J -> files=0), so NO_IMAGE is the only correct answer. J is a
  hallucination control, and it is the shape ~94x more common in real sessions.

- Every cell now prints PASS/FAIL against a declared expectation instead of a
  bare observation, plus stop_reason and the tool_use names, so a turn that
  answered with a tool call is not mistaken for a lost image.

- The scorer is exported and pinned by tests/probe-toolresult-oracle.test.js
  (+5). An acceptance gate with no test of its own is exactly how a broken gate
  ships; `score('magenta.png', 'magenta.png')` must not be a sighting, and now a
  test says so.

NOT RUN LIVE. Twelve upstream attempts across ~20 minutes, including a 5-token
no-image control, all returned HTTP 500 with ret ['FAIL_SYS_USER_VALIDATE',
'RGV587_ERROR::SM::…被挤爆啦…']: an account-level rejection, not shape-specific.
The J cell's expectation is therefore argued, not measured — see the note in
08e504a for what WAS measured (the positive note on a previous turn, 2/2
fabrication).

Full suite: 879 pass, 0 fail, 117 suites (854 baseline at a9c9f57 + 25 added).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…top it faking closes

Repairs three findings that adversarial verification confirmed against the previous
commit (a9c9f57). Each one is reproduced, fixed, and pinned by a new test.

1. RESIDUE LEAK (found independently by all three verifiers, the worst of the set).
   The tolerant unwrap splices the tags out of the MIDDLE of the text, so the
   delivered string stopped being a contiguous substring of `cleanedText`.
   `rebaseResidueSpans` located it with `source.indexOf(target)`: that returned -1
   for every tolerated round and DROPPED ALL SPANS, and `peelDeliverableText`
   returns the text untouched when the span list is empty — so a raw
   `[END TOOL CALL]` reached the client as assistant text again, reversing plan
   Task 7 (20 leaks measured over 192 real sessions).

   Reproduced end-to-end through `handleStreamResponse` on this branch's HEAD:
     round text: 'Ya inspeccione el archivo.\n[END TOOL CALL]\nEso es todo.\n\n<agent_final>Listo</agent_final>'
     before: spans=[]  delivered='...\n[END TOOL CALL]\n...'  LEAKS=true
     after:  spans=[{text:'[END TOOL CALL]',at:27}]  delivered has no marker  LEAKS=false

   The unwrap now returns the surviving `segments` (`from`/`to` in source
   coordinates, `at` in output coordinates) and the rebase moves each span
   arithmetically through them. `indexOf` stays as the path for contiguous
   results (exact wrapper, `bare`, `invalid_control`), ambiguity guard included.
   Both modes still revalidate every span against the destination, so anything
   that straddles a removed tag is dropped rather than mis-deleting.

2. THE `bare` VETO WAS BREACHED. The previous commit claimed it did not overturn
   that policy; in effect it did. Any prose carrying one balanced pair ANYWHERE
   was promoted from `bare` (vetoed) to `final` and delivered with
   finish_reason=stop on attempt 1:
     'Next I will read the file and then emit <agent_final>the summary</agent_final> when done.'
       -> {kind:'final'} -> 200, delivered as a finished task.
   A plan is not a conclusion. The close tag must now be the LAST non-whitespace
   of the message: a tag with text after it is an incidental mention, not a close.
   This accepts 3/3 of the live-measured shapes (reasoning prose, then the pair at
   the end) and the proxy's own image markdown, which is always PREPENDED
   (`pendingImages` is flushed the moment the answer channel starts). Prose AFTER
   the close, and a pair inside a code fence, go back to `invalid_control` — the
   pre-a9c9f57 behaviour; neither was ever observed live.

3. THE invalid_control GIVE-UP IS REMOVED, not narrowed. Its justification ("the
   model DID declare the close, only the wrapper was mis-shaped") is false for
   nearly everything it fired on: after the end-anchor, what still lands in
   invalid_control is exactly the shapes that declare no readable close —
   unbalanced, reversed, doubled, both families at once. It delivered
   'task complete' + 'I need your DB password' as one completed turn, and
   '<agent_final>half wrapped answer' (no close tag at all) as stop. It was not
   gated on real exhaustion either: the loop's `break` also fires when there is no
   requestSender, so the FIRST malformed round shipped as stop with attempts=1.
   And on SSE it was unreachable for its own test's shape, because streamed text
   trips the 422 first. config/index.js:58 stands: exhaustion fails explicitly.
   The tolerant unwrap accepts the measured shape on attempt 1, which is what
   actually fixed the 429.

Also corrected, all claims rather than code:
- "CERO fueron `bare`" (commit message, source comment, test header) was falsified
  by a later n=5 run on the same cell that DID log a `bare` rejection. The family
  is minority, not ruled out. The veto stays — an exhausted `bare` is still 502.
- "doubled shapes still reject" was false. A string that BEGINS with the open tag
  and ENDS with the close is absorbed whole by `unwrapExactTag`'s lazy body. That
  is pre-existing and untouched; it is now pinned as a documented known gap so the
  neighbouring test is not misread as covering it.
- "openai-agent-runtime.js:703 is the only 429 reachable from /v1/chat/completions"
  was false: chat.image.video.js:97 returns 429 on upstream RateLimited, reachable
  via routes/chat.js for t2i/t2v/image_edit and as the default fallback. The
  conclusion (this 429 was ours) rests on the gate's own warn in the logs, not on
  that grep.

Hardening while here: the open/close offsets come from a lowercased copy, and
toLowerCase can change a character's LENGTH (U+0130 -> 2). Those offsets now also
rebase residue spans, so a skew would bite the answer. They are validated against
the original before any cut; a mismatch rejects the round instead.

Not touched, by construction: src/utils/chat-helpers.js and
src/controllers/anthropic.js are absent from this commit, so both twin media scans
stay byte-identical and the image-delivery invariant holds. tool-prompt.js:44-52
injection boundaries untouched. buildAgentTurnDirective is byte-identical (1513 B),
so per-request prompt cost is unchanged; the angle form is not re-taught.

Live verification was NOT possible: the Qwen account is WAF-challenged
(upstream_waf_challenge, ~700 ms, rejected at the edge before generation) on every
request, with and without an image payload. 8 upstream requests spent confirming
that, 0 reached the model, no 429/RateLimited. Probe-matrix cells C and F remain
unverified against real Qwen for this change and should be re-run when the account
recovers.

Tests: 889 pass, 0 fail (879 baseline at 8411eb4 + 10 added), run serially with
--test-concurrency=1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…in scans diverge

El tope de medios por turno estaba declarado DOS veces como literales independientes:
chat-helpers.js#harvestCurrentTurnMedia (ruta OpenAI) y el bucle en linea de
anthropic.js#buildInternalRequest (ruta Anthropic, la que corre Claude Code). Nada los
relacionaba. Un mutation test bajando el de anthropic.js de 4 a 2 —— media entrega de
imagenes menos en la ruta del usuario —— dejaba las 889 pruebas en verde.

Ahora la constante vive una sola vez y se exporta; anthropic.js la importa.

El guardian no compara numeros: mete UNA entrada (6 imagenes pegadas, la forma que manda
Claude Code) por los DOS barridos y exige la misma entrega. Comparar constantes habria
pasado igual con un barrido que dejara de respetar su tope.

Mutantes verificados —— cada uno aplicado, visto fallar, revertido:
  A  anthropic.js redeclara `= 2`          -> 5/6 fallan (parity + single-literal)
  B  la constante compartida 4 -> 2        -> 2/6 fallan (pin de capacidad)
  C  el barrido ignora el tope, constante  -> 2/6 fallan (SOLO lo pilla la parity;
     intacta                                  un test de constantes habria pasado)

Ademas clava el desacuerdo que anthropic.js:390 documentaba sin probar: `delivered` en la
nota se decide por POSICION (currentTurnStartIndex) y el corte del tope ocurre despues, en
el barrido, asi que un turno de 6 resultados con imagen produce 6 notas positivas y solo 4
imagenes en files[]. Se clava tal cual esta hoy —— es un pin de un agujero conocido, no una
afirmacion de que este bien. Arreglarlo cambiaria comportamiento y queda fuera.

Sin cambio de comportamiento: la unica diferencia funcional es de donde lee el 4.

Suite: 889 + 6 = 895 tests / 119 suites / 0 fail. Confirmado por suma por-fichero
(41 ficheros = 895), que no sufre el subconteo bajo carga.
Mutation testing cut buildToolHistoryLedger's maxEntries default from 40 to 3
and all 895 tests stayed green. The mechanism was tested; its reach was not —
and reach is what decides whether the ledger sees the call the model is about
to repeat at all. With the default at 3 the block would name 3 of every 40
executed calls and nothing would chirp.

The two existing cap tests both pass maxEntries EXPLICITLY (3 and 40), so
neither one exercises the default. This test does, at the boundary:

- 40 distinct executed calls -> 40 entries, no omission note (claiming
  omissions when the list IS exhaustive pushes the model to re-call "just in
  case" — the bug inverted).
- 41 -> exactly 40 entries plus the note, and the retained ordinals are
  Rfym21#41..Rfym21#2. The one that goes is the OLDEST; keeping the old ones would be the
  worst possible split, since the call about to be repeated is the last one.
- The same 41 with maxBytes at 1e6 still yields 40, so the assertion measures
  the ENTRY cap and not the byte cap. Without that separation the test would
  stay green while silently measuring the wrong limit the day an entry grows
  fat enough for the 6000 B cap to bite first (today the block is ~1.8 KB).

Verified by mutation: 40 -> 3 fails this test and only this test (896 tests,
895 pass, 1 fail); reverted, 896/896.

No source change — the value itself is left at 40 deliberately; moving it
needs measurement first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The driver is the instrument that decides whether numbering the folded tool
history and injecting an executed-call ledger reduce duplicate tool calls. It
was untracked and untested, and a design review found it manufactured empty
results at high rate — including on the most natural call for its own task.

Reproduced before fixing, on the driver as it stood:

  Grep{pattern:'legacyFormat\(', glob:'src/**/*.js'} -> "No matches found"
  Bash: grep -rn 'legacyFormat(' src/               -> "No matches found"
  Bash: sed -n '200,400p' src/core/pipeline.js      -> "(no output)"
  Bash: wc -l src/core/pipeline.js                  -> "23"  (repo file count)
  Bash: cat src/core/pipeline.js                    -> first 60 of 1278 lines
  Read{limit:500}                                   -> 200 lines, no marker

Two root causes. The grep scope was built by deleting every '*' from the
pattern, so 'src/**/*.js' became the literal prefix 'src///.js' and matched
nothing; and an invalid-regex catch returned NO MATCH rather than falling back,
while the task's own target string 'legacyFormat(' is an invalid JS regex that
grep matches literally. A harness that tells the model a file is empty and then
counts the re-read as a duplicate is measuring itself.

Simulator: proper glob semantics (** spans directories, * does not, a slashless
glob filters the basename) in ONE implementation shared by Glob, Grep's filter
and grep --include; literal fallback for an uncompilable pattern; wc -l counts
the named file; cat/head/tail honour -n and direction; sed -n and awk NR page;
clustered flags (-rln) are read letter by letter; the stage splitter respects
quotes, so `awk 'NR>=10 && NR<=12' f` is no longer shredded at the &&; pipelines
evaluate left to right, so `grep pat X | wc -l` counts matches rather than X's
lines; Read honours its limit and marks truncation; past EOF returns a
Read-shaped notice; and an unrecognised command returns an ERROR, never silence.
Silence is a lie the model cannot detect; an error it can react to.

Metric: cross-turn duplicates are separated from same-message repeats (the
corpus definition is cross-turn, and same-message was measured at exactly 0);
`seen` is now read for a whole turn before being written, and a repeat inside
one message consumes one ordinal rather than two. On the OpenAI path unparseable
arguments no longer collapse to {}, which had made two DIFFERENT malformed calls
duplicates of each other. The key now names the same equivalence class as the
proxy's ledger, pinned against canonicalJson.

Instrumentation the analysis needs: gapTurns/gapDistinct back to the original
(raw, so an in-ledger-window flag can be applied post-hoc at any window size),
per-call `finish`, distinctRatio, full provenance (driver content hash, git head,
argv, task id, seed), and distinctTargetsCovered to match arms on task PROGRESS
rather than on turn index — post-fix injects ~6 KB more and crosses the
externalisation threshold earlier, so the same turn index is not the same amount
of work done. Progress is now declared by each tool result rather than inferred
by scanning its body for anything path-shaped, which had let one
`Glob src/**/*.js` jump coverage to 21/23 without a line being read.

Tasks: the old single task — find every call site across 23 files — has an
expanding frontier, distinct/total ~1.0. In the corpus that ratio is 0.99 in
sessions below 5% duplicates and 0.62 above 25%, so it was a LOW-duplicate-regime
task, which explains its 20-call/0-duplicate null at least as well as low power
does. Replaced with five bounded-revisit tasks differing in traversal order and
in what drives the revisit, so one behavioural quirk cannot carry all five.
The import graph is now a DAG by construction: the first version drew edges
uniformly and produced 99 cycles reachable from core/pipeline, which makes the
trace task unanswerable and would have had the model looping forever while the
harness scored every lap as duplicates the fix failed to prevent.

MAX_TOKENS 1024 -> 4096: agentic turns truncated, and stop_reason precedence
under truncation is itself one of the changes under test.

--self-test runs the calls the tasks plausibly generate, fails on any empty or
error, and separately checks the answers are TRUE — a full but wrong answer is
worse than an empty one. It spends zero upstream requests and must be run first;
the absence of exactly this check is why 15 requests were wasted once. It has
already earned its place: it caught two regressions introduced by this very
change before either reached the wire.

Suite: 896 baseline + 56 new = 952 tests / 119 suites / 0 fail, confirmed by
both `npm test` and the per-file sum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Qwen answers an exhausted daily quota with `data.code = 'RateLimited'` and the
text "You've reached the upper limit for today's usage.". Neither path told the
client what that was:

  /v1/messages          HTTP 500 {"error":{"type":"api_error"}}
  /v1/chat/completions  HTTP 502 {"error":{"type":"upstream_error"}}

Both read as "the server is broken", so an agentic client retries against a wall
and burns another account from the pool on every turn. The native APIs answer 429
for exactly this reason. Observed live in the user's own sessions (2026-08-21).

The detection now lives once, in utils/upstream-error.js (isRateLimitError /
rateLimitRetryAfterSeconds / describeUpstreamFailure), and each controller only
translates it to its own wire shape: Anthropic 429 `rate_limit_error`, OpenAI 429
`insufficient_quota`. Both wire names sit next to each other in that file so the
twins cannot drift apart.

Mid-stream the HTTP status is already committed, so the error event (Anthropic)
and the error frame + [DONE] (OpenAI) carry the signal instead. On the OpenAI
agent path that is the ONLY channel: handleOpenAIAgentStream writes its opening
role delta before consuming upstream (chat.js:407), so a 429 status is
unreachable there by construction — pinned by a test that says so.

Retry-After only when the upstream actually sourced a wait: `data.num` is in
hours, the same reading chat.image.video.js:88 already does. No field, no header
— a fabricated wait is worse than none, because the client obeys it literally.

Everything that is not a quota refusal keeps its old status, type and code,
including the gate's deliberate 502 for protocol exhaustion.

Tests: 952 -> 971 (+19 new), 0 fail, 122 suites, per-file sum; every
pre-existing file's count unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… that governs it

maxBytes is what actually bounds the ledger on real traffic — a realistic entry
weighs ~223 B (ASCII) to ~333 B (long path), so the byte cap bites long before
maxEntries=40 does and the ledger never reaches its advertised 40 entries. The
test that landed in 1c31c6f pins maxEntries and deliberately passes
maxBytes: 1_000_000, so the only number production reads had no test at all.

Re-measured reach over 199 real Claude Code sessions with duplicates (18,008
tool calls, 1,970 re-issues of a call already made), not one. Reach = at the
moment the call is about to repeat, the ledger built from the prior history
still names the earlier instance:

   6,000 B  81.0%   5,315 B/request mean
   9,000 B  88.7%   7,612 B   (+7.8pp for +2,297 B = 67 onsets/KB)
  12,000 B  91.4%   9,276 B   (+2.6pp for +1,664 B = 31 onsets/KB)
  16,000 B  95.3%  11,836 B   (+3.9pp for +2,560 B = 30 onsets/KB)
  24,000 B  96.5%  14,761 B   (+1.3pp for +2,925 B =  9 onsets/KB)
  uncapped 100.0%  30,091 B   (+1.9pp for +12,549 B = 3 onsets/KB)

The single-session curve did not generalise, again: the worst session (33f8544e)
reads 59.8% at the shipped default, the 199-session aggregate reads 81.0%. Two
of the three mechanisms killed in this effort died of exactly that error, so the
value is chosen on the aggregate. The curve bends after 16,000; 12,000 sits on
the steep part and buys 10.4pp over the old default. Raising maxEntries instead
buys almost nothing: at 12,000 B, 40 -> 60 entries moves reach 91.4% -> 91.8%,
so maxEntries stays 40 and its pin stays untouched.

The externalisation-threshold objection turned out to be the weak half of the
argument. Over 25,576 real request boundaries, 71.2% were ALREADY past the
90 KiB threshold at the old cap (the median conversation is 169 KB), and going
to 12,000 newly pushes 116 of 25,576 = 0.45% of requests across. That is where
the bytes are needed: 76% of re-issues happen at already-externalised
boundaries, where reach was 77.0% at 6,000 and is 89.4% at 12,000.

Body-size delta measured through the real buildInternalRequest on all three
shapes: fresh turn 6,033 B unchanged (no tool history, no ledger);
no-tools-with-tool-history 267,773 / 1,249,735 B unchanged (the hasTools gate
holds); with tool history +4,382 B (+1.57%) at a 120-message prefix and
+5,020 B (+0.40%) on the full worst session.

The new test is a REACH test, the lower bound the suite never had: 60 heavy
calls that overflow the cap must still yield >=30 of them, newest-first, plus
the omission note, and it asserts <40 lines so it cannot silently drift back
into measuring maxEntries. Mutation drill on the default: 6000 fail, 9000 fail,
11000 pass, 12000 pass, 16000 fail, 24000 fail — pinned on both sides. The two
neighbouring "block never exceeds its cap" assertions had 6000-era ceilings
hardcoded (8192 and 6000); they now read the same constant, so a default raised
without measuring breaks them too.

Per-file test sum (the authoritative count; the glob run under-reports):
baseline at 08c724f = 971 across 43 files, 0 fail. Now 972, 0 fail. Exactly one
file changed count: tool-repetition 48 -> 49. 971 + 1 = 972. This run npm test
agreed at 972 / 122 suites / 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm test` reported 944, 970, 975, 983 and 986 tests on a byte-identical
tree, every one with `ℹ fail 0` and exit code 0. A run 42 tests short was
indistinguishable from a pass, so every quality claim in this branch rested
on a gate that lies.

Cause, confirmed rather than guessed: node propagates `--test-force-exit` to
the child process it spawns per test file — it is visible in the child's
`process.execArgv`. When a child finishes it calls `process.exit()`, which
does not flush stdout still buffered for the pipe back to the runner, so a
tail of that child's reporter output is discarded. The runner counts what
arrived, sees no failure, exits 0. Reproduced with no project code at all:
two throwaway files (3000 tests + 3 tests) under `--test-force-exit`
reported 3000 on 1 of 5 runs, the 3-test file vanishing whole; without
force-exit the same pair reported 3003 every time.

Dropping `--test-force-exit` is not available: 26 of the 43 files in tests/
never exit without it, because `src/utils/account.js` starts ref'd
`setInterval`s at module load and everything reaching `chat-helpers.js`
inherits them. The suite then hangs forever instead of finishing short.

So `npm test` now runs tools/test-gate.js, which checks the reported counts
against tests/expected-counts.json. Short run -> retried (the loss is a race,
so a genuinely deleted test is short on every attempt while a truncated one
is not) -> still short -> exit non-zero with the arithmetic spelled out. More
tests than expected also fails, so the baseline cannot rot. No summary, a
nonzero runner exit, or a watchdog timeout all fail loudly too.

Also fixes `npm test -- tests/foo.test.js`, which used to be a lie: the
package script's glob won and the whole suite ran anyway. The filter is now
honoured and the gate says it skipped itself.

Baseline: 972 (per-file sum at e5babf7) + 14 new gate tests = 986 tests /
122 suites / 0 fail, verified by two independent per-file sums.
…t the 429

Repairs 08c724f, which failed adversarial verification on two counts.

1. THE HEADLINE WAS FALSE FOR THE PATH THAT PRODUCED EVERY INCIDENT.

"map ... to 429 on both API paths" does not hold on /v1/messages with
stream:true — the only mode Claude Code runs, and the mode of every quota
refusal in the user's logs. handleAnthropicStream sets the headers (:1203) and
writes message_start (:1211) before reading one upstream byte, so res.headersSent
is already true when the quota packet arrives and the catch (:2665) can only take
the event branch. The 429 is unreachable there by construction.

That is not a defect to paper over: committing the response early is what makes
in-protocol `ping` legal (:1156-1160), which is how the bridge's false "dead
stream" was killed. The reachability matrix now lives in upstream-error.js and is
pinned by tests, so the claim cannot be made again:

  /v1/messages        stream:false  429  body.error.type
  /v1/messages        stream:true   200  event error.type      <- the user's path
  /v1/chat/... llano  stream:false  429  body.error.type
  /v1/chat/... llano  stream:true   429  body.error.type
  /v1/chat/... agente stream:true   200  frame error.type

Also fixed: the previous test comments credited the OpenAI agent path to Claude
Code. Inverted — Claude Code speaks /v1/messages.

The consequence was a real hole, not just wording. When the event is the only
channel, everything the Retry-After header would have carried has to travel
inside it, and the wait was being dropped. `retry_after` now rides in the
Anthropic error EVENT and the OpenAI error FRAME, on the same rule as the header:
only when the upstream actually sourced it (`data.num`, in hours).

2. THE BURN LOOP THE COMMIT CLAIMED TO FIX WAS NEVER CLOSED.

Its own justification was that a wrong status makes the client "burn another
account from the pool on every turn" — then nothing marked the exhausted account.
HTTP 4xx/5xx go to recordError, which deliberately does not cool down, and the
quota refusal is not even an HTTP status: it arrives inside a 200 SSE body that
request.js never inspects. Better status, same server-side loop.

AccountRotator gains recordQuotaExhausted() — a third class beside recordError
(no cooldown, account still valid) and recordFailure (needs maxFailures of
evidence). One refusal is conclusive. The account leaves the draw until the
upstream's own wait elapses, or one hour when it gave none: long enough to break
the hot loop, short enough that a misclassification cannot strand a good account
for a day. resetFailures deliberately does NOT lift it — account.js:516 calls
that for every account on the token-refresh timer, which would revive the loop by
itself. Both controllers report through one shared helper so the twins cannot
drift.

Dashboard consequence, closed here too: status.kind reads cooldownEndsAt, which
only the failure counter sets, so a benched account would have shown "active"
while rotation skipped it. cli-support now merges both cooldowns, latest wins.

Tests: 986 -> 1009 (+23), 122 -> 127 suites, 0 fail, per-file sum over 44 files;
only tests/upstream-quota-429.test.js moved (19 -> 42), every other file's count
byte-identical. The gate's own whole-suite run agrees at 1009/127. All 23 were
watched failing first.

NOT CLOSED — ccproxy, out of this repo. The user's transcripts hold 154 lines of
`Upstream error mid-stream: 500 You've reached the upper limit for today's
usage.`, and every one of the 281 mid-stream lines reports 500, whatever the
event said. The bridge synthesizes a fixed 500 and does not branch on error.type,
so this change does not reach Claude Code as a 429 until ccproxy maps
rate_limit_error itself. What does survive today is the message text and now the
wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hes the model

La verificacion adversarial tenia razon y el numero es peor de lo que estimaba.
Subir el tope a 12.000 con el ledger dentro de `envelope.prefix` no mejoraba nada:
EMPEORABA lo que el modelo lee, por debajo del 6.000 de partida.

Medido sobre 286 sesiones reales de Claude Code, 1.604 reemisiones de una llamada
ya hecha (el 96,1% en fronteras ya externalizadas). Alcance = con la llamada a punto
de repetirse, el ledger construido con la historia PREVIA todavia nombra la instancia
anterior; "bloque" es lo que el ledger contiene, "inline" es lo que sobrevive a
buildAgentContextLivePrompt, que es lo unico que el modelo lee:

                        bloque    inline
  antes, cap 6.000       73,3%     73,3%
  antes, cap 12.000      84,7%     39,7%   <- la subida, sola, costaba 33,6 puntos
  ahora, cap 12.000      84,7%     84,7%

En las externalizadas solas: 72,2/72,2 -> 84,0/37,4 -> 84,0/84,0.

Causa, exactamente la que se reporto: el prefijo se retiene inline recortado por
CABEZA Y COLA (headRatio 0.55) y el bloque va del mas nuevo al mas viejo, asi que la
rebanada de cola conservaba sus entradas VIEJAS y el hueco compactado se comia las
NUEVAS — la llamada que esta a punto de repetirse, la unica razon por la que el bloque
existe. Con 6.000 B cabia entero en esa cola por casualidad aritmetica; con 12.000 no.
No era pre-existente: lo creaba la subida.

Arreglo, en el orden que se pidio — primero la colocacion, despues el tope:
buildBudgetedAgentPrompt separa el ledger del prefijo y le da seccion propia, con
presupuesto reservado ANTES del reparto por pesos (tope: un cuarto del pool, que en
produccion no muerde) y recorte propio por renglones enteros, conservando los MAS
NUEVOS y con la nota de omision puesta. El tope de 12.000 y el pin de maxEntries=40
de 1c31c6f se quedan como estaban.

Rejilla de 48 sobres externalizados (94-384 KB crudos, 8-60 herramientas, 30-120
llamadas, system prompt de 3 a 50 KB): antes 0 de 48 completos —sobrevivian 23-24 de
30-37 entradas y en 44 de 48 faltaba la mas nueva—; ahora 48 de 48 completos. Cuesta
~2,9 renglones de historia reciente inline (12,0 -> 9,1 de media): ~2,5 KB por renglon
de resultado crudo a cambio de ~333 B por llamada nombrada, y 13-14 llamadas mas
nombradas. Se acepta a sabiendas.

Un agujero propio del arreglo, encontrado y cerrado aqui: reconocer el bloque solo por
su cabecera —o por cabecera + leyenda— dejaba que un texto del cliente que las imitara
se llevara el trato del ledger; sin ningun renglon `#n `, el recorte por renglones lo
dejaba en '' y borraba ~11,8 KB de reglas del cliente del prompt inline. Ahora hacen
falta las tres cosas: cabecera, leyenda literal y un renglon de entrada detras.

tests/tool-repetition.test.js 49 -> 53. Las cuatro nuevas se vieron fallar antes de
pasar: la de supervivencia inline falla en 41b2e4d con "la entrada mas nueva (Rfym21#60)
desaparecio del prompt inline; sobrevivieron 23 entradas Rfym21#46..Rfym21#24"; la de cabecera +
leyenda falla al quitar la tercera condicion. Ninguna prueba podia ver esto: todas
llamaban a buildToolHistoryLedger directamente y ninguna pasaba el bloque por el
presupuesto.

Tambien: el guardia del test del tope de entradas ya no compara contra un numero
escrito a mano (decia `< 4000` con el default en 6.000 y siguio verde al subirlo a
12.000, con el margen sin vigilar) sino contra el mismo bloque construido sin tope de
bytes — una igualdad que no envejece. Y se corrigen los dos comentarios de
anthropic.js y chat-middleware.js que afirmaban que el prefijo "nunca se externaliza",
que es la creencia que causo el fallo.

Suma por archivo, con guardia de "ningun archivo sin contar": 1009 (41b2e4d, 44
archivos, 127 suites, 0 fail) - 49 + 53 = 1013. Medido: 1013 / 127 / 0 fail, 44
archivos, ningun archivo a cero. Gate y lint en verde. 0 peticiones upstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial verification found the gate shipping two defects of its own,
one of them attacking the exact property the gate exists to guarantee.
A third, in the escape hatch, turned up while measuring the fix.

1. NOT_ALL_RAN. `evaluate()` read only tests/suites/fail. It parsed
   pass/skipped/todo/cancelled and asserted on none of them, so a disabled
   test — which keeps its slot in `tests` — was invisible. Marking all six
   tests of tests/harvest-media-cap.test.js `it.skip` (the file guarding the
   image-delivery twin-scan invariant) produced a PASS banner byte-identical
   to an honest run's, exit 0, `skipped 6` printed right there in the output.
   Unlike the truncation race this is deterministic: it survives all three
   retries and gets committed. Now checked as pass + fail === tests, before
   the count checks so a skipped-and-truncated run reports the cause that
   will not go away, and never retryable. Verified on node v24:
   tests = pass + fail + skipped + todo + cancelled; suites are not in pass,
   and a `todo` test is not counted as pass despite its check mark.

2. bless could only ratchet up, and lied when it could not. It seeded its
   running maximum from the baseline on disk, so deleting a test file ran
   three clean attempts reporting 998/124, printed "BLESSED: 1013 tests /
   127 suites", exited 0, wrote nothing, and left `npm test` permanently red
   with no documented escape. The max now runs over the attempts alone —
   computeBlessed() takes no baseline argument, so it cannot be floored by
   one — and bless refuses any unclean run, including one with skips, so a
   skip cannot be laundered into the baseline.

3. CI could never reach the watchdog: 600000 ms x 3 attempts = 30 min inside
   a 10-minute job. TEST_GATE_TIMEOUT_MS is pinned to 150000 (~30x the ~5s
   the suite actually takes) and timeout-minutes raised to 15, so a hung
   runner is reported as TIMEOUT instead of looking like CI infra flake.

4. The escape hatch under-counted too. Every place telling you to confirm the
   real number shipped a per-file sum whose grep lacked `-a`;
   tests/tool-prompt.test.js emits bytes that make grep call the stream
   binary and print "Binary file ... matches" instead of that file's summary
   line. Measured 892 where the truth was 1025 — off by exactly its 133
   tests, from the one command whose job is to be the trustworthy second
   opinion.

Two pre-existing fixtures described impossible runs (tests 944 with pass
972). A real truncated run loses tests and pass together, so they are
corrected to move in step; they were latent nonsense that only became
load-bearing once pass was asserted on.

Zero production files touched: `git diff --stat -- src/` is empty. The
image-delivery invariant, twin-scan lockstep, injection boundaries and the
angle-form ban are untouched by construction, and their guarding tests pass.

Tests 1013 -> 1026 (test-count-gate.test.js 14 -> 27, +13; suites 127
unchanged). Per-file sum, twice: 1026 tests / 127 suites / 0 fail / 0
skipped / 0 todo. Gate green 3/3 with zero retries needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ever measured

buildToolHistoryLedger's maxBytes default went 6000 -> 12000 on a REACH curve:
81,0% -> 91,4% of re-issues end up named by the block. Reach is a necessary
condition for the block to work, not evidence that it works, and the effect was
never measured -- there has never been an arm with the block off.

Its intervention CLASS is measured, though. The corpus carries a natural
experiment: a client hook that replaces the tool_result with "Wasted call --
file unchanged since your last Read. Refer to that earlier tool_result
instead.", 364 firings across 79 sessions. It is a strictly stronger version of
what the ledger says -- it sits inside the result the model just asked for,
names the specific offence, 95 B, impossible to miss -- and conditioned on the
population it fires in (Reads that are already re-issues) it is null:

  train    hooked 162/253 = 64,0%   unhooked 185/313 = 59,1%   RR 1,08 [0,90, 1,42]
  holdout  hooked  34/79  = 43,0%   unhooked  36/80  = 45,0%   RR 0,96 [0,59, 1,86]

Per-session sign test: 26 up, 12 tied, 22 down. One key carried 32 warnings and
the loop survived all 32. The train CI upper bound rules out any large benefit.
A weaker restatement further up-context cannot do more.

The cost is real. Measured on both paths, three shapes:

  fresh turn (tools, no tool history)      ledger 0 B      ->     0 B     0
  with tool history                        ledger 11.887 B -> 5.883 B  -6.004 B
  no-tools + tool history                  ledger 0 B      ->     0 B     0

Plus, on an externalised request, the block takes up to a quarter of the inline
pool (LEDGER_POOL_SHARE): ~2,9 lines of recent inline history, ~2,5 KB of raw
result each = ~7 KB of actual tool results evicted to name calls at ~333 B.
That is where 76% of the re-issues happen.

6000 and not 0: there is evidence the benefit is unmeasured and that its closest
analogue is null, but no evidence the block does harm. 6000 halves a certain
cost against an uncertain benefit and keeps the artifact so it can be A/B'd for
real (randomised BY SESSION, not per request).

The dedicated inline section from 258a658 stays -- that fix is independently
correct (it made the NEWEST entries survive the budget instead of the oldest)
and lowering the cap does not undo it; the block simply fits with more room.

New test measures the MECHANISM, not the constant: it counts the ledger's bytes
in the assembled content that goes upstream, on both paths. Verified it catches
a call site that passes its own maxBytes while the default stays at 6000 -- an
assert.equal on the constant would not.

Tests: 1026 baseline + 1 new = 1027 / 127 suites / 0 fail (per-file sum and gate
agree). Floors relaxed 30 -> 15 entries: 60 heavy calls now fit 18, not 35.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fix is truncated

The 6000 B cap stands. This adds the test that was outstanding across three
workflows -- does the ledger survive buildBudgetedAgentPrompt in the >92 KiB
regime -- and, in writing it, found that the revert to 6000 had quietly disarmed
the guard for the dedicated-ledger-section fix (258a658).

Measured by putting the ledger back inside envelope.prefix (weight 34,
headRatio 0.55), on the file's own externalised fixture, at the PRODUCTION
inline budget of 48 KiB:

  cap  6.000   18 of 18 entries arrive, byte-identical   -> regression INVISIBLE
  cap 12.000   23 of 37 arrive, Rfym21#46..Rfym21#24                 -> the 14 NEWEST lost,
                                                            header gone entirely

So at 6000 the block fits in the tail slice by arithmetic accident, exactly as
the code comment says it used to, and every assertion still passes. Confirmed
against the suite: with the regression injected, the pre-existing "la entrada
MAS NUEVA sobrevive" test PASSES. The only surviving guard was the neighbouring
test, and only at AGENT_CONTEXT_LIVE_PROMPT_BYTES=12000, which is not
production.

Two tests, both of which fail with that regression injected:

- survival: runs BOTH caps and asserts the block arrives byte-identical, not
  just that its newest entry survived. Two guards first -- request > 92160 B,
  and the compaction separator lands BEFORE the ledger -- so it cannot pass
  outside the regime it claims to measure. The 12000 arm stays although it is
  no longer the default: it is the only arm that watches the mechanism at the
  production budget.
- degradation above the section ceiling: with the production budget and this
  envelope shape the ledger's own section tops out at ~12,8 KB. At 6000 the
  block uses 5.882 B (under half of it) and arrives whole; at 12000, 11.886 B,
  grazing it; at 24000 it no longer fits and 37 of 40 arrive -- the 37 newest,
  whole lines, omission note intact. That is "raising the cap moves the block
  towards the cut, not away from it", as an assertion instead of a story.

Docs corrected where they overstated: agent-turn.js claimed the inline-survival
test was what pinned this, and request.js claimed the suite catches the
in-prefix regression. Both now say what is actually true, including that the
6000 arm is blind to it.

Tests: 1027 baseline + 2 new = 1029 / 127 suites / 0 fail. Per-file sum 1029,
gate 1029, blessed. eslint clean. No upstream calls, no dev server, no VPS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntarla

La verificacion adversarial de 40df21e encontro que ese commit desarmo la unica
guarda del arreglo de la seccion propia del ledger (258a658) y ademas escribio en
los comentarios que la guarda seguia en pie. fb904db ya rearmo la guarda y corrigio
los dos comentarios de src/. Reproducido aqui de forma independiente -- con la
regresion inyectada, tests/tool-repetition.test.js falla 3 veces en HEAD -- y
quedaban tres cosas.

1. Un tercer comentario de la misma clase seguia vivo, y en el propio archivo de
   tests: «ninguna pasaba el bloque por el presupuesto. Esta si.» El test que
   introduce esa frase corre al tope que se envia (6.000) y a ese tope NO ve la
   regresion: con el ledger devuelto al interior del prefijo pasa igual.

2. La correccion de fb904db introdujo una afirmacion nueva que tampoco es cierta:
   que el brazo de 12.000 es «el unico que vigila el mecanismo al presupuesto de
   produccion». Medido inyectando la regresion: el test de degradado (24.000)
   tambien la ve, al mismo presupuesto.

3. La ceguera se contaba como «casualidad aritmetica» de un fixture. No lo es. La
   rebanada de cola del prefijo mide ~7,1-7,8 KB con el presupuesto de produccion
   (medido: un bloque de 7.146 B cabe entero, uno de 7.778 ya no), asi que
   CUALQUIER bloque acotado a 6.000 B cabe. Es una relacion entre dos numeros que
   nadie vigilaba.

El test nuevo la vigila, y de paso convierte el arreglo de 258a658 en un
diferencial que no necesita parchear el codigo: entierra el bloque en el prefijo
dentro del fixture con una sangria de un byte -- la cabecera deja de estar a
principio de linea y splitAgentLedger no la reclama --, que es la forma que el
bloque tenia antes de tener seccion propia.

  cap  6.000   bloque  5.882 B   seccion 18/18   enterrado 18/18        IDENTICOS
  cap 12.000   bloque 11.886 B   seccion 37/37   enterrado 23/37, Rfym21#46   se ve

Reproduce exactamente los numeros que fb904db midio parcheando request.js.

Visto fallar antes, con dos perturbaciones y la suite entera:

- peso del prefijo 34 -> 20 (la cola encoge por debajo del tope): falla el brazo
  de 6.000 con «el brazo del default ha dejado de ser ciego a la regresion». Es el
  UNICO test de los 1.030 que lo coge.
- splitAgentLedger devuelto a no partir (deshacer 258a658): falla el brazo de
  12.000 con «llegaron 23 de 37 entradas». Lo cogen 4 tests, este entre ellos.

Sin cambio de comportamiento: el diff de src/ es solo comentarios.

Tests: 1029 + 1 = 1030 / 127 suites / 0 fail. Suma por fichero 1030 (44 ficheros,
ninguno perdido), gate 1030, expected-counts actualizado. eslint limpio.

Upstream: cero completions, cero dev server, cero VPS. Una unica llamada de AUTH
se disparo al principio al requerir los modulos con el .env real, que es lo que
hace tambien npm test en este repo; a partir de ahi todo se midio en copias del
arbol en el scratchpad con ACCOUNTS vacio, que reproducen 1029 exactos en HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ript path

The duplicate-onset replay harness carried a hardcoded absolute path to one
developer's Claude Code session as its default transcript. Session transcripts
are the operator's own work and their paths carry a username, so nothing in the
repo should name one.

--transcript / TRANSCRIPT now supply it and there is no default; the harness
exits 2 with the conventional location instead of guessing. The test that
pinned the old default now pins its absence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxff77 maxff77 closed this Sep 10, 2026
Rfym21 pushed a commit that referenced this pull request Sep 11, 2026
…ails under CI load

CI (ubuntu-latest, 2 vCPU) reported 1035 of 1074 tests with fail 0 and exit 0
on 3 of 3 attempts for a tree that is whole (main after #167), each attempt
missing the tail of different files. The same tree reports 1074 locally and on
an idle 4-core VPS, and that VPS reproduces the drop (1069) as soon as CPU load
is added. The loss is `node --test`'s parent runner losing the end of a child's
piped output on --test-force-exit; it is load-dependent, and CI is always loaded,
so the gate's retries could never save it.

tools/test-gate.js: stop using the parent runner. Every file runs as
`node --test --test-isolation=none --test-force-exit <file>`, its stdout is read
to EOF before anything is counted, and the per-file summaries are summed
(sumSummaries: a file with no summary voids the attempt as NO_SUMMARY, it is
never subtracted quietly). Bounded pool (TEST_GATE_CONCURRENCY, default
min(4, cores - 1)); one watchdog covers the whole attempt. --test-force-exit
stays: 26 files never exit without it.

.github/workflows/ci.yml: 300 s per attempt, 2 attempts (measured: ~10 s local,
77 s on the 4-core VPS at concurrency 1).

tests/tool-prompt.test.js (P14): threshold 400 -> 1500 ms. Measured 50 ms on a
Mac, 210-225 ms on the VPS idle, 445 ms under load; the quadratic regression it
guards was ~2 s, so 1500 still catches it and stops flaking on a shared runner.

tests/test-count-gate.test.js: three tests for sumSummaries. Baseline 1074 -> 1077.

Verified: gate PASS 1077/128 locally; on the VPS under six CPU hogs, single
attempt, 2 of 3 runs PASS 1074 and the third failed loudly on P14 — never a
short pass.
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.

1 participant