fix(session): stop persisting empty-shell assistant messages and repair the readers that assumed the row exists - #1947
Open
wqymi wants to merge 3 commits into
Open
fix(session): stop persisting empty-shell assistant messages and repair the readers that assumed the row exists#1947wqymi wants to merge 3 commits into
wqymi wants to merge 3 commits into
Conversation
wqymi
force-pushed
the
fix/no-empty-shell-assistant-t86
branch
from
July 29, 2026 16:46
1da2f1b to
729c517
Compare
…sages A turn killed by a hard provider error leaves an assistant row carrying only bookkeeping parts (step-start/step-finish/patch) or no parts at all. Those shells accumulate — 123 zero-part APIError rows in one live session — and each one is replayed into the next request, so a single bad turn can escalate into dozens of provider 400s. Write side (SessionProcessor.cleanup): do not persist a contentless errored assistant; delete the row instead. The error is not swallowed — `halt` already published it on Session.Event.Error and reset status, and a new `Handle.dropped` flag makes SessionPrompt report the turn from the in-memory message, so `lastAssistant` can no longer return an older successful assistant and mask the failure. A user abort is exempt: a cancelled turn is a normal outcome the transcript should keep showing. Read side (backstop for shells older builds already wrote): - toModelMessages emits a user message only when it converted to at least one part, symmetric with the existing assistant guard. Previously the message was pushed before its parts were filled and only an incidental `parts.some(...)` filter kept empty content off the wire. - The assistant error gate now uses the shared hasSubstantiveAssistantContent predicate rather than an inline part-type check that counted step-finish and patch as content. - sweepOrphanAssistants is reduced to the minimal rule "history ends in an incomplete assistant → drop it", removing the one-hour age gate that kept orphans on disk poisoning requests. Callers now gate on the session being idle, which is what makes the age check unnecessary and keeps an in-flight assistant from ever being removed.
wqymi
force-pushed
the
fix/no-empty-shell-assistant-t86
branch
from
July 29, 2026 16:52
729c517 to
64bcd6d
Compare
…rn-lifecycle The empty-shell guard deletes a contentless errored assistant row, so "how did this turn end?" can no longer be answered by re-reading the session. runLoop's tail already handles that via `droppedShell`, but `firePostSession` is a SECOND, independent reader of the turn's final assistant — it re-derives it from the persisted slice with filterCompactedEffect + findLast(role === "assistant"). With the row gone, findLast skips the hole to an older successful assistant (or finds none), so `finalIsError` went false and an `llm.error(400)` turn reported outcome "completed". That silently mis-reported the `session.post` plugin outcome and, since #1851 (merged 7167c47) derives the MCP turn-lifecycle `status` from the same two flags, published `status: "completed"` for a failed turn — breaking "MCP lifecycle emits one error notification when the outer run fails". Consult `droppedShell` first here too, mirroring the precedence runLoop already uses for `final`. Parts are empty because the row is gone.
…eleted context watermark
Auditing every reader of the assistant row this branch deletes turned up two
write-side defects that are mirror images of each other, both in
sweepOrphanAssistants, plus one reader that expected the row to exist.
1. sweepOrphanAssistants read `agentID: "*"` and removed the session-global
newest incomplete assistant. SessionProcessor publishes status for the main
slice alone — the same fact the adjacent sweepOrphanToolParts comment calls
load-bearing — so a background actor can be mid-turn while session status
reads idle. That row is then exactly what the sweep deletes, out from under
the actor's own processor. Stamping (the old behaviour) was recoverable;
deleting is not. The sweep now reads the main slice only, which is all the
idle gate proves.
2. Restricting it to the TRAILING message stranded every non-trailing orphan
forever. The sweep runs before createUserMessage, so a single-slice orphan is
trailing — but one is left non-trailing by a concurrent subagent row or by a
previous source:"spawn"|"hook" prompt that skipped the sweep. The TUI's
`pending` marker derives from the newest INCOMPLETE assistant regardless of
what follows it, so a stranded row keeps rendering fresh messages as stuck
QUEUED — the exact symptom this sweep exists to prevent. Measured on the
local 5.4 GB DB: 85 such rows (78 mid-slice, 7 last-in-slice-but-not-
last-in-session) across 33 sessions with 2+ incomplete assistants.
3. filterCompactedEffect resolved a fork's contextWatermark by exact message id
and fell through to the FULL parent history when the row was missing. A
`context: "full"` subagent captures that watermark from lastMainMessageID,
so a spawn issued inside a turn captures that turn's in-flight assistant —
precisely the row the empty-shell guard deletes. The child then silently
inherited more context than it was registered for. MessageID is monotonic
ascending, so the boundary survives the row; the fallback now truncates by id
instead. The resolved path is untouched and stays authoritative for array
order, which is by time_created and can diverge from id order.
The replaced assertion in prompt-sweep.test.ts ("leaves history whose last
message is a user message untouched") pinned defect 2 directly: its rationale
covered only the replay path, which the read side already closes, and not the
stuck-QUEUED path the age-independent sweep was introduced for.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Read this first: this PR does not fix a live outage. The justification it was opened with has expired, and the honest current one is narrower.
What actually motivated it (evidence kept, conclusion retired)
A turn killed by a hard provider error leaves an assistant row carrying only bookkeeping parts (
step-start/step-finish/patch) or no parts at all. In one live session (ses_0c3e12b6affeoSCn4d940Q5gIT) that produced 123 zero-partAPIErrorassistant rows plus 3 rows whose only parts werestep-finish(×2) orpatch(×1), and 62 messages in that slice carrymessages.<N>: user messages must have non-empty content(newest atmessages.260/messages.230). That history is real and stays on the record.What has not survived is the causal story built on top of it — that these shells are still being replayed into later requests and still escalating into 400s. Two independent measurements retired it:
1. This PR's own replay experiment. Replaying that session's real persisted history (4228 main-slice messages) through the current converter and the full pre-send pipeline (
toModelMessages→ProviderTransform.message, Bedrock model):Today's
mainemits no zero-content message from that history.2. The read side is behaviourally redundant. All four of this PR's read-side assertions were ported into a probe and run on pristine
main: 4 pass / 0 fail.mainalready closes that path atsession/message-v2.ts:699—if (msg.parts.length === 0) continuesession/message-v2.ts:912—if (assistantMessage.parts.length > 0)session/message-v2.ts:941—result.filter((msg) => msg.parts.some((part) => part.type !== "step-start"))provider/transform.ts:364, wired at:973— merged fix(provider): never send a message with empty content (the Bedrock/Anthropic 400) — producer + backstop + inbox invariant #1948'sensureNonEmptyContent⇒ The 400s are already prevented on
main. Nothing in this PR is required to stop them.So what is left worth merging
The read side: making an incidental guard explicit.
main's protection is a side effect of aparts.some(...)filter that exists for a different reason. Delete that incidental filter and the difference shows up immediately:parts.some(...)filter removedmainfilters out messages with only ignored parts,drops a user message whose parts all convert to nothingSame observable behaviour today; different resilience to the next edit of that filter. That is the read side's entire value, and it is a clarity argument, not a bug fix.
The write side: a structural trade, with a cost this PR paid in public. Instead of requiring every reader of "the last message" to remember to filter an empty shell, the shell does not exist. But the trade is real and runs both ways:
That is not a hypothetical cost.
firePostSession(prompt.ts) is a second, independent reader that re-derives the turn's final assistant from the persisted slice. With the row deleted itsfindLastskipped the hole to an older successful assistant,finalIsErrorwent false, andlifecycleStatuspublished"completed"for a failed turn — breaking merged #1851's contract and silently mis-reporting thesession.postplugin outcome. CI caught it; inspection had not. Commit128849986fixed that reader by consultingdroppedShellfirst.Deleting a persisted row therefore creates an obligation to audit every reader that assumed it existed. That audit is below, and it is what discharges the cost.
Reader audit — every site that reads "the last message"
Re-derived on the branch head (line numbers below are from this branch, not
main). Verdict per site, with the reasoning tied tofile:line.Two measurements against the local 5.4 GB DB decide most of it:
APIError160,UnknownError37,InvalidOutputError12,ContentFilterError1,ContextOverflowError1.finish = NULL; 15 carryfinishset.ctx.assistantMessage.finishis only assigned incase "finish-step"(processor.ts:588), which a hard 400 never reaches — butInvalidOutputError&co. do. Sofinish-gated readers are not automatically safe, and are assessed as affected.checkpoint.ts:258findLastIndex(assistant && finish !== undefined)finish-set shells, deletion moveslastAsstIdxearlier. Removing an element can never makefindLastIndexselect a later message, andstartIdx = lastAsstIdx - 1then only walks backward ⇒ the boundary can only move earlier or stay. Boundary = first message to preserve, so it can only preserve more, never discard more. No data loss possible in this direction. Separately, the boundary ID can never dangle: it is always≤ lastAsstIdx - 1, i.e. strictly before the last assistant, while both deletions (cleanup,sweepOrphanAssistants) only ever remove a row that is last in its slice.compaction.ts:241findLastIndex(m => m.info.id === parentID)usermessage (throwat :243 otherwise). Deleting an assistant cannot change which row hasid === parentID; the index shifts but is consumed within the same post-deletion array.compaction.ts:255findLastIndex(i < parentIdx && role === "user" && parts.some(compaction))role === "user"; the deleted row is an assistant and can never be selected.compaction.ts:296messages.at(-1)?.info.id === parentIDat(-1)was the shell and the parent marker was fed to the summarizer; with the shell gone,at(-1)is the parent andslice(0, -1)correctly drops the marker. The check exists precisely to exclude the marker, so the deletion makes it fire as intended. Direction is exclusion of a synthetic marker, not of user content.llm-request-prefix.ts:50findLast(m => role === "user")role === "user"; no index arithmetic, no positional dependency.message-v2.ts:1023slice.at(-1)(was:996onmain)page().sliceis the DB rows actually returned;tailis the oldest row of the page and is encoded as the cursor. A deleted row is simply absent from the result set — the cursor still names a row that exists.prompt.ts:787findLast(msg => role === "user")insertReminders' user-message target; filtersrole === "user".prompt.ts:824findLast(msg => role === "assistant"):944asassistantMessage?.info.agent === "plan". With a plan-mode shell deleted, this reads the previous assistant; the two branches diverge only at the moment plan mode is entered, where the effect is that the full plan prompt is injected onto the new user message instead of the short "still active" reminder. No duplicate stacking (the:950guard is per-message), no data loss, and the fuller prompt is the safer of the two.prompt.ts:950input.messages.at(-1) !== userMessagecleanupdeletes only at turn end. Across turns,sweepOrphanAssistantsruns beforecreateUserMessage(prompt.ts:2474), so the new user message is last either way.prompt.ts:1473findLast(assistant && !!finish)finish-set shells,lastFinishedbecomes an older real assistant. Effect: itstokensfeedcontextPressureLevelandslice(lastFinishedIndex + 1)covers more messages ⇒ pressure reads higher ⇒ a shorter MCP tool-search description. Conservative direction, and a real assistant's token counts are the more accurate input than a shell's.Sites beyond the ten, in the same class as
firePostSession(post-turn result derivation) — this is where the risk actually lives:prompt.ts:4188final = droppedShell ? … : lastAssistant(…)128849986. Verified reachable on every drop, not just some: a drop impliesassistantMessage.error, which forcesreturn "stop"atprocessor.ts:901, which forcesoutcome === "break"atprompt.ts:4003— the only branch that assignsdroppedShell. ThedroppedShell = undefinedreset on thecontinuepath is therefore unreachable after a drop.actor/spawn.ts:276if (info?.role === "assistant" && info.error) fail(…)runLoop's return value, so the samedroppedShellpatch is what lets a subagent's failed turn still surface as a failure instead of an older success. Worth naming: one fix repaired two readers.actor/waiter.ts:67,actor/group.ts:99entry.lastOutcome === "success"/outcome === "success") before reading the last assistant. A dropped turn is never a success.prompt.tsgoalGatefindLast(assistant)?.info.idprompt.ts:695predictreturn "", instead of running a prediction off a contentless shell.prompt.ts:3983assistantFinalText(handle.message, MessageV2.parts(handle.message.id))and:4014parts:cleanupruns asEffect.ensuring(processor.ts:896), so these read the row after deletion and get[]. Classification only inspectspartsfortool/text;[]and[step-start, step-finish]are indistinguishable to it, and a shell has no final text either way.prompt.ts:3123-3131loop's backward scan forlastAssistant/lastFinished, consumed byclassifyAssistantStepat:3192phase === "existing-assistant" && !(lastUser.id < assistant.id) → continue) absorbs the hole: the older assistant predateslastUser, so it classifiescontinue, exactly as the 195finish-NULL shells did via guard #2 onmain. Across turns both sides agree for the same reason.tool/session.ts:83,94,tool/session.ts:1183-1184,acp/agent.ts:99agentNamebaseline,set-modeagentrewrite, context-limit lookup. A shell carries the sameagent/providerID/modelIDas the neighbouring real assistant.Three defects the audit found, fixed in
3338fb03e1.
sweepOrphanAssistantscould delete a live subagent's assistant row. It readagentID: "*"and removed the session-global newest incomplete assistant.SessionProcessorpublishes status for the main slice alone — the very fact the adjacentsweepOrphanToolPartscomment calls load-bearing — so a background actor can be mid-turn while session status readsidle. Its in-flight row is then exactly what the sweep deletes, out from under its own processor, which keeps callingupdatePartagainst thatmessageID. The old stamping behaviour was recoverable; deleting is not. Now scoped to the main slice, which is all the idle gate proves.2. Narrowing the sweep to the trailing message stranded every non-trailing orphan forever. The sweep runs before
createUserMessage, so a single-slice orphan is trailing — but one is left non-trailing by a concurrent subagent row, or by a previoussource: "spawn" | "hook"prompt that skipped the sweep block. The TUI'spendingmarker (routes/session/index.tsx:195) derives from the newest incomplete assistant regardless of what follows it, so a stranded row keeps rendering fresh messages as stuck QUEUED — the exact symptom the age-independent sweep was introduced to prevent. Measured on the local 5.4 GB DB: 85 such rows (78 mid-slice, 7 last-in-slice-but-not-last-in-session) across 33 sessions carrying 2+ incomplete assistants.64bcd6d34— "leaves history whose last message is a user message untouched" — pinned the regression rather than a requirement. Its stated rationale covered only the replay path, which the read side already closes, and not the stuck-QUEUED path. It has been replaced, and that replacement is called out here rather than made quietly.3.
filterCompactedEffectwidened a fork boundary when the watermark row was deleted.message-v2.ts:1109resolved a fork'scontextWatermarkby exact message ID and fell through to the full parent history when the row was missing. Acontext: "full"subagent captures that watermark fromSession.lastMainMessageID(actor/spawn.ts:728), so a spawn issued inside a turn captures that turn's in-flight assistant — precisely the row the empty-shell guard then deletes. The child silently inherited more context than it was registered for: a boundary shift, not a display bug.MessageIDis monotonic ascending, so the boundary survives the row; the fallback now truncates by ID. The resolved path is byte-identical and stays authoritative for array order, which is bytime_createdand can diverge from ID order (insertRebuildBoundaryback-datestime.createdwhile allocating an ascending ID).What changed
Write side —
SessionProcessor.cleanupDo not persist a contentless errored assistant; delete the row instead. The error is not swallowed:
haltalready published it onSession.Event.Errorand reset session status, and a newHandle.droppedflag makesSessionPromptreport the turn from the in-memory message.A user abort is deliberately exempt: cancelling is a normal outcome the transcript should keep showing (pinned by the pre-existing
records aborted errors when prompt is cancelled mid-streamregression test), and an errored assistant can never reach a provider anyway becausetoModelMessages' error gate drops it.Write side —
sweepOrphanAssistantsAge gate removed (the caller establishes idleness), and orphans are deleted rather than stamped. Scope: the main slice, and every incomplete assistant in it, not just the trailing one — see defects 1 and 2 above for why both halves of that are load-bearing.
Read side — restatement, plus one real fix
toModelMessagesemits a user message only when it converted to at least one part, symmetric with the existing assistant guard.hasSubstantiveAssistantContentpredicate instead of an inline part-type check that countedstep-finishandpatchas content — the assistant-side mirror ofhasSubstantiveContentinsession/prompt.ts.filterCompactedEffectno longer discards a fork boundary when the watermark row is gone (defect 3). This one is a behaviour fix, not a restatement.Tests — each verified as a real regression test
Every test was proved by reverting only its corresponding src change.
processor.ts(fail) session.processor effect tests do not retry unknown json errors—expect(handle.dropped).toBe(true)→expect(received).toBe(expected)(receivedundefined); same for both abort tests ontoBe(false)prompt.tssweep (original)(fail) sweepOrphanAssistants > drops a trailing incomplete assistant regardless of age—expect(after.find(...)).toBeUndefined()prompt.tssweep (this round)(fail) sweepOrphanAssistants > drops a NON-trailing incomplete assistant in the main slice—error: expect(received).toBeUndefined()/Received: {· and(fail) sweepOrphanAssistants > never touches a non-main slice, whose actor may still be mid-turn—error: expect(received).toBeDefined()/Received: undefined, i.e. pristine really does delete the live actor's rowmessage-v2.tswatermark(fail) filterCompactedEffect contextWatermark > truncates at the boundary even after the watermark row is deleted—expect(received).toEqual(expected), diff+ "msg_…5005hQbvxh2RBXSFkF"(the post-boundary message leaking in) · and(fail) … inherits nothing when the deleted watermark predates every surviving message—expect(received).toBe(expected)/Expected: false/Received: truemessage-v2.tsconverterVerification
bun typecheck— exit 0, 12/12 tasks.prompt-sweep,context-watermark,message-v2,processor-effect,prompt-effect,compaction-agent-scope,rebuild-microcompact,checkpoint-boundary,checkpoint-main-slice,checkpoint-child-session): 126 pass / 5 skip / 0 fail vs the identical command on the pristine branch head 123 pass / 5 skip / 0 fail (+3 net new cases, zero failures either side).prompt-effect.test.ts— the suite carrying fix(mcp): negotiate per-turn lifecycle notifications #1851'sturn-lifecyclecontract — is in that set and green.whitespace-only text skips loop and returns empty parts(5000 ms timeout), which reproduces identically on pristinemain⇒ pre-existing.No assertion was weakened. One assertion was replaced —
64bcd6d34's non-trailing-orphan case — and it is reported as a finding above rather than adjusted in silence.Deliberately left alone
main(verified by stashing and re-runningprettier --check).ProviderTransform.normalizeMessages/ensureTrailingUserMessage— already correct.checkpoint.ts:258andprompt.ts:1473are left as-is: both are affected only for the 15finish-carrying shells, and in both cases the shift is provably in the conservative direction (preserve more / assume more pressure). Fixing them would add adroppedShelldependency to two hot paths for no behavioural gain.Title
Narrowed to the write side plus the reader repairs. The original "and replaying" half is now handled by
mainindependently (message-v2.ts:699/912/941+ #1948'sensureNonEmptyContent), so claiming it would overstate what this PR contributes.Supersedes the closed PR #1702 (branch 348 commits behind
main); this is a fresh branch off latestorigin/main.Rebase onto
origin/main(b816708)Rebased
1da2f1ba2→729c51768(ontof91eb6334) →64bcd6d34(ontob81670870, after #1782 merged mid-rebase). Two rounds becausemainmoved.Conflicts and how they were resolved
1.
processor.tscleanup— co-located, kept both, order is load-bearing.#1960 added a DB-driven second pass finalizing orphaned tool parts; this PR adds the empty-shell guard. Both landed right after
ctx.toolcalls = {}and collapsed onto the shared brace. #1960's pass now runs first, then the guard. Reason recorded inline atprocessor.ts:797: the guard ends in an earlyreturn, so running it first would skip #1960's finalization on the paths where the row survives. The reverse ordering is also unnecessary —hasSubstantiveAssistantContentcounts anytoolpart as content regardless of state, so a turn that really called a tool is never dropped and the guard can only fire where #1960's loop is a no-op. Guarded by #1960's own suite:prompt-orphan-tool-parts6 pass / 0 fail, identical to the pristine baseline.2.
prompt.tsinterface + call site — co-located, kept both.#1960 added
sweepOrphanToolPartson the line belowsweepOrphanAssistants, whose signature this PR narrows (immediateremoved). Kept #1960's new member and the narrowed signature; the idle gate moved to the caller whilesweepOrphanToolPartsstays self-gated. Both now read the main slice only, and3338fb03erecords that shared scope and why it is load-bearing for both.3.
prompt.tsrunLoop— a genuine contradiction, resolved inmain's favour.#1782 (
b81670870) is a revert: it deleted the empty-step guard (emptyStepStreak,hardHalt,isEmptyStep,EMPTY_STEP_MAX_RECOVERY) fromprompt.ts. Those symbols appear as context in this PR's hunks. Taking this PR's side wholesale would have resurrected code that was deliberately reverted. Resolution: honour the revert — kept onlydroppedShelland itsoutcome === "break"assignment, droppedemptyStepStreak/hardHaltentirely. Verified: 0 occurrences of all four reverted symbols remain inprompt.ts.