From 6bc5e623ec4a9369edb96ffe0bce69eeb9963c18 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 07:29:33 +0800 Subject: [PATCH 01/10] 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 3bfb380177..c369fa5696 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, + ); + }); }); test('runtime errors reject malformed retry decisions at the durable boundary', () => { diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 9794423f43..63044e39c9 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -1514,7 +1514,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). @@ -1525,7 +1528,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 ab2b3e685c..df0552d771 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1922,6 +1922,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 e5c5c8e3ea..5f7a02ec2f 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -472,7 +472,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 0f448f29f39f2c553c5ad642b83a3db312fbdc58 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 20:57:32 +0800 Subject: [PATCH 02/10] 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 09ec91885aa854519a3efe78c5cb4b22944b92fd Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 21:19:30 +0800 Subject: [PATCH 03/10] 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 df0552d771..86e7314bb4 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1950,6 +1950,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 ae600aa5de..2dd0e26d7a 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, @@ -802,7 +802,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), }; } From dcf339e0f9ba12312790211e614a8cf46c2f3d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Mon, 7 Sep 2026 18:04:25 +0800 Subject: [PATCH 04/10] fix(runtime-host): bump compatibility epoch for message admission (#4804) The structured-only Message admission was declared a compatible extension, but the declaration is the weaker side of an asymmetric bet: a wrong "compatible" claim is invisible at handshake and only surfaces when a mixed-version pair exchanges the new frame, while a spare epoch number costs nothing. Upstream also moved the epoch 112 -> 123 since the declaration was written, which invalidates it outright (the guard requires declaration epoch == head epoch). Bump RUNTIME_HOST_COMPATIBILITY_EPOCH 123 -> 124, record the change in the epoch log, and drop the compatibility declaration. Follow-up to the P2 review on #4815. Generated-by: GLM-5.3-Flash (ZCode) --- .../message-admission-quote-or-attachment-text.json | 9 --------- packages/runtime-host/src/protocol/index.ts | 5 ++++- 2 files changed, 4 insertions(+), 10 deletions(-) delete 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 deleted file mode 100644 index 3b8d81d2ef..0000000000 --- a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "epoch": 112, - "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/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 536c7aa667..abdd277c49 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 125 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 126 as const; +// 126: Message admission accepts an empty-text Message that carries a quote or +// an attachment (#4804). Peers older than this epoch reject that frame at +// admission, so the pair must refuse each other at the handshake. // 125: Live Turn snapshots carry an optional `rootExecutionKind:'context_compact'` // so a running context-compaction Turn can render a transcript row. Epoch-124 // peers reject the added optional field on the strict live snapshot shape. From fef4f355aea3e2534c0600ead6c71445791e0390 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 8 Sep 2026 06:42:12 +0800 Subject: [PATCH 05/10] fix(runtime-host): accept structured-only Messages at durable admission Completes the storage half of the structured-only message admission: normalizeRootTurnMessageContent now uses the shared meaningful-content predicate (text, quote, or attachment) instead of the text-length rule, so a quote- or attachment-only Message that passes the protocol decoder also forms a durable Turn. The compaction estimate counts the structured envelope (a zero estimate dropped model-visible events from the history-compact gate), and the session recap projects a carrier marker for structured-only events instead of losing them. Generated-by: GLM-5.3-Flash (ZCode) --- packages/core/src/events.ts | 15 +++++++++++++++ packages/runtime-host/src/protocol/turn.ts | 16 ++++++---------- packages/runtime/src/model-history.ts | 16 ++++++++++++++-- packages/runtime/src/session-recap.ts | 15 +++++++++++++++ packages/storage/src/agent-run-store.ts | 8 +++++++- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 002bbcba0e..88d06283c4 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -167,6 +167,21 @@ const MESSAGE_CONTENT_SHAPE = defineObjectShape()( ['text'], ['displayText', 'attachments', 'directoryReferences', 'quotes', 'inlineReferences'], ); + +/** + * A Turn message is meaningful when at least one of its three content carriers + * is present: inline text, an inline excerpt, or an attachment reference. + * Admission, compaction estimates, and recap projection must share this one + * predicate (#4804) — restating it per layer is how a quote-only message ends + * up admitted by one boundary and silently dropped by the next. + */ +export function hasMeaningfulMessageContent(content: MessageContent): boolean { + return ( + content.text.length > 0 || + (content.quotes?.length ?? 0) > 0 || + (content.attachments?.length ?? 0) > 0 + ); +} const ATTACHMENT_REF_SHAPE = defineObjectShape()( ['kind', 'name', 'mimeType', 'bytes', 'ref'], [], diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 5f7a02ec2f..a4cf0402ce 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -21,6 +21,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachmen import { decodeMessageContent as decodeCanonicalMessageContent, DIRECTORY_REFERENCE_MAX_COUNT, + hasMeaningfulMessageContent, isCanonicalAttachmentRef, type ContextCompactionOutcome, type MessageContent, @@ -473,17 +474,12 @@ export function decodeMessageAdmissionContent( allowEmptyText = false, ): MessageContent { // 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. + // shared meaningful-content predicate: 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 - ) { + if (!allowEmptyText && !hasMeaningfulMessageContent(content)) { throw invalidProtocolFrame('Invalid Message text'); } if (content.attachments?.some((attachment) => attachment.ref.kind === 'session_context')) { diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index ea452dbbcf..a8141a929f 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -195,8 +195,20 @@ export function estimateEffectiveToolResultChars( export function estimateRuntimeEventChars(event: RuntimeEvent): number { let total = 0; const content = event.content; - if (content?.kind === 'text' || content?.kind === 'thinking') total += content.text.length; - else if (content?.kind === 'function_call') + if (content?.kind === 'text' || content?.kind === 'thinking') { + total += content.text.length; + // Structured carriers are part of the event's weight: a quote- or + // attachment-only user message must not estimate to zero, or the + // history-compact gate drops a model-visible event (#4804). + if (content.kind === 'text') { + for (const quote of content.quotes ?? []) { + total += quote.text.length + (quote.label?.length ?? 0); + } + for (const attachment of content.attachments ?? []) { + total += attachment.name.length + attachment.mimeType.length; + } + } + } else if (content?.kind === 'function_call') total += content.name.length + stableJsonLength(content.args); else if (content?.kind === 'function_response') total += content.name.length + estimateEffectiveToolResultChars(content, event.sessionId); diff --git a/packages/runtime/src/session-recap.ts b/packages/runtime/src/session-recap.ts index 87e34bef6d..4a04b55ccf 100644 --- a/packages/runtime/src/session-recap.ts +++ b/packages/runtime/src/session-recap.ts @@ -123,6 +123,21 @@ function projectSessionRecapMessages(events: readonly RuntimeEvent[]): ModelMess const text = content.text.trim(); if (text.length > 0) { messages.push({ role: event.role === 'user' ? 'user' : 'assistant', content: text }); + } else { + // Model-visible without inline text: a structured-only message must + // still leave evidence in the recap instead of vanishing (#4804). + const quoteCount = content.quotes?.length ?? 0; + const attachmentCount = content.attachments?.length ?? 0; + const carriers = [ + quoteCount > 0 ? `${quoteCount} quote(s)` : undefined, + attachmentCount > 0 ? `${attachmentCount} attachment(s)` : undefined, + ].filter(Boolean); + if (carriers.length > 0) { + messages.push({ + role: event.role === 'user' ? 'user' : 'assistant', + content: `[message carried ${carriers.join(' and ')}]`, + }); + } } continue; } diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 58aa12a584..5d0d005726 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -53,6 +53,7 @@ import type { import { aggregateMessageContents, decodeMessageContent, + hasMeaningfulMessageContent, isCanonicalAttachmentRef, messageContentsEqual, type AttachmentRef, @@ -1613,7 +1614,12 @@ function normalizeRootTurnMessageContent( } throw new Error(`Invalid ${description}`); } - if (normalized.text.length === 0 || (normalized.attachments?.length ?? 0) > maxAttachments) { + // Quote- or attachment-only input is meaningful (#4804): the text carrier + // alone no longer decides durability admission. + if ( + !hasMeaningfulMessageContent(normalized) || + (normalized.attachments?.length ?? 0) > maxAttachments + ) { throw new Error(`Invalid ${description}`); } for (const [index, attachment] of (normalized.attachments ?? []).entries()) { From 54a454d1fc6b6160dafa6e7270a834fed686cbf7 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 8 Sep 2026 06:53:11 +0800 Subject: [PATCH 06/10] test(storage): pin quote-only and attachment-only durable admission Pins the #4804 admission contract at the durable owner: quote-only and attachment-only root Turn inputs are admitted, and a truly contentless input still throws the same frame error. On the pre-fix base the quote-only and attachment-only cases fail (the text-length rule rejected them), matching jackwener's end-to-end reproduction on #4815. Generated-by: GLM-5.3-Flash (ZCode) --- .../root-turn-admission-normalization.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts b/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts index 6ec943fd9b..c329793e30 100644 --- a/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts +++ b/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts @@ -101,3 +101,39 @@ test('root admission preserves and validates each source Skill outcome', () => { ]), ); }); + +test('admits a quote-only root Turn input (#4804)', () => { + const content = { + text: '', + quotes: [{ text: 'quoted passage worth answering' }], + } as const; + const normalized = normalizeRootTurnAdmissionPayload(content, []); + + assert.ok(normalized.normalizedInput); + assert.equal(normalized.normalizedInput?.quotes?.[0]?.text, 'quoted passage worth answering'); +}); + +test('admits an attachment-only root Turn input (#4804)', () => { + const content = { + text: '', + attachments: [ + { + kind: 'image' as const, + name: 'diagram.png', + mimeType: 'image/png', + bytes: 1024, + ref: { kind: 'workspace_file', relativePath: 'blobs/diagram.png' }, + }, + ], + } as const; + const normalized = normalizeRootTurnAdmissionPayload(content, []); + + assert.equal(normalized.normalizedInput?.attachments?.[0]?.name, 'diagram.png'); +}); + +test('still rejects a truly contentless root Turn input', () => { + assert.throws( + () => normalizeRootTurnAdmissionPayload({ text: '' }, []), + /Invalid root turn normalized input/u, + ); +}); From b12ce2e6aa64769839b8ccdaf846762e736297aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Tue, 8 Sep 2026 09:58:51 +0800 Subject: [PATCH 07/10] test(runtime): pin the quoted excerpt in the recap of a structured-only message The recap previously rendered a count placeholder for a structured-only message; pin the actual excerpt text so the #4804 acceptance (quote content appears in the recap input) is asserted, not assumed. Generated-by: GLM-5.3-Flash (ZCode) --- .../src/__tests__/session-recap.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/runtime/src/__tests__/session-recap.test.ts b/packages/runtime/src/__tests__/session-recap.test.ts index 2a7d9d4fcf..ae2b3ac00e 100644 --- a/packages/runtime/src/__tests__/session-recap.test.ts +++ b/packages/runtime/src/__tests__/session-recap.test.ts @@ -127,6 +127,29 @@ test('session recap budgets only the evidence it sends', () => { assert.equal(serialized.includes(oversizedArgs), false); }); +test('session recap carries the quoted excerpt of a structured-only message', () => { + const quotedText = 'QUOTED-EXCERPT-SENTINEL the deploy failed at step three'; + const messages = buildSessionRecapMessages({ + events: [ + { + ...textEvent('quoted-user', 'turn-1', 'user', ''), + content: { + kind: 'text', + text: '', + quotes: [{ text: quotedText, sourceTurnId: 'turn-0' }], + }, + }, + ], + connection: connection(), + modelId: 'gpt-4', + }); + const serialized = JSON.stringify(messages); + + assert.equal(serialized.includes(quotedText), true); + assert.equal(serialized.includes(''), true); + assert.equal(serialized.includes('[message carried'), false); +}); + test('session recap excludes model-hidden tool outcomes', () => { const messages = buildSessionRecapMessages({ events: [ From 2f68f36c8aa1f58e50625803cfc940fbb4f7dd76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Tue, 8 Sep 2026 12:33:25 +0800 Subject: [PATCH 08/10] fix(ui): carry the structured-only contract through side-chat consumers Review follow-up on #4815. The shared Composer now enables Send with an empty draft once a quote or attachment is staged, but two consumers still rejected the structured-only frame on the normal user path: - `useQuoteCompanion.send` gated on `!trimmed` before fork/Host admission, so "select transcript text -> Ask about selection -> empty draft -> Send" (and attachment-only sends) returned false before reaching the quote and attachment payload the send already carries. The same text-only guard in `steer` rejected the action while streaming. Both entries now accept an empty text when a quote or attachment is staged; steering passes quotes and attachment items through the one Message admission channel, and the staged quotes stay pending until the Host admits the steering Message. - `UserMessageBody` in packages/ui created `ChatMessageBubble` unconditionally, so a quote-only message rendered an empty bubble on both the transient and durable paths. The bubble now renders only for non-blank text; quotes and attachments keep their existing surfaces. The merge with current main also reconciles the compatibility epoch: both branches had independently claimed 131, so this branch now carries 132 for the structured-only admission widening. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/quote-companion-retry.test.ts | 70 ++++++++++ .../src/renderer/features/workbar/ports.ts | 7 +- .../tools/side-chat/quote-companion-panel.tsx | 11 +- .../tools/side-chat/use-quote-companion.ts | 132 +++++++++++------- .../desktop/create-workbar-services.ts | 6 +- .../__tests__/chat-turn-quote-only.test.tsx | 107 ++++++++++++++ packages/ui/src/chat-turn.tsx | 28 ++-- 7 files changed, 293 insertions(+), 68 deletions(-) create mode 100644 packages/ui/src/__tests__/chat-turn-quote-only.test.tsx diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 159cbb3311..363562e73b 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -2119,3 +2119,73 @@ async function awaitCompanion(container: Element, id = 'side-conversation'): Pro async function awaitProcessing(container: Element): Promise { await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); } + +test('a structured-only send (empty text with a staged quote) reaches the fork admission', async () => { + const sendCommands: Array[1]> = []; + const rendered = await renderOwnershipProbe( + { + listTurns: async () => [settledTurn('done-turn')], + branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + send: async (_sessionId, command) => { + sendCommands.push(command); + return { ok: true as const, turnId: 'quote-only-turn' }; + }, + }, + { + pendingQuotes: [{ id: 'quote-1', value: { text: 'selected excerpt' } }], + }, + ); + const probe = rendered.container.firstElementChild; + assert.ok(probe); + + // The Composer enables Send once a quote is staged; an empty draft must ride + // the same admission as a text send instead of dying on the `!trimmed` guard. + await act(async () => { + assert.equal(await rendered.send(''), true); + await Promise.resolve(); + }); + await awaitCompanion(rendered.container); + assert.equal(sendCommands.length, 1); + assert.equal(sendCommands[0].text, ''); + assert.deepEqual( + sendCommands[0].quotes?.map((quote) => quote.text), + ['selected excerpt'], + ); + assert.equal(probe.getAttribute('data-error'), ''); +}); + +test('a structured-only steer (empty text with a staged quote) rides the steering contract', async () => { + const steerContents: Array[3]> = []; + const rendered = await renderOwnershipProbe( + { + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, _admissionId, content) => { + steerContents.push(content); + return { kind: 'queued', messageId: 'steer-1' }; + }, + }, + { + pendingQuotes: [{ id: 'quote-1', value: { text: 'streaming excerpt' } }], + }, + ); + + await act(async () => { + assert.equal(await rendered.send('initial prompt'), true); + await Promise.resolve(); + }); + await waitUntil( + () => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true', + ); + + // Streaming steers take the same structured-content contract: the quote alone + // is a valid steering Message, and the `!trimmed` guard must not drop it. + await act(async () => { + assert.equal(await rendered.steer(''), true); + await Promise.resolve(); + }); + assert.equal(steerContents.length, 1); + assert.deepEqual( + steerContents[0]?.quotes?.map((quote) => quote.text), + ['streaming excerpt'], + ); +}); diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 4ee8b1cd65..84f02d3ffe 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -237,7 +237,12 @@ export interface SideChatSessionPort { sessionId: string, target?: SideChatStopTarget, ): Promise<{ kind: 'retracted'; messageId: string } | undefined>; - steer(sessionId: string, text: string, admissionId?: string): Promise; + steer( + sessionId: string, + text: string, + admissionId?: string, + content?: { quotes?: QuoteRef[]; attachmentItems?: WorkbarIngestInput[] }, + ): Promise; setPermissionMode( sessionId: string, mode: PermissionMode, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index d49fafb4a0..abe8915b01 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -344,7 +344,16 @@ export function QuoteCompanionPanel(props: { text, streaming: companion.streaming, compact: companion.compact, - steer: companion.steer, + steer: async (text) => { + const accepted = await companion.steer( + text, + pendingAttachments.length > 0 + ? toComposerIngestItems(pendingAttachments) + : undefined, + ); + if (accepted) clearSubmittedAttachments(pendingAttachments); + return accepted; + }, send: async () => { try { preflightAttachmentItems(pendingAttachments, locale); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 1a65ba4024..81a8ba3fbd 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -186,8 +186,9 @@ export interface UseQuoteCompanionResult { /** Returns whether the send was accepted; false leaves the draft + staged * quotes in place so the user can retry. */ send: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; - /** Insert text into the active companion turn at the next model step. */ - steer: (text: string) => Promise; + /** Insert text — or a structured-only quote/attachment — into the active + * companion turn at the next model step. */ + steer: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; setPermissionMode: (mode: PermissionMode) => Promise; regenerate: (turnId: string) => Promise; stop: () => Promise; @@ -853,9 +854,13 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ): Promise => { const trimmed = text.trim(); if (isExactCompactCommand(trimmed)) return compact(); + // A structured-only Message (empty text carrying a quote or an attachment) + // is a valid send since the admission widening (#4804), so the guard + // rejects only when nothing at all is staged. + const quoteSnapshot = snapshotCompanionQuotes(panelId, pendingQuotes); if ( !mountedRef.current || - !trimmed || + (!trimmed && quoteSnapshot.quotes.length === 0 && !attachmentItems?.length) || submitLockRef.current || compactionRequestInFlightRef.current || activeTurnIdRef.current || @@ -868,7 +873,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setSubmitLocked(true); setError(null); const turnId = crypto.randomUUID(); - const quoteSnapshot = snapshotCompanionQuotes(panelId, pendingQuotes); const label = (quoteSnapshot.quotes[0]?.text ?? trimmed).slice(0, 24); // Show the user's question IMMEDIATELY as an optimistic bubble, before the // fork exists. On a first send `ensureFork` makes a Host round trip, and the @@ -1120,59 +1124,81 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } }, [releaseAdmission, resolveAdmission, sideChat]); - const steer = useCallback(async (text: string): Promise => { - const id = companionIdRef.current; - const trimmed = text.trim(); - if ( - !mountedRef.current || - !id || - !trimmed || - !turnInFlight || - pendingAdmissionRef.current - ) { - return false; - } - const admissionId = crypto.randomUUID(); - const admission: PendingAdmission = { - messageId: admissionId, - events: [], - }; - setPendingAdmission(admission); - try { - const outcome = await sideChat.steer(id, trimmed, admissionId); - if (!mountedRef.current) return false; - if ((await admission.stopPromise) === 'confirmed') return false; - if (admissionOutcomeForMessage(admission.events, admission.messageId)?.kind === 'retracted') { - return false; - } - if (outcome.kind === 'started') { - bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); - } else if (resolveAdmission(id, admission, outcome.messageId, true)?.kind === 'retracted') { + const steer = useCallback( + async ( + text: string, + attachmentItems?: WorkbarIngestInput[], + ): Promise => { + const id = companionIdRef.current; + const trimmed = text.trim(); + // Same structured-only contract as `send`: a quote or an attachment alone + // is a valid steering Message (#4804). + const quoteSnapshot = snapshotCompanionQuotes(panelId, pendingQuotes); + if ( + !mountedRef.current || + !id || + (!trimmed && quoteSnapshot.quotes.length === 0 && !attachmentItems?.length) || + !turnInFlight || + pendingAdmissionRef.current + ) { return false; } - setError(null); - return true; - } catch { - if (mountedRef.current) { - if (pendingAdmissionRef.current === admission) { - releaseAdmission(admission, copyRef.current.errors.sendFailed); - } else if ( - admissionOutcomeForMessage(admission.events, admission.messageId)?.kind !== 'retracted' - ) { - setError(copyRef.current.errors.sendFailed); + const admissionId = crypto.randomUUID(); + const admission: PendingAdmission = { + messageId: admissionId, + events: [], + // Quotes stay staged until the Host admits the steering Message; a + // failed or retracted steer keeps them available for retry. + ...(quoteSnapshot.quotes.length > 0 + ? { consumeOnAdmission: () => onQuotesConsumed(quoteSnapshot) } + : {}), + }; + setPendingAdmission(admission); + try { + const outcome = await sideChat.steer(id, trimmed, admissionId, { + ...(quoteSnapshot.quotes.length > 0 + ? { quotes: [...quoteSnapshot.quotes] } + : {}), + ...(attachmentItems?.length ? { attachmentItems } : {}), + }); + if (!mountedRef.current) return false; + if ((await admission.stopPromise) === 'confirmed') return false; + if (admissionOutcomeForMessage(admission.events, admission.messageId)?.kind === 'retracted') { + return false; } + if (outcome.kind === 'started') { + bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); + } else if (resolveAdmission(id, admission, outcome.messageId, true)?.kind === 'retracted') { + return false; + } + setError(null); + return true; + } catch { + if (mountedRef.current) { + if (pendingAdmissionRef.current === admission) { + releaseAdmission(admission, copyRef.current.errors.sendFailed); + } else if ( + admissionOutcomeForMessage(admission.events, admission.messageId)?.kind !== 'retracted' + ) { + setError(copyRef.current.errors.sendFailed); + } + } + return false; } - return false; - } - }, [ - bindAdmittedTurn, - mountedRef, - releaseAdmission, - resolveAdmission, - setPendingAdmission, - sideChat, - turnInFlight, - ]); + }, + [ + bindAdmittedTurn, + mountedRef, + onQuotesConsumed, + panelId, + pendingQuotes, + releaseAdmission, + resolveAdmission, + setPendingAdmission, + sideChat, + turnInFlight, + ], + ); const setPermissionMode = useCallback( (mode: PermissionMode): Promise => { diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 39a6e490c5..3c5098ca94 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -117,11 +117,15 @@ export function createDesktopWorkbarServices( // Steering is a Message placed at the current Turn's boundary, so it // rides the one admission channel. Runtime Host names the outcome; this // adapter only renames it for the Side Conversation port. - steer: async (sessionId, text, admissionId) => { + steer: async (sessionId, text, admissionId, content) => { const messageId = admissionId ?? crypto.randomUUID(); const result = await bridge.sessions.submitMessage(sessionId, 'current_turn', { messageId, text, + ...(content?.quotes ? { quotes: content.quotes } : {}), + ...(content?.attachmentItems + ? { attachmentItems: content.attachmentItems } + : {}), }); if (!result.ok) { if (result.reason === 'outcome_unknown') { diff --git a/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx b/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx new file mode 100644 index 0000000000..cd0583e2b3 --- /dev/null +++ b/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { TransientUserMessage } from '../chat-turn.js'; +import { LocaleProvider } from '../locale-context.js'; +import type { TransientUserMessageProjection } from '../chat-view.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function renderMessage(message: TransientUserMessageProjection) { + const parsed = parseHTML('
'); + const { document, window } = parsed; + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return async () => { + await (act(() => { + root.render( + + + , + ); + }) as unknown as Promise); + return container; + }; +} + +test('a quote-only user message renders the quote without an empty text bubble', async () => { + const container = await renderMessage({ + id: 'quote-only', + text: '', + ts: 1, + transientPlacement: 'current_turn', + quotes: [{ text: 'selected excerpt' }], + })(); + + // #4804: a structured-only Message (empty text carrying a quote) must show + // the quote chips, and the unconditional text bubble must not render empty. + const bubble = container.querySelector('.maka-chat-message-bubble-user'); + assert.equal(bubble, null, 'an empty text must not render an empty user bubble'); + const quotes = container.querySelector('.maka-user-quotes'); + assert.ok(quotes, 'the staged quote still renders'); + assert.match(quotes?.textContent ?? '', /selected excerpt/); +}); + +test('a user message with text still renders its bubble', async () => { + const container = await renderMessage({ + id: 'with-text', + text: 'explain this', + ts: 1, + transientPlacement: 'current_turn', + quotes: [{ text: 'selected excerpt' }], + })(); + + const bubble = container.querySelector('.maka-chat-message-bubble-user'); + assert.ok(bubble, 'a text message keeps its bubble'); + assert.match(bubble?.textContent ?? '', /explain this/); +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 753e199409..c5c9af894f 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -250,18 +250,22 @@ const UserMessageBody = memo(function UserMessageBody(props: { ))} ) : null} - - {props.inlineReferences ? ( - - ) : ( - - {props.text} - - )} - + {/* A structured-only message (#4804) may carry only quotes/attachments; + an empty text must not render an empty bubble on those paths. */} + {props.text.trim().length > 0 ? ( + + {props.inlineReferences ? ( + + ) : ( + + {props.text} + + )} + + ) : null} ); }); From 11aa81e324c669a5034f440c6d9f41816d0a5b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Tue, 8 Sep 2026 15:08:40 +0800 Subject: [PATCH 09/10] fix(desktop): consume staged attachments only on confirmed admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #4815. `useQuoteCompanion.steer()` returns true on an `outcome_unknown` result — the supported reconnect/failure path answers without an admission receipt — and the panel then cleared the submitted attachments although the Message may never have been admitted. Quotes already waited through `consumeOnAdmission`, so one structured Message had two different cleanup boundaries. Both `send` and `steer` now take an `onAdmitted` callback that fires from the shared admission boundary: confirmed admission binds the Turn and consumes the staged quotes and submitted attachments together; an unknown outcome keeps everything staged until the reconciliation binds the Turn (a late admission fires the callback then) or a retraction releases the Message with the attachments still staged for retry. The panel no longer clears attachments on the optimistic return. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/quote-companion-retry.test.ts | 109 +++++++++++++++++- .../tools/side-chat/quote-companion-panel.tsx | 25 ++-- .../tools/side-chat/use-quote-companion.ts | 41 +++++-- 3 files changed, 154 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 363562e73b..7ef1481c40 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -32,6 +32,7 @@ import type { TurnRecord, } from '@maka/core/session'; import type { ContextCompactResult } from '@maka/runtime-host/protocol'; +import type { WorkbarIngestInput } from '../../renderer/features/workbar/ports.js'; import { createFakeWorkbarServices, dispatchQuoteCompanionInput, @@ -57,6 +58,11 @@ const originalGlobals = { let mountedRoot: Root | undefined; const SOURCE_SESSION = session('source-session'); type SideChatStopTarget = Parameters[1]; +type SteerFn = ( + text: string, + attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, +) => Promise; type QueueUpdate = Extract; type QueueEntry = NonNullable[number]; @@ -134,7 +140,7 @@ async function renderProbe( modelChoices?: readonly ChatModelChoice[]; ready?: (container: Element) => boolean; onSend?: (send: (text: string) => Promise) => void; - onSteer?: (steer: (text: string) => Promise) => void; + onSteer?: (steer: SteerFn) => void; onStop?: (stop: () => Promise) => void; onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; confirmBypass?: () => Promise; @@ -199,7 +205,7 @@ async function renderOwnershipProbe( } = {}, ) { let send!: (text: string) => Promise; - let steer!: (text: string) => Promise; + let steer!: SteerFn; let stop!: () => Promise; let setPermissionMode!: (mode: PermissionMode) => Promise; let eventHandler: ((event: SessionEvent) => void) | undefined; @@ -228,7 +234,8 @@ async function renderOwnershipProbe( return { ...rendered, send: (text: string) => send(text), - steer: (text: string) => steer(text), + steer: (text: string, attachmentItems?: WorkbarIngestInput[], onAdmitted?: () => void) => + steer(text, attachmentItems, onAdmitted), stop: () => stop(), setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode), emit(event: SessionEvent) { @@ -2012,7 +2019,7 @@ function QuoteCompanionProbe(props: { function QuoteCompanionOwnershipProbe(props: { onSend: (send: (text: string) => Promise) => void; - onSteer?: (steer: (text: string) => Promise) => void; + onSteer?: (steer: SteerFn) => void; onStop?: (stop: () => Promise) => void; onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; onContextCompactionError?: (sessionId: string, error: unknown) => void; @@ -2189,3 +2196,97 @@ test('a structured-only steer (empty text with a staged quote) rides the steerin ['streaming excerpt'], ); }); + +test('a steer with staged attachments consumes them only on confirmed admission', async () => { + const admissionIds: string[] = []; + const rendered = await renderOwnershipProbe( + { + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, admissionId) => { + const id = admissionId ?? ''; + admissionIds.push(id); + // The reconnect/failure path answers without an admission receipt. + return { kind: 'outcome_unknown', messageId: id }; + }, + }, + { pendingQuotes: [] }, + ); + + await act(async () => { + assert.equal(await rendered.send('initial prompt'), true); + await Promise.resolve(); + }); + await waitUntil( + () => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true', + ); + + const consumed: string[] = []; + await act(async () => { + assert.equal( + await rendered.steer('', [{ approvalId: 'a-1', name: 'notes.txt' }], () => { + consumed.push('admitted'); + }), + true, + ); + await Promise.resolve(); + }); + // The optimistic accept must not retire the attachments: with no admission + // receipt the Message may still be admitted or retracted by the Host. + assert.deepEqual(consumed, []); + + // The late admission arrives through the fork's event stream; only now does + // the confirmed-admission boundary fire. + await act(async () => { + rendered.emit(messageAdmittedEvent('steer-late-admit', 'steered-turn', 1, admissionIds[0])); + }); + assert.deepEqual(consumed, ['admitted']); +}); + +test('an unknown steer outcome that later retracts keeps the staged attachments', async () => { + const admissionIds: string[] = []; + const rendered = await renderOwnershipProbe( + { + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, admissionId) => { + const id = admissionId ?? ''; + admissionIds.push(id); + return { kind: 'outcome_unknown', messageId: id }; + }, + }, + { pendingQuotes: [] }, + ); + + await act(async () => { + assert.equal(await rendered.send('initial prompt'), true); + await Promise.resolve(); + }); + await waitUntil( + () => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true', + ); + + const consumed: string[] = []; + await act(async () => { + assert.equal( + await rendered.steer('', [{ approvalId: 'a-1', name: 'notes.txt' }], () => { + consumed.push('admitted'); + }), + true, + ); + await Promise.resolve(); + }); + assert.deepEqual(consumed, []); + + // A retraction releases the Message without consuming anything staged: the + // user keeps the attachments and may retry the steer. + await act(async () => { + rendered.emit({ + type: 'message_admission', + id: 'steer-late-retract', + turnId: 'old-turn', + ts: 2, + messageId: admissionIds[0], + outcome: 'retracted', + }); + }); + assert.deepEqual(consumed, []); +}); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index e7819f92a7..256730293c 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -348,14 +348,19 @@ export function QuoteCompanionPanel(props: { streaming: companion.streaming, compact: companion.compact, steer: async (text) => { - const accepted = await companion.steer( + // Submitted attachments retire on the confirmed-admission + // boundary, not on the hook's optimistic return: an unknown + // outcome keeps them staged for retry (#4804). + const submitted = pendingAttachments; + const submittedItems = + submitted.length > 0 ? toComposerIngestItems(submitted) : undefined; + return companion.steer( text, - pendingAttachments.length > 0 - ? toComposerIngestItems(pendingAttachments) + submittedItems, + submittedItems + ? () => clearSubmittedAttachments(submitted) : undefined, ); - if (accepted) clearSubmittedAttachments(pendingAttachments); - return accepted; }, send: async () => { try { @@ -367,16 +372,20 @@ export function QuoteCompanionPanel(props: { ); return false; } + // Same admission-boundary retirement as `steer` above. + const submitted = pendingAttachments; + const submittedItems = + submitted.length > 0 ? toComposerIngestItems(submitted) : undefined; const accepted = await companion.send( text, - pendingAttachments.length > 0 - ? toComposerIngestItems(pendingAttachments) + submittedItems, + submittedItems + ? () => clearSubmittedAttachments(submitted) : undefined, ); if (accepted) { props.onPromptAccepted?.(props.panelId, text); } - if (accepted) clearSubmittedAttachments(pendingAttachments); return accepted; }, }) diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 81a8ba3fbd..b3fb2a88c9 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -184,11 +184,22 @@ export interface UseQuoteCompanionResult { /** Runs `/compact` against the committed companion fork when it is idle. */ compact: () => Promise; /** Returns whether the send was accepted; false leaves the draft + staged - * quotes in place so the user can retry. */ - send: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; + * quotes in place so the user can retry. `onAdmitted` fires only once the + * Host admission is confirmed (never on an unknown outcome), so callers + * can retire submitted attachments on the same boundary as the quotes. */ + send: ( + text: string, + attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, + ) => Promise; /** Insert text — or a structured-only quote/attachment — into the active - * companion turn at the next model step. */ - steer: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; + * companion turn at the next model step. `onAdmitted` follows the same + * confirmed-admission boundary as `send`. */ + steer: ( + text: string, + attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, + ) => Promise; setPermissionMode: (mode: PermissionMode) => Promise; regenerate: (turnId: string) => Promise; stop: () => Promise; @@ -851,6 +862,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan async ( text: string, attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, ): Promise => { const trimmed = text.trim(); if (isExactCompactCommand(trimmed)) return compact(); @@ -890,7 +902,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const admission: PendingAdmission = { messageId: turnId, events: [], - consumeOnAdmission: () => onQuotesConsumed(quoteSnapshot), + // Quotes and submitted attachments share one cleanup boundary — + // confirmed Host admission (#4804). An unknown outcome keeps them + // staged until the reconciliation binds the Turn or a retraction + // releases the send, so nothing staged is consumed on a guess. + consumeOnAdmission: () => { + onQuotesConsumed(quoteSnapshot); + onAdmitted?.(); + }, }; setPendingUserMessages((current) => [ ...current.filter((message) => message.id !== turnId), @@ -1128,6 +1147,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan async ( text: string, attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, ): Promise => { const id = companionIdRef.current; const trimmed = text.trim(); @@ -1148,10 +1168,13 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan messageId: admissionId, events: [], // Quotes stay staged until the Host admits the steering Message; a - // failed or retracted steer keeps them available for retry. - ...(quoteSnapshot.quotes.length > 0 - ? { consumeOnAdmission: () => onQuotesConsumed(quoteSnapshot) } - : {}), + // failed or retracted steer keeps them available for retry. Submitted + // attachments share that boundary: an unknown outcome keeps them + // staged until reconciliation binds the Turn or the steer retracts. + consumeOnAdmission: () => { + if (quoteSnapshot.quotes.length > 0) onQuotesConsumed(quoteSnapshot); + onAdmitted?.(); + }, }; setPendingAdmission(admission); try { From 816e6dc5a151f92837be31d97e00d3fe522d3498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Tue, 8 Sep 2026 15:36:58 +0800 Subject: [PATCH 10/10] fix(desktop): pass the renderer architecture check on the merged head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI run on 11aa81e32 failed the renderer-architecture gate twice: the regression test imported `WorkbarIngestInput` straight from `ports.js`, which only `index`/`testing` re-exports may reach from feature code, and the `allowAttachmentOnlySend` line grew the frozen `app-shell.tsx` token budget by one. The type now ships through the workbar `testing.js` entry, and the side-chat panel — not the frozen shell — opts into attachment-only sends, which is where the #4804 acceptance scenario actually sends from. The branch also merges the current `main` (#5001 included), so the frozen-file budget is evaluated against the live baseline. Generated-by: GLM-5.3-Flash (ZCode) --- apps/desktop/src/main/__tests__/quote-companion-retry.test.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 1 - apps/desktop/src/renderer/features/workbar/testing.ts | 1 + .../features/workbar/tools/side-chat/quote-companion-panel.tsx | 2 ++ 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 7ef1481c40..89a619eed7 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -32,7 +32,6 @@ import type { TurnRecord, } from '@maka/core/session'; import type { ContextCompactResult } from '@maka/runtime-host/protocol'; -import type { WorkbarIngestInput } from '../../renderer/features/workbar/ports.js'; import { createFakeWorkbarServices, dispatchQuoteCompanionInput, @@ -41,6 +40,7 @@ import { WorkbarServicesProvider, type CompanionQuoteSnapshot, type StagedCompanionQuote, + type WorkbarIngestInput, type WorkbarServices, } from '../../renderer/features/workbar/testing.js'; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1803cc7fe1..6c0a35b7ce 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2704,7 +2704,6 @@ function AppShellContent({ : undefined } slashCommands={desktopSlashCommands} - allowAttachmentOnlySend pendingAttachments={pendingAttachments} onRemoveAttachment={removeAttachment} pendingQuotes={pendingQuotes} diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 11c1e45a56..40e09d330e 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -24,6 +24,7 @@ export type { WorkbarServices, WorkbarSessionTracePage, WorkbarSessionUsageSummary, + WorkbarIngestInput, } from './ports.js'; export * from './model/workbar-tabs.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 256730293c..fdc80f58c6 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -398,6 +398,8 @@ export function QuoteCompanionPanel(props: { disabled={!companion.modelReady} onPickAttachments={pickAttachments} onAttachFilePaths={attachFilePaths} + // The side chat submits staged context without a prompt (#4804). + allowAttachmentOnlySend pendingAttachments={pendingAttachments} onRemoveAttachment={removeAttachment} mentionSkills={mentions?.mentionSkills}