Skip to content

fix(inbox): stop the drain from persisting an empty user text part (the Bedrock 400 producer) - #1963

Closed
wqymi wants to merge 1 commit into
mainfrom
fix/session-msgshape-dual
Closed

fix(inbox): stop the drain from persisting an empty user text part (the Bedrock 400 producer)#1963
wqymi wants to merge 1 commit into
mainfrom
fix/session-msgshape-dual

Conversation

@wqymi

@wqymi wqymi commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What this is

The producer behind the live Bedrock 400 messages.<N>: user messages must have non-empty content.

64a128523 ("never PRODUCE an empty-content user message") guarded createUserMessage, and #1948 adds a pre-send backstop. Neither covers Inbox.drainthe one user-message producer that does not go through createUserMessage, so hasSubstantiveContent (prompt.ts:4386) never inspects it. This closes that producer.

#1948's ensureNonEmptyContent is deliberately left intact — defence in depth is wanted here.

Root cause

  • renderInboxRow (src/inbox/render.ts:8) used content.text ?? "(no notification body)". ?? only catches null/undefined; a blank body is stored as "" (inbox.ts:162 content: { text: input.content }), and an actor_notification body is passed through RAW with no wrapper. So a blank notification rendered to "".
  • Inbox.drain (src/inbox/inbox.ts:268-277) wrote one text part per row unconditionally, so that "" was persisted as a user message whose only part is {type:"text",text:""}parts.length === 1, which every content.length === 0 check misses.
  • ai@6.0.168's convertToLanguageModelMessage user branch filters empty text parts out with no backfill (.filter(p => p.type !== "text" || p.text !== "")), so that message reaches the provider as content: [] → the 400.
  • The blank body is reachable: the actor tool's JSON path guards content with z.string().min(1) (src/tool/actor.ts:454) but the shell path (src/tool/actor.ts:177-189) did not, and shell-wrap.ts:121 hands a parsed shell op straight to execute without re-validating it against parameters.

Changes (three layers, outermost first)

  1. src/tool/actor.ts — the shell send parser rejects a blank content, restoring parity with the JSON path's .min(1).
  2. src/inbox/render.ts — a blankTo helper substitutes the placeholder for any blank body, not just a missing one, for both row kinds. A blank notification is now visible to the model as (no notification body) rather than silently empty, so nothing is lost.
  3. src/inbox/inbox.ts — the drain renders before writing anything and drops any row that renders blank; if every row renders blank it consumes the rows without writing a message at all. This is the structural invariant that keeps the fatal shape unreachable regardless of what a future row type renders.

Tests

  • test/inbox/drain-no-empty-part.test.ts (new, 7 tests): renderInboxRow never returns a blank string (empty / whitespace-only / missing body, plus verbatim passthrough for a real body); end-to-end, a blank actor_notification drained into a session yields exactly one non-blank synthetic part, and a blank mixed with a real one keeps both parts non-blank.
  • test/tool/actor.shell.test.ts (+4): the shell send parser rejects "" and whitespace-only content, and still accepts a short non-blank one.

Each test proven to fail without its src change

revert result observed failure
render.ts alone 5 fail the drain filter catches the blank row, so the notification is dropped: expect(count).toBe(1)Received: 0
render.ts and inbox.ts (= pristine main) 5 fail the original defect shape is persisted: expect(received).not.toBe("")Expected: not ""
tool/actor.ts alone 3 fail expect(exit._tag).toBe("Failure")Received: "Success"

Verification

bun typecheck exit 0. Affected suites (test/inbox/, test/tool/actor.shell.test.ts, test/tool/session-tool.test.ts): 127 pass / 1 skip / 0 fail.

Full bun test was run on a superset of this diff; its 15 failures are all pre-existing load-flaky families on the build box (the prompt-effect loop/cancel/busy suite, Vcs branch watchers, workflow timeouts) and none touch inbox, render, or the actor shell parser.

Not in scope

Defect B — recoverSessionArgs silently rewriting a correct send into a create — ships separately as #1962.

…he Bedrock 400 producer)

