Skip to content

fix(runtime-host): admit structured-only Messages and keep them model-visible - #4815

Open
ggbdpq wants to merge 4 commits into
apache:mainfrom
ggbdpq:fix/structured-only-message-admission
Open

fix(runtime-host): admit structured-only Messages and keep them model-visible#4815
ggbdpq wants to merge 4 commits into
apache:mainfrom
ggbdpq:fix/structured-only-message-admission

Conversation

@ggbdpq

@ggbdpq ggbdpq commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #4804. Quote-only and attachment-only turns carried real model-facing context but were rejected twice on the way to the model, both times by a text-only admission assumption:

  1. Host admissiondecodeMessageAdmissionContent threw Invalid Message text for empty inline text unless skill ids were present, so a staged quote or a lone attachment could not be submitted.
  2. Replay visibilityruntimeEventHasModelVisibleContent counted only inline text length, so a persisted user event with empty text and a non-empty quotes array was dropped as blocking-unsupported, producing the durable current-run projection is not replayable / empty_text_skipped failure the issue records.

Now decodeMessageAdmissionContent applies the rule itself after structural decode — empty inline text is admissible when the Message carries quotes or attachments, and a Message with none of the three still throws the same invalid-frame error — and the visibility predicate counts structured user context (quotes/attachments) as model-visible. All turn/message admission call sites share the one function, and the model projection already renders quotes (formatQuoteRefs), so structured-only turns now reach the model with their content.

Verification

Claim Command Result
New tests pass with the fix node --test --test-name-pattern="#4804" on core runtime-event + runtime-host protocol dist tests 2 tests, 2 pass
Red-green stash the two production files, rebuild, rerun 2 tests, 2 fail; restored → pass
Quote-only admission at the frame level turn.message.submit with {text:'', quotes:[…]} / {text:'', attachments:[…]} admitted; {text:''} alone still Invalid Message text
No admission regression protocol.test.js (78), message-coordinator.test.js (73) all pass
Format npm run format:check exit 0

Known Windows-environment failures encountered while verifying, both reproduced identically with these production files stashed (control runs) and therefore pre-existing: hosted linked child roots share admission… (EBUSY unlink of a temp runtime.sqlite) and one EPIPE shutdown test in host-kernel. The full Runtime Host suite additionally stalls on this machine on an unrelated file; CI on Linux/macOS is the authoritative full-suite run.

Scope note: the renderer-side empty user bubble beside a quote chip is a display projection and is not addressed here; the durable event and the model request are correct after this change.

AI use

Implemented with ZCode (GLM-5.3-Flash): traced the issue's four named boundaries to the two blocking ones, applied the smallest contract change at each, and added red-green tests tagged with the issue number. The commit carries the Generated-by trailer.

Checklist

  • Root-cause fix at shared admission/visibility points, not per-surface patches
  • Regression tests fail on the old code (verified via stash/rebuild)
  • Test style follows each file's existing conventions, issue number in test names
  • No new dependencies; renderer display left as a stated follow-up

…-visible

A quote-only or attachment-only turn carried real model-facing context but
was rejected at the Host admission boundary ("Invalid Message text") and,
once persisted, dropped by the replay visibility predicate, which counted
only inline text length. The result was exactly apache#4804: structured-only
sends fail before the provider request, and any that persisted render as
an empty user bubble while the model never sees the quoted content.

- decodeMessageAdmissionContent now decodes the frame structurally and
  applies the text rule itself: empty inline text is admissible when the
  Message carries quotes or attachments; a Message with none of the three
  still throws the same invalid-frame error. All turn/message admission
  call sites share this function, so skill-only and structured-only
  admissions now follow one rule.
- runtimeEventHasModelVisibleContent counts a user-authored text event
  with quotes or attachments as model-visible even when the text is
  empty, so the durable event survives replay and the existing quote
  projection (formatQuoteRefs) reaches the model.

