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
24 changes: 24 additions & 0 deletions apps/desktop/e2e/send-message.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,27 @@ test('Enter mid-IME commits the candidate, then an ordinary send streams a reply

await expect(page.getByRole('log').getByText(/Fake backend received: hello e2e/)).toBeVisible();
});

test('a reference-sized paste can be sent without typing an extra prompt', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const pasted = ['QUOTE_ONLY_MARKER', ...Array(10).fill('reference material')].join('\n');
await composer.evaluate((element, text) => {
const clipboardData = new DataTransfer();
clipboardData.setData('text/plain', text);
element.dispatchEvent(
new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData }),
);
}, pasted);

await expect(page.getByText('粘贴的文本', { exact: true })).toBeVisible();
await expect(composer).toHaveText('');
const send = page.getByRole('button', { name: '发送' });
await expect(send).toBeEnabled();
await send.click();

await expect(page.getByRole('log').getByText('Fake backend received:', { exact: true }))
.toBeVisible();
await expect(send).toBeDisabled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,30 @@ describe('permission response IPC boundary', () => {
text: '',
skillIds: ['writer'],
});
assert.deepEqual(
normalizeSessionSendCommand({
type: 'send',
text: '',
quotes: [{ text: 'pasted message', label: 'Pasted text' }],
}),
{
type: 'send',
text: '',
quotes: [{ text: 'pasted message', label: 'Pasted text' }],
},
);
assert.deepEqual(
normalizeSessionSendCommand({
type: 'send',
text: '',
attachmentItems: [{ approvalId: 'image-approval', name: 'image.png' }],
}),
{
type: 'send',
text: '',
attachmentItems: [{ approvalId: 'image-approval', name: 'image.png' }],
},
);
});

