Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/core/src/__tests__/runtime-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/runtime-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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':
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"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."
}
71 changes: 71 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1912,6 +1912,77 @@ 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('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',
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-host/src/protocol/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-host/src/protocol/session-continuity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
} from './message.js';
import { defineOperation } from './operation-spec.js';
import {
decodeMessageContent,
decodeMessageAdmissionContent,
decodeTurnSnapshot,
type MessageContent,
type TurnSnapshot,
Expand Down Expand Up @@ -775,7 +775,7 @@ function decodeSessionSteeringEvent(record: Record<string, unknown>): 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),
};
}

Expand Down
15 changes: 14 additions & 1 deletion packages/runtime-host/src/protocol/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down