Red-green: both new tests (apache#4804-tagged) fail with the production files
stashed and pass with them restored.

Fixes apache#4804

Generated-by: GLM-5.3-Flash (ZCode)
@github-actions github-actions Bot added the effort/S Under 100 readable lines label Sep 4, 2026

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head de6183d3172116b220f5fc7b5bbfdf1f3ebc9a59 (OPEN). One P1 below plus a hard CI blocker — this PR is not awaiting human sign-off; its own required check is red.

P1 — admission now accepts empty-text-with-quote, but the two snapshot decoders still reject it

turn.ts:463 (decodeMessageAdmissionContent) parses with allowEmptyText = true and then accepts empty inline text when a quote or attachment is present. But the two places that read these messages back were not changed: message.ts:654 (decodeMessageQueueEntrySnapshot) and session-continuity.ts:778 (steering message events), both importing decodeMessageContent from ./turn.js with the default allowEmptyText = false, which throws Invalid Message text via requireUtf8String. Concrete path: a turn.message.submit with placement: 'next_turn' carrying empty text + a quote is admitted at message.ts:334, enters the pending queue, and then breaks the entire queue snapshot frame when the host serializes it. Same story for session continuity/replay via the steering path. The PR's own new test uses exactly 'next_turn' placement but only asserts the submit frame decodes, stopping one step short of the snapshot frame. Checked and excluded: the storage layer uses the events.ts:260 decoder which only requires typeof text === 'string', so the asymmetry is confined to these two runtime-host protocol call sites — switch them to the same structured-counts-as-valid rule or extract a shared predicate.

CI is red on this head — protocol epoch not bumped

The repo's own protocol-epoch-check.mjs guard fails: protocol files changed but RUNTIME_HOST_COMPATIBILITY_EPOCH is still 112. Note for sequencing: sibling branches bumping the same number merge without git conflict (see #3313), so whoever merges last must re-bump — worth coordinating with the other protocol PRs in flight.

Checked and found sound

The admission refactor itself is behavior-preserving except for the intended widening (allowEmptyText only feeds the empty check at turn.ts:406, UTF-8/length validation untouched); the frame error string is intentionally unchanged; the widened visibility predicate and admission rule agree with each other; re-checked all 5 non-test callers of runtimeEventHasModelVisibleContent (model-history.ts:566,733, runtime-resume.ts:1106,1238, session-recap.ts:120) — all filter semantics, widening only retains more, never drops.

What I could not judge

The P1 path is derived from decoder imports and defaults, not from running the host — a submit-then-snapshot round-trip test would prove or falsify it, and that test does not exist yet. Whether the desktop UI allows empty-body submit was not checked; the protocol is the contract either way.


Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.

简体中文

本条结论全部来自 @Opus-Qronos-AstroHan 的审查。我自己没有读这份 diff;我核的是当前 head 有没有漂移。当前 head 是 de6183d,未关闭。一条 P1:准入放宽了但读回的两处没跟着放宽,会带倒整个快照帧;另必需检查红了,纪元号没提。修好再合。

…ire-compatible

The apache#4804 admission change touches packages/runtime-host/src/protocol/turn.ts
without changing the wire: the Host only accepts strictly more frames (an
empty-text Message that carries a quote or an attachment is admitted), emits
nothing new, and rejects nothing that was valid before. Declare it under
protocol-compatible-changes/ at epoch 112 instead of bumping the epoch, per
the apache#3313 guard's compatible-extension path; the guard passes again on the
merge result against current main.

Generated-by: GLM-5.3-Flash (ZCode)
…mission rule

Review P1 (Astro-Han): decodeMessageAdmissionContent now admits an
empty-text Message that carries a quote or an attachment, but the two
places that read those messages back — the message queue entry snapshot
(message.ts) and the durable steering echo (session-continuity.ts) — still
decoded with the default text-length rule, so one admitted next_turn entry
broke the whole queue snapshot frame at serialization. Both call sites use
the same admission decoder now, and a submit-to-snapshot round-trip test
pins the path the review named. The compatible-change declaration grows by
the two read-back files; the protocol epoch guard stays green at 112.

Generated-by: GLM-5.3-Flash (ZCode)

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up to my review above, new head 44d7c17fa46080ab4858b9a581abf65d67381586 (OPEN). The previous P1 is fixed — and fixed exactly where flagged — and the required test check is green on this head. One new P2 below, on the new approach taken to the epoch question.

P2 — a compatibility declaration replaces the epoch bump; the judgment call deserves a deploy-shape answer

This version does not bump the epoch. Instead it adds protocol-compatible-changes/message-admission-quote-or-attachment-text.json, declaring epoch stays 112 on the grounds that no new frame shape is emitted and no previously valid frame is rejected. I read the guard (scripts/protocol-epoch-check.mjs): it is purely mechanical — declaration epoch must equal the head epoch, files must cover all changed protocol files, reason non-empty. It cannot validate the compatibility claim itself, so the claim's correctness is entirely human judgment — which is what needs reviewing now. The script's own comments state the trade-off direction ("a needless epoch bump costs a number, a missed one …"): it leans toward bumping. My recommendation is to bump: the costs are asymmetric — a wasted number versus two incompatible peers both believing they are compatible, an error invisible at handshake and surfacing only when some user pastes a quote into an empty message and the frame fails to decode. Graded P2 rather than P1 because I cannot evidence how often two different-version clients hang off one host in practice; if maintainers can confirm that scenario is excluded by another mechanism (e.g. client version strongly bound to host), the declaration stands and this drops to an observation. That question belongs to someone who knows the deployment shape.

What I could not judge

The host was not run and no cross-version client experiment was constructed — the P2 failure path is derived from decoder behavior differences across versions; the reasoning is held with confidence, the incidence rate without evidence.


Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.

简体中文

本条是上一条的后续,同样来自 @Opus-Qronos-AstroHan 的审查,针对新 head 44d7c17。上一轮的 P1 已经修掉。新增一条 P2:用兼容声明代替提纪元,这个判断偏乐观,建议提纪元,除非能确认跨版本场景不存在。修好再合。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/S Under 100 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

desktop: quote-only and attachment-only messages require inline text

2 participants