fix(inbox): stop rendering a body-less notification into an empty user text part - #1961
fix(inbox): stop rendering a body-less notification into an empty user text part#1961wqymi wants to merge 1 commit into
Conversation
…r text part
Inbox.drain persists renderInboxRow(row) verbatim as the only text part of a
synthetic role:"user" message, bypassing createUserMessage/hasSubstantiveContent.
renderInboxRow used `content.text ?? "(no notification body)"`, and `??` does not
catch "", so a body-less actor_notification rendered to exactly "". With one
queued row that is `parts: [{type:"text",text:""}]` — length 1, invisible to
every parts.length===0 guard — which ai@6.0.168's convertToLanguageModelMessage
filters to `content: []`, the shape a provider rejects with
"messages.<N>: user messages must have non-empty content".
Also restores the shell path's parity with the JSON path's content min(1):
shell-wrap routes a shell-parsed op straight to def.execute without
re-validating against `parameters`, so `actor send main ""` could queue the
body-less row in the first place.
|
Folded into #1948 ( Rationale: this PR identified the producer of the empty user text part; #1948 adds the pre-send invariant that backstops it. They are the root cause and the backstop of one defect (Bedrock 400 Commit Post-fold on #1948: Branch |
The producer of the empty user
contentA live Bedrock 400 (
messages.<N>: user messages must have non-empty content) has been traced through the SDK before:ai@6.0.168'sconvertToLanguageModelMessageuser branch drops empty text parts with no backfill (dist/index.mjs:1424,.filter((part) => part.type !== "text" || part.text !== "")), so a user message whose content was[{type:"text",text:""}]reaches the provider ascontent: []. What was never identified is who produces that empty text part. This is one such producer, end to end and reachable:src/inbox/render.ts:8—return content.text ?? "(no notification body)". The??only catchesnull/undefined; an empty body passes straight through as"". The placeholder was clearly meant to cover exactly this case.src/inbox/inbox.ts:275—drain()persiststext: renderInboxRow(row)verbatim, one part per queued row, viasessions.updateMessage+sessions.updatePart. This bypassescreateUserMessageand itshasSubstantiveContentguard entirely, andSession.updatePartvalidates nothing.[{type:"text",text:""}]— length 1, so everyparts.length === 0guard misses it, includingmessage-v2.ts:922.src/inbox/inbox.ts:162—Inbox.sendstorescontent: { text: input.content }with no emptiness check, and the reachable trigger isactor send main "" --type actor_notification: the JSON path declarescontent: z.string().min(1)(src/tool/actor.ts:454) but the shell path takescontent: rest[1]verbatim (:185) andshell-wrap.ts:121callsdef.execute(parsed, ...)without re-validating againstparameters.This also explains why a post-hoc DB sweep for "a user message with an exactly-empty text part" found nothing useful: the row that carries it is a synthetic drain part, and the queued inbox row that caused it is
DELETEd in the same drain.Fix
src/inbox/render.ts—||instead of??for both row types, so a body-less row always renders its placeholder. This makes "the text persisted bydrain()is never empty" an invariant of the renderer.src/tool/actor.ts— reject an emptycontenttoken in the shellsendverb, restoring parity with the JSON path'smin(1)that shell-wrap bypasses. The model gets a loud, self-correctable error instead of silently delivering a placeholder notification.Tests
test/inbox/empty-notification-part.test.ts(new) —renderInboxRownever returns an empty string for any row type x body combination, and an end-to-endInbox.send("") -> drain()writes a non-empty synthetic part (plus a mixed empty/real batch leaving no empty part behind).test/session/message-v2.test.ts— mechanism pin: an empty-text-only user message survives our layer atparts.length === 1(content: [{type:"text",text:""}]) and only collapses tocontent: []inside the SDK step that runs between us and the provider (convertToLanguageModelPromptfromai/internal). Sits next to the existing "filters out messages with no parts" test, which is the complementary proof that a zero-part user message is dropped by us and can never reach a provider.test/tool/actor.shell.test.ts—actor send main ""is rejected.Every test above was revert-probed: with
render.tsrestored to??, all 5 tests in the new file fail, the drain one withExpected: "(no notification body)" / Received: ""; with theactor.tsguard removed the shell test failsExpected: "Failure" / Received: "Success".Note
This is a producer fix, not a replacement for a pre-send invariant. The
ensureNonEmptyContentbackstop proposed in #1948 is still wanted as defence in depth — this PR does not touch it, and other producers of the same shape may exist (unvalidatedPATCH .../part/:partID, the import/migration paths,Session.fork, and compaction replay all copy part text without an emptiness check).