From 2517cfc9bd6e402c244a81201f7032c52ae7ebf2 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 07:29:33 +0800 Subject: [PATCH 1/3] fix(runtime-host): admit structured-only Messages and keep them model-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 #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 (#4804-tagged) fail with the production files stashed and pass with them restored. Fixes #4804 Generated-by: GLM-5.3-Flash (ZCode) --- .../core/src/__tests__/runtime-event.test.ts | 31 +++++++++++++++++++ packages/core/src/runtime-event.ts | 11 +++++-- .../src/__tests__/protocol.test.ts | 28 +++++++++++++++++ packages/runtime-host/src/protocol/turn.ts | 15 ++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 18c4dde645..97656529d8 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -854,6 +854,37 @@ describe('runtimeEventHasModelVisibleContent', () => { for (const event of hidden) assert.strictEqual(runtimeEventHasModelVisibleContent(event), false); }); + + test('counts structured user context as model-visible with empty inline text (#4804)', () => { + const visible = [ + baseEvent({ + role: 'user', + content: { kind: 'text', text: '', quotes: [{ text: 'pasted reference-sized excerpt' }] }, + }), + baseEvent({ + role: 'user', + content: { + kind: 'text', + text: '', + attachments: [ + { + kind: 'code', + name: 'a.ts', + mimeType: 'text/typescript', + bytes: 10, + ref: { kind: 'workspace_file', relativePath: 'a.ts' }, + }, + ], + }, + }), + ]; + for (const event of visible) + assert.strictEqual(runtimeEventHasModelVisibleContent(event), true); + assert.strictEqual( + runtimeEventHasModelVisibleContent(baseEvent({ content: { kind: 'text', text: '' } })), + false, + ); + }); }); describe('RuntimeEvent reference validation', () => { diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 8323d289b4..912a42f695 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -1468,7 +1468,10 @@ export function isPartialRuntimeEvent(event: RuntimeEvent): boolean { /** * True if the event carries content whose kind is eligible for model * history projection: text, thinking, function_call, or function_response. - * Error-only content and pure action/refs events are NOT model-visible. + * A user-authored text event with structured context (quotes or attachments) + * is model-visible even when the inline text is empty — the structured part + * is what carries the turn (#4804). Error-only content and pure action/refs + * events are NOT model-visible. * * This is a content-kind check only. Callers still apply `partial` * filtering (partial chunks are never replayed into the next model call). @@ -1479,7 +1482,11 @@ export function runtimeEventHasModelVisibleContent(event: RuntimeEvent): boolean if (!content) return false; switch (content.kind) { case 'text': - return content.text.length > 0; + return ( + content.text.length > 0 || + (content.quotes?.length ?? 0) > 0 || + (content.attachments?.length ?? 0) > 0 + ); case 'thinking': case 'function_call': case 'function_response': diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 9ac1cfde4f..f2ed237abc 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1912,6 +1912,34 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('admits structured-only Messages: empty inline text with quotes or attachments (#4804)', () => { + const submit = (content: unknown) => + decodeClientFrame({ + requestId: 'submit-structured-only', + operation: 'turn.message.submit', + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + messageId: 'message-1', + content, + placement: 'next_turn', + }, + }); + // A quote or an attachment carries the turn by itself: empty inline text + // is admissible when either is present. + assert.doesNotThrow(() => + submit({ text: '', quotes: [{ text: 'pasted reference-sized excerpt' }] }), + ); + assert.doesNotThrow(() => + submit({ + text: '', + attachments: [attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' })], + }), + ); + // A Message with nothing but empty text is still an invalid frame. + assert.throws(() => submit({ text: '' }), isInvalidFrame); + }); + test('bounds Message text in UTF-8 bytes while preserving frame headroom', () => { const input = { originHostEpoch: 'epoch-1', diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index dafa428649..755304fecb 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -464,7 +464,20 @@ export function decodeMessageAdmissionContent( value: unknown, allowEmptyText = false, ): MessageContent { - const content = decodeMessageContent(value, allowEmptyText); + // Structure first with text emptiness unconstrained, then apply the + // admission rule: a quote or an attachment carries the turn by itself, so + // empty inline text is admissible when either is present (#4804). A truly + // contentless Message still throws, with the same frame error the + // text-length rule produced. + const content = decodeMessageContent(value, true); + if ( + !allowEmptyText && + content.text.length === 0 && + (content.quotes?.length ?? 0) === 0 && + (content.attachments?.length ?? 0) === 0 + ) { + throw invalidProtocolFrame('Invalid Message text'); + } if (content.attachments?.some((attachment) => attachment.ref.kind === 'session_context')) { throw invalidProtocolFrame('Session context references are Host-owned'); } From ea1cdf6c8ff47a0b70f8d27ffbe95c0f55a12351 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 20:57:32 +0800 Subject: [PATCH 2/3] chore(runtime-host): declare the structured-only admission widening wire-compatible The #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 #3313 guard's compatible-extension path; the guard passes again on the merge result against current main. Generated-by: GLM-5.3-Flash (ZCode) --- .../message-admission-quote-or-attachment-text.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json diff --git a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json new file mode 100644 index 0000000000..c840e81ab3 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json @@ -0,0 +1,5 @@ +{ + "epoch": 112, + "files": ["packages/runtime-host/src/protocol/turn.ts"], + "reason": "Admission-only widening (#4804): an empty-text Message that carries a quote or an attachment is now accepted; the Host emits no new frame shape and no previously valid frame is rejected, so peers on earlier epochs interoperate unchanged." +} From 44d7c17fa46080ab4858b9a581abf65d67381586 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 21:19:30 +0800 Subject: [PATCH 3/3] fix(runtime-host): read queued and steering messages back with the admission rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...ge-admission-quote-or-attachment-text.json | 8 +++- .../src/__tests__/protocol.test.ts | 43 +++++++++++++++++++ packages/runtime-host/src/protocol/message.ts | 2 +- .../src/protocol/session-continuity.ts | 4 +- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json index c840e81ab3..3b8d81d2ef 100644 --- a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json +++ b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json @@ -1,5 +1,9 @@ { "epoch": 112, - "files": ["packages/runtime-host/src/protocol/turn.ts"], - "reason": "Admission-only widening (#4804): an empty-text Message that carries a quote or an attachment is now accepted; the Host emits no new frame shape and no previously valid frame is rejected, so peers on earlier epochs interoperate unchanged." + "files": [ + "packages/runtime-host/src/protocol/turn.ts", + "packages/runtime-host/src/protocol/message.ts", + "packages/runtime-host/src/protocol/session-continuity.ts" + ], + "reason": "Admission-only widening (#4804): an empty-text Message that carries a quote or an attachment is now accepted at submit and read back the same way by the queue-entry and steering decoders; the Host emits no new frame shape and no previously valid frame is rejected, so peers on earlier epochs interoperate unchanged." } diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index f2ed237abc..056dba3255 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1940,6 +1940,49 @@ describe('Runtime Host bootstrap protocol', () => { assert.throws(() => submit({ text: '' }), isInvalidFrame); }); + test('admitted structured-only Messages survive queue and steering read-back (#4804)', () => { + const admitted = { text: '', quotes: [{ text: 'pasted reference-sized excerpt' }] }; + // A queued next_turn entry carries content admission already accepted at + // submit; the read-back decoders must apply the same rule or the whole + // snapshot frame breaks around one admitted entry. + const projectionWire = { + hostEpoch: 'epoch-1', + queueRevision: 7, + steering: [], + followup: [ + { + ...queuedMessage('later', 'next_turn'), + entryId: 'entry-9', + messageId: 'm-9', + content: admitted, + }, + ], + }; + assert.deepEqual( + decodeSessionMessageQueueProjection(JSON.parse(JSON.stringify(projectionWire))), + projectionWire, + ); + // The durable steering echo reads back through the session-event frame. + assert.doesNotThrow(() => + decodeHostFrame({ + kind: 'subscription.session_event' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'steering_message' as const, + id: 'steering-event-9', + turnId: 'turn-1', + ts: 7, + messageId: 'steering-message-9', + content: admitted, + }, + }), + ); + }); + test('bounds Message text in UTF-8 bytes while preserving frame headroom', () => { const input = { originHostEpoch: 'epoch-1', diff --git a/packages/runtime-host/src/protocol/message.ts b/packages/runtime-host/src/protocol/message.ts index 4bb02151fe..89806dc726 100644 --- a/packages/runtime-host/src/protocol/message.ts +++ b/packages/runtime-host/src/protocol/message.ts @@ -651,7 +651,7 @@ function decodeMessageQueueEntrySnapshot(value: unknown): MessageQueueEntrySnaps const base = { entryId: requireEntityId(record.entryId, 'entryId'), messageId: requireEntityId(record.messageId, 'messageId'), - content: decodeMessageContent(record.content), + content: decodeMessageAdmissionContent(record.content), placement: requireMessagePlacement(record.placement), }; if (record.state === 'queued' || record.state === 'retracted') { diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 4219b6ab34..10fe67cfdb 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -42,7 +42,7 @@ import { } from './message.js'; import { defineOperation } from './operation-spec.js'; import { - decodeMessageContent, + decodeMessageAdmissionContent, decodeTurnSnapshot, type MessageContent, type TurnSnapshot, @@ -775,7 +775,7 @@ function decodeSessionSteeringEvent(record: Record): SessionSte turnId: requireEntityId(record.turnId, 'turnId'), ts: requireCount(record.ts, 'Session steering event timestamp'), messageId: requireEntityId(record.messageId, 'messageId'), - content: decodeMessageContent(record.content), + content: decodeMessageAdmissionContent(record.content), }; }