`64a128523` made createUserMessage refuse to PRODUCE an empty-content user
message, and #1948 adds a pre-send backstop. Neither covers `Inbox.drain` —
the one user-message producer that does NOT go through createUserMessage, so
`hasSubstantiveContent` (prompt.ts:4386) never inspects it. This closes that
producer.

Root cause:
- renderInboxRow (inbox/render.ts:8) used `content.text ?? "(no notification
  body)"`. `??` only catches null/undefined; a blank body is stored as ""
  (inbox.ts:162 `content: { text: input.content }`), and an
  `actor_notification` body is passed through RAW with no wrapper. So a blank
  notification rendered to "".
- Inbox.drain (inbox/inbox.ts:268-277) wrote one text part per row
  unconditionally, so that "" was persisted as a user message whose only part
  is {type:"text",text:""} — parts.length === 1, which every
  `content.length === 0` check misses.
- ai@6.0.168's convertToLanguageModelMessage user branch filters empty text
  parts out with NO backfill (`.filter(p => p.type !== "text" || p.text !==
  "")`), so that message reaches the provider as `content: []` and is rejected
  with `messages.<N>: user messages must have non-empty content`.
- The blank body is reachable: the `actor` tool's JSON path guards content with
  `z.string().min(1)` (tool/actor.ts:454) but the SHELL path
  (tool/actor.ts:177-189) did not, and shell-wrap.ts:121 hands a parsed shell
  op straight to execute without re-validating it against `parameters`.

Changes (three layers, outermost first):
1. tool/actor.ts — the shell `send` parser rejects a blank content, restoring
   parity with the JSON path's `.min(1)`.
2. inbox/render.ts — a `blankTo` helper substitutes the placeholder for any
   BLANK body, not just a missing one, for both row kinds. A blank
   notification is now visible to the model as "(no notification body)"
   rather than silently empty.
3. inbox/inbox.ts — the drain renders BEFORE writing anything and drops any
   row that renders blank; if every row renders blank it consumes the rows
   without writing a message at all. This is the structural invariant that
   keeps the fatal shape unreachable regardless of what a future row type
   renders.

Tests:
- test/inbox/drain-no-empty-part.test.ts (new): renderInboxRow never returns a
  blank string (empty / whitespace-only / missing body, plus verbatim
  passthrough for a real body); end-to-end, a blank actor_notification drained
  into a session yields exactly one NON-blank synthetic part, and a blank
  mixed with a real one keeps both parts non-blank.
- test/tool/actor.shell.test.ts: the shell `send` parser rejects "" and
  whitespace-only content, and still accepts a short non-blank one.

#1948's ensureNonEmptyContent is deliberately left intact — defence in depth.
wqymi added a commit that referenced this pull request Jul 28, 2026
…sts a blank part

Consolidates the "empty content reaches the provider" family into one PR.
Supersedes #1963, keeping its stronger version wherever the two overlapped:

- src/inbox/render.ts: `blankTo()` helper, so any BLANK body (not merely a
  missing one) gets the placeholder. Replaces the bare `??` -> `||` change.
- src/inbox/inbox.ts: the drain renders BEFORE writing and drops any row that
  renders blank; if every row renders blank it consumes them without writing a
  message at all. This is the structural invariant — the drain is the one
  user-message producer that bypasses createUserMessage/hasSubstantiveContent
  (src/session/prompt.ts:2164, :4386), so it needs its own guarantee.
