diff --git a/apps/desktop/e2e/send-message.spec.ts b/apps/desktop/e2e/send-message.spec.ts index 55fbc49611..ed5fe11390 100644 --- a/apps/desktop/e2e/send-message.spec.ts +++ b/apps/desktop/e2e/send-message.spec.ts @@ -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(); +}); diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index 5b186c081d..a939b340bd 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -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', () => { @@ -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/, diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 9fc25de20e..19c1c0d645 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -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 { @@ -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, diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 18c4dde645..44d7b9993f 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -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({ diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 8323d289b4..02dc3870f8 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -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': diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 9ac1cfde4f..c7f09e1c62 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -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), diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index dafa428649..eefb030a30 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -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, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index ef34267203..576c5f8533 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -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[] = []; diff --git a/packages/runtime/src/__tests__/directory-reference-model-context.test.ts b/packages/runtime/src/__tests__/directory-reference-model-context.test.ts index e3ff831b59..2c29ba4183 100644 --- a/packages/runtime/src/__tests__/directory-reference-model-context.test.ts +++ b/packages/runtime/src/__tests__/directory-reference-model-context.test.ts @@ -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, //); +}); + test('replay uses the same reference form and escapes path markup as untrusted data', () => { const formatted = formatTextWithInlineRefs({ kind: 'text', diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index c47760680f..e560531cac 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -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({ 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..1d5dcb309f 100644 --- a/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts +++ b/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts @@ -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, [ diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index dd855c9b3f..26674ff553 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -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()) { diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index 4f6b66e0c0..a899be72e7 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -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, { diff --git a/packages/ui/src/__tests__/composer-send-toggle.test.tsx b/packages/ui/src/__tests__/composer-send-toggle.test.tsx index c4a097c1a1..6477ea7d5d 100644 --- a/packages/ui/src/__tests__/composer-send-toggle.test.tsx +++ b/packages/ui/src/__tests__/composer-send-toggle.test.tsx @@ -64,6 +64,100 @@ test('a running composer keeps Send alone — no mode switch in the send slot', assert.doesNotMatch(markup, /SegmentedControl/); }); +test('a pasted-text quote can be sent without additional inline text', async () => { + const original = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => ({ + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const sent: string[] = []; + + try { + await act(() => root.render( + + { sent.push(text); }} + onStop={() => undefined} + /> + , + )); + const send = container.querySelector('button[aria-label="Send"]'); + assert.ok(send); + assert.equal(send.disabled, false); + const form = container.querySelector('form'); + assert.ok(form); + await act(async () => { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + assert.deepEqual(sent, ['']); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); + +test('an image attachment can be sent without additional inline text', async () => { + const original = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => ({ + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const sent: string[] = []; + + try { + await act(() => root.render( + + { sent.push(text); }} + onStop={() => undefined} + /> + , + )); + const send = container.querySelector('button[aria-label="Send"]'); + assert.ok(send); + assert.equal(send.disabled, false); + const form = container.querySelector('form'); + assert.ok(form); + await act(async () => { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + assert.deepEqual(sent, ['']); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); + test('keeps Host order visible until the reordered projection arrives', async () => { const original = { document: globalThis.document, diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 69cc381b8e..63ff5a1929 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -167,6 +167,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { const copyText = getConversationCopy(locale).messages; const nonImageAttachments = props.attachments?.filter((attachment) => attachment.kind !== 'image') ?? []; const imageAttachments = props.attachments?.filter((attachment) => attachment.kind === 'image') ?? []; + const hasText = props.text.length > 0; const editActionLabel = props.editDisabled ? (props.editDisabledReason ?? copyText.editMessageDisabledRunning) : copyText.editMessage; @@ -251,18 +252,22 @@ const UserMessageBody = memo(function UserMessageBody(props: { ))} ) : null} - - {props.inlineReferences ? ( - - ) : ( - - {props.text} - - )} - + {hasText ? ( + + {props.inlineReferences ? ( + + ) : ( + + {props.text} + + )} + + ) : ( + userMetadata + )} ); }); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 9ba7b50206..cb0e6faa16 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -1192,6 +1192,11 @@ export const Composer = forwardRef< return pasteAsInlineToken.onPaste(event, pasted); }, }; + // Structured context is staged beside the editor instead of being copied + // into it. It is still user-authored message content, so a quote or file on + // its own must keep the same send affordance as inline text. + const hasPendingQuote = (props.pendingQuotes?.length ?? 0) > 0; + const hasPendingAttachment = (props.pendingAttachments?.length ?? 0) > 0; useImperativeHandle( ref, @@ -1255,11 +1260,11 @@ export const Composer = forwardRef< || sendPendingRef.current || importActionOwnerRef.current?.pending ) return; - // There is one authoritative draft: staged Skills and files serialize into - // `text`. The optional metadata below is a send-time rendering snapshot of - // file chips that still exist in the editor, not a second draft state. + // Inline text remains the authoritative editable draft. Large pasted text + // is deliberately staged beside it as a QuoteRef, and staged files are + // likewise independently sufficient to submit. const text = composerWireText(textPort.getValue()); - if (!text) return; + if (!text && !hasPendingQuote && !hasPendingAttachment) return; const editable = editableNode(); const workspaceFileReferences = editable ? workspaceFileReferencePositions(editable) : []; const submittedDraftKey = activeDraftKey(); @@ -1447,7 +1452,7 @@ export const Composer = forwardRef< props.sendBlocked || sendPending || importActionBusy || - !text.trim() || + (!text.trim() && !hasPendingQuote && !hasPendingAttachment) || noModelConnection; // The disabled Send is explanatory only in the no-model dead-end; other // disabled reasons (empty draft, in-flight import) keep the neutral label. @@ -1458,7 +1463,8 @@ export const Composer = forwardRef< // returns to Send (the host queues it as a follow-up). Stop is not lost in // that window: Esc interrupts from the input, which is where the hands already // are. - const stopShown = props.streaming === true && !text.trim(); + const stopShown = + props.streaming === true && !text.trim() && !hasPendingQuote && !hasPendingAttachment; // The pending plate renders the follow-up queue only: steering entries are // already handed to the active Turn and leave the plate at that moment. const queueCount = props.queuedMessages?.length ?? 0;