fix(ai): read every text block from the model, and fail loudly on none - #223
Conversation
Two defects in one four-line read. `message.content[0].type === 'text' ? message.content[0].text : ''` only ever looked at the FIRST content block. Anthropic returns an array, and any non-text leading block — a tool_use, a thinking block — made the whole response read as empty even though the text was sitting in blocks 1 and 2. Multi-block text answers were silently truncated to the first block. It also indexed `content[0]` without checking the array had an element, so an empty `content` threw a TypeError that the surrounding catch swallowed into the generic fallback. The crash was invisible; only the apology reached the reporter. `extractResponseText` joins every text block in response order and returns '' for an empty array. An empty result is now an explicit throw rather than an empty string handed downstream, which routes it through the existing catch and produces the intended `degraded: true` fallback instead of publishing a blank answer that would still have marked the ticket answered. Split out of #187 on purpose: these are the only non-worker files in that branch, and three response-quality issues live in generator.ts (#149, #146, #178). Landing them separately unblocks that work now rather than after worker correctness. Advances #178 Verified: packages/outpost — 61 files, 1013 tests pass.
CPK-7925 3. Open + merge the third split PR — worker correctness (unblocks response quality)
The third of the three PRs split out of #187. Not yet opened — #191's description names it as "third (worker correctness) to follow." Scope — derived from the files #187 carries that neither split doesRoadmap issues it should close or advance
Worth confirming that list against the actual diff when the PR is opened rather than assuming it — the file overlap is strong evidence of scope, not a promise of what the code does. Why this is the response-quality gate
Until this lands, work on any of the three either conflicts with this branch or gets rewritten by it. One thing to decide when opening it: whether the CPK-7981 Review + land PR #223 — generator text blocks / fail loudly (4 findings open)
CopilotKit/outpost#223 by jerelvelarde, opened 2026-08-19 12:38Z off Smallest of the three. Answers outpost#178 — "decide the generator's empty-response behaviour" — and the title says the decision was fail loudly, which is the right direction: the alternative is an empty or apology reply posted publicly as though it were an answer. Note on how this arrivedI had scoped CPK-7925 as one worker-correctness PR and flagged that Where to look
cr-loop complete 2026-08-19 — 4 mandatory findings, do not merge as-isTier 1 (36 LOC, single module, no shared mechanism). Panel of 3. Round 1 complete, 3/3. Convergence not declared; no fix cycle run. Review comment posted. The concatenation half is correct —
Pre-existing and flagged for the record, not asked of this PR: Ledger + full reports: |
|
Hey @jerelvelarde, Replacing my earlier comment here too, same reason as the other two: re-ran with mutation testing enforced, and it both found blockers I'd missed and retracted two things I'd claimed. Verdict: NEEDS CHANGES. Two blockers, both coverage rather than logic. The direction of this PR is right and the base behavior was worse, so this is close. Approved
Splitting the generator work out of #187 was the right call. It unblocked #149 and #146 earlier than my ticket predicted. And the base behavior this replaces was genuinely worse: a blank answer published at full retrieval confidence. BlockersB1. The helper's wiring into
|
…onse guard Both blockers were coverage, not logic. Reverting the call site in `generate()` to the pre-PR first-block-only expression left 254/254 passing, because the new test only exercised the exported helper — so a bad merge or a later refactor could restore the exact defect this PR fixes with CI green. #187 touches this file, which is where that would come from. Relaxing `!responseText.trim()` to `!responseText` also left the suite green, and whitespace-only is the mode the API can realistically produce for this call shape, so the untested half was the reachable one. Drives a `[thinking, text]` response through `generate()` via aimock's `reasoning` option, asserting the answer survives and `degraded` is false; and a `' \n '` fixture asserting the fallback. Both new tests now die under their mutation. The existing empty-response test also asserts the reason, not just the fallback text: aimock builds `content: ''` as `[{type:'text', text:''}]`, but a real `content: []` reaches the same fallback via TypeError, so without that assertion the test could pass for the wrong reason. Also from review: - Join text blocks with a blank line instead of concatenating. Two text blocks are only adjacent because something non-text sat between them, so they were separate emissions — gluing them yields `...first step.Next you...`. Filtering explicitly rather than mapping non-text to `''` makes the separator apply where it should and nowhere else. - Type the fixture as `Anthropic.ContentBlock[]` instead of casting through `unknown`. The cast was hiding real drift: `ToolUseBlock` now requires `caller`, and the typecheck said so the moment it was removed. 23 tests in the file, 1015 in the package. Typecheck adds no errors over base (same 10, all unbuilt-`shared` module resolution).
|
Both blockers fixed in B1 — call site untestedConfirmed exactly as you described: reverting Used your aimock recipe. Verified the shape myself before relying on it — B2 — the
|
|
Hey @jerelvelarde, Verified
Each kills exactly one test, so the new tests are doing the work rather than something incidental catching it. The thinking test calls the real Your Head 1015, base 1013, delta +2. Nothing regressed. Three notes, none of them gating, all fine as follow-ups: One correction to the commit message. You wrote that a real
Diff noise. The commit carries prettier reformatting across The four non-blocking items are all still open and that's fine, they were never gating: |
There was a problem hiding this comment.
Approval stands — I modified a comment in the docs so it's accurate now.
Note the first one is at generator.test.ts:135-137 at head, not 132-134 as I wrote in my last comment.
Once both are committed I'll re-run the B1/B2 mutation matrix at the new head and confirm the test count and typecheck delta are unchanged.
Advances #178. First of the two PRs splitting the remainder of #187 — see "Relationship to #187" below for why this is separate.
Two defects in one four-line read:
1. Only the first content block was ever read. Anthropic returns
contentas an array. Any non-text leading block — atool_use, a thinking block — made the entire response read as empty even though the text was sitting in blocks 1 and 2. Multi-block text answers were silently truncated to the first block.2.
content[0]was indexed without checking the array had an element. An emptycontentthrew aTypeError, which the surrounding catch swallowed into the generic fallback. The crash was invisible in the logs' shape — you'd see the apology fallback and aGeneration failed:reason, with nothing indicating the real cause was our own indexing rather than the model or the API.The fix
extractResponseTextjoins every text block in response order and returns''for an empty array. An empty result is then an explicit throw rather than an empty string handed downstream.That throw is the deliberate part. It routes through the existing catch and produces the apology fallback — instead of publishing a blank answer that, under the one-response-per-ticket rule, would still have marked the ticket answered and spent the reporter's single response on nothing. Failing loudly here is what makes the ticket recoverable.
One correction to an earlier version of this description, per review: the mechanism doing the work on that path is
confidenceScore: 0, not thedegradedflag. The generator'sdegradedis dead —pipeline.ts:218reads onlyconfidenceAssessment.degraded. That's pre-existing (the base returned it too) and out of scope here, but the flag is not what makes this safe.Relationship to #187
generator.tsandgenerator.test.tsare the only non-worker files in #187's remaining nine, and CPK-7925 flagged the question of whether they belong with worker correctness at all. They don't: three response-quality issues live ingenerator.ts— #149 (temperaturesent unconditionally), #146 (streaming path skips the groundedness gate), #178 (empty-response behaviour) — and holding them behind a worker-correctness review round buys nothing.Landing these two files on their own unblocks that work now. Worker correctness (
worker.ts,create-job.ts,health.ts,railway.toml, and the job-claim-token migration) follows as its own PR.Cherry-picked from
fix/ai-response-delivery-durabilityunmodified, so the diff is byte-identical to what #187 already carried — nothing here is newly written or re-derived.Verification
packages/outpost: 61 test files, 1015 tests passing. Four tests cover the two halves and their wiring:[thinking, text]response driven throughgenerate(), so the call site itself is pinned — reverting it to first-block-only while leaving the helper intact previously left the whole suite greenno usable text) rather than only the fallback copy.trim()half of the guard — relaxing it to!responseTextpreviously left the suite greenNote for anyone running this locally: a fresh worktree needs
pnpm installandnpx prisma generate --schema=db/prisma/schema.prismabefore the suite is green — without the generate stepqueue/src/__tests__/scheduler.test.tsfails to load on.prisma/client/defaultand reads like a real failure. pnpm's ignored-build-scripts warning is the only hint.