- src/tool/actor.ts: keeps the shell-boundary rejection, upgraded to `.trim()`
  so whitespace-only bodies are rejected too (zod's `min(1)` accepts " ").

Also corrects a comment that asserted the opposite of the truth: a shell-parsed
op IS re-validated against `parameters`. shell-wrap.ts calls `def.execute(parsed)`
on the def from Tool.init, which is wrap()-decorated, and wrap() runs
`parameters.parse(args)` inside execute. Verified end-to-end: with the
parse-level guard removed, the real shellWrap route still enqueues nothing and
reports "Too small: expected string to have >=1 characters -> at
operation.content". A blank `actor send` was never reachable, so the render/drain
work closes a LATENT defence gap rather than a live producer. New test
test/inbox/empty-notification-reachability.test.ts drives that composition.

The send-side backstop `ensureNonEmptyContent` (src/provider/transform.ts) stays:
it remains the guard for the other message producers.
@wqymi

wqymi commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Folded into #1948 and closing this in favour of it. The user's rule is that one defect family is one PR, and this PR and #1948 were editing the same two files (src/inbox/render.ts, src/tool/actor.ts) with two different versions of the same fix.

Nothing from here was dropped — this PR's version won every overlap:

Verified in the fold: reverting src/inbox/inbox.ts + src/inbox/render.ts to main on the merged branch produces 10 failures split across both sides' test files (5 from this PR's drain-no-empty-part.test.ts, 5 from #1948's empty-notification-part.test.ts), so the fold neutralised nothing.

Two corrections to the framing carried over, worth recording here:

  1. This PR's src/tool/actor.ts comment ("shell-wrap hands a parsed shell op straight to execute WITHOUT re-validating it against parameters") is false, and fix(provider): never send a message with empty content (the Bedrock/Anthropic 400) — producer + backstop + inbox invariant #1948 now carries a corrected version. shell-wrap.ts:121 calls def.execute(parsed, ctx) on the def from Tool.init, which is wrap()-decorated, and wrap() runs parameters.parse(args) inside execute. Driving the real composition shellWrap(Tool.init(actor)).execute({ script: 'actor send <id> ""' }) with this PR's guard removed still enqueues nothing and reports Too small: expected string to have >=1 characters → at operation.content. A revert probe on parseActorScript cannot settle this — asserting that the parser fails and then removing the parser's guard is circular.

  2. Consequently the render/drain work is described in fix(provider): never send a message with empty content (the Bedrock/Anthropic 400) — producer + backstop + inbox invariant #1948 as closing a defence gap that no observed transcript exercised (0 user text parts with text = '' in a 5.6 GB production DB), not as fixing an observed producer. The actual producer turned out to be elsewhere and is now the headline of fix(provider): never send a message with empty content (the Bedrock/Anthropic 400) — producer + backstop + inbox invariant #1948: ensureTrailingUserMessage appended { role: "user", content: "Continue." } as a bare string into a LanguageModelV3Prompt, and @ai-sdk/anthropic iterates a string character-by-character with no default case, shipping {"role":"user","content":[]}.

The guard added here is kept on its own merits: it turns a generic zod dump into one specific, teachable message, and .trim() rejects whitespace-only bodies which min(1) accepts.

Branch fix/session-msgshape-dual is left in place (not deleted).

@wqymi wqymi closed this Jul 28, 2026
@wqymi

wqymi commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favour of #1948, which now carries this entire defect family in one PR (the user's rule: one defect family, one PR — not scattered across three).

Nothing here is lost. #1963's version won every overlap:

Verified the fold did not neutralise anything: reverting inbox.ts + render.ts to origin/main on the merged branch fails 10 tests, split 5/5 across both folded test files. Folded branch runs 535 pass / 0 fail / 24 files vs this branch's pristine 507 pass / 0 fail / 21 files, identical command. bun typecheck exit 0.

Two corrections applied while folding, both worth reading:

  1. The shell-boundary guard's comment (carried by both PRs) asserted that a shell-parsed op is "NEVER re-validated against parameters". That is falseshell-wrap.ts:121 calls def.execute(parsed) on the def from Tool.init, which is wrap()-decorated, and wrap() runs parameters.parse(args) inside execute. Driving the real composition with the guard removed still enqueues nothing and reports Too small: expected string to have >=1 characters → at operation.content. The guard is kept for message quality and for the whitespace case, not for reachability. Comment corrected in fix(provider): never send a message with empty content (the Bedrock/Anthropic 400) — producer + backstop + inbox invariant #1948.
  2. The real producer of content: [] turned out to be elsewhere and is now fix(provider): never send a message with empty content (the Bedrock/Anthropic 400) — producer + backstop + inbox invariant #1948's headline: ensureTrailingUserMessage appended { role: "user", content: "Continue." } as a bare string into a LanguageModelV3Prompt, and @ai-sdk/anthropic iterates a string character-by-character through a switch (part.type) with no default, shipping content: []. Wire-proven against a captured outbound HTTP body.

The branch fix/session-msgshape-dual is left in place (not deleted).

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