it('rejects malformed or oversized send payloads', () => {
Expand Down Expand Up @@ -275,7 +299,7 @@ describe('permission response IPC boundary', () => {
}
});

it('rejects empty send text without skills', () => {
it('rejects empty send text without skills, quotes, or attachments', () => {
assert.throws(
() => normalizeSessionSendCommand({ type: 'send', text: '' }),
/Invalid send text/,
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/main/permission-response-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,17 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi
const displayText =
value.displayText === undefined ? undefined : normalizeSendText(value.displayText);
const skillIds = normalizeSessionSkillIds(value.skillIds);
if (!text.trim() && skillIds.length === 0) {
const quotes = normalizeOptionalQuotes(value.quotes);
const retainedAttachments = normalizeOptionalRetainedAttachments(value.retainedAttachments);
const hasAttachmentInput =
(Array.isArray(value.attachmentItems) && value.attachmentItems.length > 0) ||
(retainedAttachments.retainedAttachments?.length ?? 0) > 0;
if (
!text.trim() &&
skillIds.length === 0 &&
quotes.quotes === undefined &&
!hasAttachmentInput
) {
throw new Error('Invalid send text');
}
return {
Expand All @@ -220,12 +230,12 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi
...(displayText !== undefined ? { displayText } : {}),
...(skillIds.length > 0 ? { skillIds } : {}),
...(value.attachmentItems !== undefined ? { attachmentItems: value.attachmentItems } : {}),
...normalizeOptionalRetainedAttachments(value.retainedAttachments),
...retainedAttachments,
...(value.turnOrchestration !== undefined
? { turnOrchestration: normalizeTurnOrchestration(value.turnOrchestration) }
: {}),
...normalizeOptionalDirectoryReferences(value.directoryReferences),
...normalizeOptionalQuotes(value.quotes),
...quotes,
...normalizeOptionalWorkspaceFileReferences(
value.workspaceFileReferences,
displayText ?? text,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/__tests__/runtime-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,10 @@ describe('runtimeEventHasModelVisibleContent', () => {
test('classifies model-visible content by semantic kind', () => {
const visible = [
baseEvent({ role: 'user', content: { kind: 'text', text: 'hi' } }),
baseEvent({
role: 'user',
content: { kind: 'text', text: '', quotes: [{ text: 'quoted context' }] },
}),
baseEvent({ content: { kind: 'thinking', text: 'r' } }),
baseEvent({ content: { kind: 'function_call', id: '1', name: 'Read', args: {} } }),
baseEvent({
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/runtime-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1479,7 +1479,16 @@ export function runtimeEventHasModelVisibleContent(event: RuntimeEvent): boolean
if (!content) return false;
switch (content.kind) {
case 'text':
return content.text.length > 0;
// Text events may carry model-visible inline context even when their
// authored body is empty. Quotes, attachments, and directory references
// are folded into provider text by formatTextWithInlineRefs; treating
// those events as empty drops the current user turn from durable replay.
return (
content.text.length > 0 ||
(content.quotes?.length ?? 0) > 0 ||
(content.attachments?.length ?? 0) > 0 ||
(content.directoryReferences?.length ?? 0) > 0
);
case 'thinking':
case 'function_call':
case 'function_response':
Expand Down
10 changes: 10 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1827,6 +1827,16 @@ describe('Runtime Host bootstrap protocol', () => {
});
const directory = { hostId: 'host-a', path: '/workspace/source' };
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56);
const quoteOnly = { text: '', quotes: [{ text: 'pasted message' }] };
assert.doesNotThrow(() => submit(quoteOnly));
assert.deepEqual(decodeMessageContent(quoteOnly), quoteOnly);
const attachmentOnly = {
text: '',
attachments: [attachmentRef({ kind: 'workspace_file', relativePath: 'image.png' })],
};
assert.doesNotThrow(() => submit(attachmentOnly));
assert.deepEqual(decodeMessageContent(attachmentOnly), attachmentOnly);
assert.throws(() => submit({ text: '' }), isInvalidFrame);
assert.doesNotThrow(() => submit({ text: 'valid', directoryReferences: [directory] }));
for (const directoryReferences of [
Array.from({ length: 5 }, () => directory),
Expand Down
9 changes: 8 additions & 1 deletion packages/runtime-host/src/protocol/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,14 @@ export function decodeMessageContent(value: unknown, allowEmptyText = false): Me
} catch {
throw invalidProtocolFrame('Invalid Message content');
}
requireUtf8String(content.text, 'Message text', TURN_MESSAGE_TEXT_MAX_BYTES, allowEmptyText);
const hasStructuredContent =
(content.quotes?.length ?? 0) > 0 || (content.attachments?.length ?? 0) > 0;
requireUtf8String(
content.text,
'Message text',
TURN_MESSAGE_TEXT_MAX_BYTES,
allowEmptyText || hasStructuredContent,
);
if (content.displayText !== undefined) {
requireUtf8String(
content.displayText,
Expand Down
92 changes: 92 additions & 0 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,98 @@ describe('AiSdkBackend sandbox boundary convergence', () => {
});

describe('AiSdkBackend model history', () => {
test('sends quote-only current turns through the durable replay projection', async () => {
const quote = { text: 'QUOTE_ONLY_DURABLE_MARKER', label: 'Pasted text' };
const durable = durableTurnHarness('turn-quote-only', '');
const quotedAnchor: RuntimeEvent = {
...durable.anchor,
content: { kind: 'text', text: '', quotes: [quote] },
};
durable.ledger.splice(0, 1, quotedAnchor);
const model = textCompletionModel('received quote');
const backend = createTestAiSdkBackend({
sessionId: 'session-1',
header: header(),
appendMessage: async () => {},
connection: connection(),
apiKey: 'sk-test',
modelId: 'mock-model-id',
modelFactory: () => model,
tools: [],
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
newId: idGenerator(),
now: monotonicClock(),
});

const events = await drainDurably(
backend.send(
durable.input({
quotes: [quote],
headAnchorRuntimeEvent: quotedAnchor,
}),
),
durable,
);

assert.equal(
events.some((event) => event.type === 'error'),
false,
);
assert.equal(model.doStreamCalls.length, 1);
assert.match(JSON.stringify(model.doStreamCalls[0]?.prompt), /QUOTE_ONLY_DURABLE_MARKER/);
});

test('sends attachment-only current turns through the durable replay projection', async () => {
const attachment = {
kind: 'image' as const,
name: 'image.png',
mimeType: 'image/png',
bytes: 3,
ref: {
kind: 'session_file' as const,
sessionId: 'session-1',
relativePath: 'attachments/image.png',
},
};
const durable = durableTurnHarness('turn-attachment-only', '');
const attachmentAnchor: RuntimeEvent = {
...durable.anchor,
content: { kind: 'text', text: '', attachments: [attachment] },
};
durable.ledger.splice(0, 1, attachmentAnchor);
const model = textCompletionModel('received attachment');
const backend = createTestAiSdkBackend({
sessionId: 'session-1',
header: header(),
appendMessage: async () => {},
connection: connection(),
apiKey: 'sk-test',
modelId: 'mock-model-id',
modelFactory: () => model,
tools: [],
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
newId: idGenerator(),
now: monotonicClock(),
});

const events = await drainDurably(
backend.send(
durable.input({
attachments: [attachment],
headAnchorRuntimeEvent: attachmentAnchor,
}),
),
durable,
);

assert.equal(
events.some((event) => event.type === 'error'),
false,
);
assert.equal(model.doStreamCalls.length, 1);
assert.match(JSON.stringify(model.doStreamCalls[0]?.prompt), /image\.png/);
});

test('records structured sandbox failure metadata on tool failure traces', async () => {
const traces: RunTraceEvent[] = [];
const messages: ToolResultMessage[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import { formatTextWithInlineRefs } from '../model-history.js';

test('quote-only messages keep their pasted text in model context', () => {
const formatted = formatTextWithInlineRefs({
kind: 'text',
text: '',
quotes: [{ text: 'QUOTE_ONLY_MARKER', label: 'Pasted text' }],
});

assert.match(formatted, /QUOTE_ONLY_MARKER/);
assert.match(formatted, /<quoted_excerpt label="Pasted text">/);
});

test('replay uses the same reference form and escapes path markup as untrusted data', () => {
const formatted = formatTextWithInlineRefs({
kind: 'text',
Expand Down
19 changes: 19 additions & 0 deletions packages/runtime/src/__tests__/runtime-event-read-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,25 @@ function equivalentLegacyMessages(): StoredMessage[] {
}

describe('projectRuntimeEventsToStoredMessages', () => {
test('replays quote-only user content instead of classifying it as empty text', () => {
const replay = buildRuntimeEventModelReplayPlan([
ev({
role: 'user',
author: 'user',
content: {
kind: 'text',
text: '',
quotes: [{ text: 'QUOTE_ONLY_REPLAY_MARKER', label: 'Pasted text' }],
},
}),
]);

assert.deepStrictEqual(replay.diagnostics, []);
const item = replay.items[0];
assert.equal(item?.kind, 'text');
assert.match(item?.kind === 'text' ? item.content : '', /QUOTE_ONLY_REPLAY_MARKER/);
});

test('exposes a session image ref as a Markdown image source to the model', () => {
const replay = buildRuntimeEventModelReplayPlan([
ev({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,37 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalizeRootTurnAdmissionPayload } from '../agent-run-store.js';

test('root admission accepts a quote-only message but still rejects empty content', () => {
const content = {
text: '',
quotes: [{ text: 'pasted message', label: 'Pasted text' }],
} as const;

assert.deepEqual(normalizeRootTurnAdmissionPayload(content, []).normalizedInput, content);
assert.throws(() => normalizeRootTurnAdmissionPayload({ text: '' }, []));
});

test('root admission accepts an attachment-only message', () => {
const content = {
text: '',
attachments: [
{
kind: 'image' as const,
name: 'image.png',
mimeType: 'image/png',
bytes: 3,
ref: {
kind: 'session_file' as const,
sessionId: 'session-1',
relativePath: 'attachments/image.png',
},
},
],
};

assert.deepEqual(normalizeRootTurnAdmissionPayload(content, []).normalizedInput, content);
});

test('root admission preserves an explicit empty inline-reference marker from its sources', () => {
const content = { text: 'plain', inlineReferences: [] } as const;
const normalized = normalizeRootTurnAdmissionPayload(content, [
Expand Down
6 changes: 5 additions & 1 deletion packages/storage/src/agent-run-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1544,7 +1544,11 @@ function normalizeRootTurnMessageContent(
}
throw new Error(`Invalid ${description}`);
}
if (normalized.text.length === 0 || (normalized.attachments?.length ?? 0) > maxAttachments) {
const hasModelContent =
normalized.text.length > 0 ||
(normalized.quotes?.length ?? 0) > 0 ||
(normalized.attachments?.length ?? 0) > 0;
if (!hasModelContent || (normalized.attachments?.length ?? 0) > maxAttachments) {
throw new Error(`Invalid ${description}`);
}
for (const [index, attachment] of (normalized.attachments ?? []).entries()) {
Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ const RUNNING_TOOL: TurnTimelineItem = {
items: [{ toolUseId: 'tool-1', toolName: 'read', status: 'running', args: {} }],
};

test('does not render an empty user bubble for a quote-only message', async () => {
const { container, root } = domRoot();
const turn = {
...turnWith([{ ...ANSWER, live: false }]),
user: {
id: 'quote-only',
role: 'user' as const,
text: '',
ts: 1,
quotes: [{ text: 'quoted content', label: 'Pasted text' }],
},
};

await renderTurn(root, turn);

assert.ok(container.querySelector('.maka-user-quotes'));
assert.equal(container.querySelector('.maka-chat-message-bubble-user'), null);
assert.ok(container.querySelector('.maka-message-meta'));
});

test('renders an aborted turn outcome as an inline system status notice', async () => {
const { container, root } = domRoot();
await renderTurn(root, {
Expand Down
Loading