diff --git a/src/main/native-chat/transcript-input-content.perf.test.ts b/src/main/native-chat/transcript-input-content.perf.test.ts index f0b1f652f12..04c82cb2335 100644 --- a/src/main/native-chat/transcript-input-content.perf.test.ts +++ b/src/main/native-chat/transcript-input-content.perf.test.ts @@ -172,23 +172,30 @@ it.each([1000, 10000])('does no glue work on %i rows preceding live legacy bound }) it.each([ - [8, 'continue'], - [80, 'continue'], - [8, 'continue continue'], - [80, 'continue continue'] + [8, 'continue', false], + [80, 'continue', false], + [8, 'continue continue', false], + [80, 'continue continue', false], + [8, 'continue continue', true], + [80, 'continue continue', true] ] as const)( 'bounds allocation work for eight identical legacy sends across %i eligible rows of %s', - (rows, text) => { + (rows, text, image) => { const count = 8 const pending: NativeChatPendingSend[] = Array.from({ length: count }, (_, index) => ({ id: `same-${index}`, - text: 'continue', + text: image && index < 2 ? 'continue continue' : 'continue', + imagePaths: image && index === 0 ? ['/tmp/i.png'] : undefined, sentAt: 1 })) const messages = [ ...Array.from({ length: rows }, (_, index) => user(text, `same-row-${index}`)), { ...user('', 'reply'), role: 'assistant' as const } ] + if (image) { + const group = [pending[0], pending[2], pending[3]] + expect(visibleNativeChatPendingSends(group, [messages[0]])).toEqual([]) + } const BaseMap = globalThis.Map let writes = 0 let lookups = 0 @@ -211,8 +218,10 @@ it.each([ expect(remaining).toEqual([]) // Eight legal row alternatives per signature; each state assigns, requires or omits each send. const states = 3 ** count - const witnessesPerRow = 2 ** count - 1 + count - const maxAllocationWrites = count * states * (witnessesPerRow + 1) + const witnessesPerRow = (image ? 2 : 1) * (2 ** count - 1) + count + // This corpus has two exact-rank patterns and one advance pattern; each signature keeps at most P rows. + const exactPatterns = image ? 2 : 1 + const maxAllocationWrites = count * exactPatterns * states * (witnessesPerRow + 1) expect(writes).toBeLessThanOrEqual(maxAllocationWrites + rows + 2 ** count) // Every candidate allocates before its next-state lookup, including candidates never written. expect(allocationLookups).toBeLessThanOrEqual(maxAllocationWrites + rows * count + 2 ** count) diff --git a/src/main/native-chat/transcript-input-content.test.ts b/src/main/native-chat/transcript-input-content.test.ts index cf6b7e01e42..5b5f02360dd 100644 --- a/src/main/native-chat/transcript-input-content.test.ts +++ b/src/main/native-chat/transcript-input-content.test.ts @@ -15,7 +15,6 @@ const pending: NativeChatPendingSend = { id: 'send', sourceAgent: 'codex', text: ' literal [Image #1]\nkeep spaces\u0085here ', - imagePaths: ['/tmp/one.png', '/tmp/two.png'], sentAt: 1 } @@ -83,19 +82,17 @@ function reconcile(payload: Record, entry = pending) { return prunePendingSends([entry], prepareNativeChatMessages([user, answer], 'codex')) } -it('settles exact submitted Codex text and ordered local images through source decoding and display preparation', () => { - const payload = { - message: '[Image #1] [Image #2] literal [Image #1]\nkeep spaceshere', - local_images: ['/tmp/one.png', '/tmp/two.png'], - images: [] - } - expect(reconcile(payload)).toEqual([]) - expect(reconcile({ ...payload, local_images: ['/tmp/two.png', '/tmp/one.png'] })).toEqual([ - pending - ]) +it('settles exact submitted Codex text while retaining literal image markers and spacing', () => { + const payload = { message: 'literal [Image #1]\nkeep spaceshere', local_images: [], images: [] } + const entry = { ...pending, text: 'literal [Image #1]\nkeep spaces\u0085here' } + expect(reconcile(payload, entry)).toEqual([]) + expect(reconcile({ ...payload, local_images: ['/tmp/one.png'] }, entry)).toEqual([entry]) expect( - reconcile({ ...payload, message: payload.message.replace('keep spaces', 'keep spaces') }) - ).toEqual([pending]) + reconcile( + { ...payload, message: payload.message.replace('keep spaces', 'keep spaces') }, + entry + ) + ).toEqual([entry]) }) it('does not turn unaccounted file-backed attachments or literal-marker differences into text-only delivery', () => { @@ -115,8 +112,7 @@ it('matches the complete submitted file-mention text without inventing attachmen id: 'mention', sourceAgent: 'omp', sentAt: 1, - text: ' caption\tline\nend ', - imagePaths: ['/tmp/one image.png'] + text: '@"/tmp/one image.png" caption\tline\nend' } const readOmp = (extra: unknown[] = []) => decodeOmpTranscriptLine( @@ -235,7 +231,7 @@ it('does not allocate a recorded Codex response/completed pair to two sends', () ).toEqual(['second']) }) -it('matches per-frame OpenCode caption trimming and C1 paste sanitization without normalizing short typed input', () => { +it('matches OpenCode text trimming and C1 paste sanitization without normalizing short typed input', () => { const decode = (text: string, id = 'user') => opencodeMessages( { id, role: 'user', timestamp: null }, @@ -251,19 +247,14 @@ it('matches per-frame OpenCode caption trimming and C1 paste sanitization withou blocks: [] } const input: NativeChatPendingSend = { - id: 'image', + id: 'text', sourceAgent: 'opencode', text: ' alpha\n\u009b31mbeta\ngamma ', - imagePaths: ['/tmp/a.png'], sentAt: 1 } - expect(prunePendingSends([input], [decode('@/tmp/a.png alpha\nbeta\ngamma'), answer])).toEqual([]) - expect( - prunePendingSends([input], [decode('@/tmp/a.png alpha\nbeta\ngamma '), answer]) - ).toEqual([]) - expect(prunePendingSends([input], [decode('@/tmp/a.png alpha beta gamma'), answer])).toEqual([ - input - ]) + expect(prunePendingSends([input], [decode('alpha\nbeta\ngamma'), answer])).toEqual([]) + expect(prunePendingSends([input], [decode(' alpha\nbeta\ngamma '), answer])).toEqual([]) + expect(prunePendingSends([input], [decode('alpha beta gamma'), answer])).toEqual([input]) const short = { ...input, id: 'short', text: ` ${'x'.repeat(160)} `, imagePaths: [] } expect(prunePendingSends([short], [decode('x'.repeat(160)), answer])).toEqual([short]) expect(prunePendingSends([short], [decode(` ${'x'.repeat(160)} `), answer])).toEqual([]) diff --git a/src/main/runtime/rpc/methods/native-chat/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat/native-chat.test.ts index 9599a06485f..e925f35321c 100644 --- a/src/main/runtime/rpc/methods/native-chat/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat/native-chat.test.ts @@ -186,7 +186,6 @@ describe('nativeChat.readSession clientKind truncation gating', () => { const pending = { id: 'pending', text, - imagePaths: images, sourceAgent: 'codex' as const, sentAt: 0 } diff --git a/src/main/runtime/terminal/input/terminal-agent-prompt-chat.ts b/src/main/runtime/terminal/input/terminal-agent-prompt-chat.ts index 5389abe6ead..12ba03bc9b8 100644 --- a/src/main/runtime/terminal/input/terminal-agent-prompt-chat.ts +++ b/src/main/runtime/terminal/input/terminal-agent-prompt-chat.ts @@ -9,11 +9,8 @@ import { waitForAgentPromptPromise } from './agent-prompt-signals' import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-launch/startup/agent-tui-input-clear' -import { agentImagePastePath } from '../../../../shared/agent-image-paste-path' -import { agentImagePasteWrites } from '../../../../shared/agent-image-paste' import { buildNativeChatPasteBytes, - buildNativeChatImagePasteBytes, NATIVE_CHAT_SUBMIT } from '../../../../shared/native-chat/input/native-chat-paste-bytes' import { @@ -56,34 +53,14 @@ export abstract class OrcaRuntimeWithTerminalChatPrompt extends OrcaRuntimeWithW ) { return Promise.resolve(refused) } - const rawBytes = - Buffer.byteLength(prompt.text) + - (prompt.imagePaths ?? []).reduce( - (sum, path) => sum + Buffer.byteLength(JSON.stringify(path)) + 1, - 0 - ) - if (rawBytes > TERMINAL_INPUT_MAX_BYTES || (!prompt.text && !prompt.imagePaths?.length)) { + const rawBytes = Buffer.byteLength(prompt.text) + if (rawBytes > TERMINAL_INPUT_MAX_BYTES || !prompt.text) { return Promise.resolve(refused) } const pty = this.ptysById.get(binding.ptyId) const agent = pty?.foregroundAgent ?? pty?.launchAgent ?? prompt.agent - const body = (prompt.imagePaths?.length ? prompt.text.trim().length : prompt.text.length) - ? buildNativeChatPasteBytes(prompt.text) - : '' - const frames = [ - prompt.text, - AGENT_TUI_CLEAR_INPUT_MAX, - ...agentImagePasteWrites( - agent, - (prompt.imagePaths ?? []).map((path) => - buildNativeChatImagePasteBytes(agentImagePastePath(agent, path)) - ), - body.length > 0 - ) - ] - if (body) { - frames.push(body) - } + const body = buildNativeChatPasteBytes(prompt.text) + const frames = [prompt.text, AGENT_TUI_CLEAR_INPUT_MAX, body] const byteLength = rawBytes + frames.reduce((sum, frame) => sum + Buffer.byteLength(frame), 0) + 1 const entry = this.promptInputs.admit( @@ -175,31 +152,21 @@ export abstract class OrcaRuntimeWithTerminalChatPrompt extends OrcaRuntimeWithW const platform = this.getPtyWriteHostPlatform(binding.ptyId) await write(frames[1]) const joined = agentPromptSubmitJoinsPasteFrame(agent) - for (let index = 2; index < frames.length; index++) { - const frame = frames[index] - const last = index === frames.length - 1 - const gate = - last && !joined - ? this.createAgentPromptRenderGate( - binding.ptyId, - getTerminalPasteIngestMs(platform, Buffer.byteLength(frame)), - scheduleText - ) - : null - try { - gate?.arm() - await write(frame + (last && joined ? NATIVE_CHAT_SUBMIT : '')) - if (gate) { - await waitForAgentPromptPromise(gate.wait(), entry.signal) - } else if (!last) { - await waitForAgentPromptDelay( - 300 + getTerminalPasteIngestMs(platform, Buffer.byteLength(frame)), - entry.signal - ) - } - } finally { - gate?.dispose() + const gate = joined + ? null + : this.createAgentPromptRenderGate( + binding.ptyId, + getTerminalPasteIngestMs(platform, Buffer.byteLength(frames[2])), + scheduleText + ) + try { + gate?.arm() + await write(frames[2] + (joined ? NATIVE_CHAT_SUBMIT : '')) + if (gate) { + await waitForAgentPromptPromise(gate.wait(), entry.signal) } + } finally { + gate?.dispose() } if (!joined) { await waitForAgentPromptDelay( diff --git a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-pty-composer-send.test.tsx b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-pty-composer-send.test.tsx new file mode 100644 index 00000000000..d2906e262ee --- /dev/null +++ b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-pty-composer-send.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' + +const mocks = vi.hoisted(() => ({ host: vi.fn(), image: vi.fn(), text: vi.fn() })) +vi.mock('../../session/terminal/send/native-chat-host-prompt', () => ({ + sendNativeChatHostPrompt: mocks.host +})) +vi.mock('../../session/terminal/send/native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: mocks.image +})) +vi.mock('../../session/terminal/send/native-chat-runtime-send', () => ({ + sendNativeChatMessage: mocks.text, + sendNativeChatTypedCommand: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('../../drafts/native-chat-composer-draft-store', () => ({ + readNativeChatComposerDraft: () => ({}) +})) +vi.mock('../pickers/native-chat-agent-reference-send', () => ({ + serializeNativeChatAgentReferences: (text: string) => text +})) +vi.mock('../../paste/native-chat-composer-pasted-text-references', () => ({ + admitNativeChatPastedText: () => true, + resolveNativeChatPastedText: (_scope: string, text: string) => text +})) +vi.mock('../../drafts/native-chat-launch-draft-send', () => ({ + resolveNativeChatLaunchDraftSend: () => ({ sendOptions: {} }) +})) +vi.mock('../../telemetry/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('@/store/index', () => ({ + useAppStore: { getState: () => ({ tabsByWorktree: {}, clearNativeChatLaunchDraft: vi.fn() }) } +})) + +beforeEach(() => { + vi.clearAllMocks() + const handle = { cancel: vi.fn(), settleAfterMs: 0 } + mocks.host.mockReturnValue(handle) + mocks.image.mockReturnValue(handle) +}) +afterEach(cleanup) + +function composer(draft: string) { + const optimistic = vi.fn(() => 'pending') + const hook = renderHook(() => + useNativeChatPtyComposerSend({ + agent: 'codex', + draft, + draftScopeKey: 'pane', + imageAttachments: [{ path: '/tmp/original.png' }], + disabled: false, + isDispatchingSessionOption: false, + launchDraftResolved: true, + resolveTarget: () => ({ ptyId: 'pty', paneKey: 'pane' }), + classifySend: () => 'chat', + onOptimisticSend: optimistic, + sessionOptionsSurface: null, + terminalTabId: 'tab', + trackPendingSend: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn(), + clearSkillOrigin: vi.fn(), + clearImageAttachments: vi.fn(), + setNotice: vi.fn() + }) + ) + return { hook, optimistic } +} + +it.each(['', 'caption', '/model'])( + 'keeps image send %j on the shipped attachment writer', + (text) => { + const { hook, optimistic } = composer(text) + act(() => expect(hook.result.current()).toBe(true)) + expect(mocks.image).toHaveBeenCalledWith('codex', 'pty', text, ['/tmp/original.png'], {}) + expect(mocks.host).not.toHaveBeenCalled() + expect(optimistic).toHaveBeenCalledWith(text, ['/tmp/original.png'], false, undefined) + } +) + +it('uses the retry attachments rather than the current draft to choose the writer', () => { + const { hook, optimistic } = composer('unsent draft') + act(() => + expect(hook.result.current({ text: 'retry', imagePaths: ['/tmp/retry.png'] })).toBe(true) + ) + expect(mocks.image).toHaveBeenCalledWith('codex', 'pty', 'retry', ['/tmp/retry.png'], {}) + expect(mocks.host).not.toHaveBeenCalled() + expect(optimistic).toHaveBeenCalledWith('retry', ['/tmp/retry.png'], true, undefined) + act(() => expect(hook.result.current({ text: 'text retry' })).toBe(true)) + expect(mocks.host).toHaveBeenCalledWith( + expect.objectContaining({ prompt: { agent: 'codex', text: 'text retry' } }) + ) + expect(optimistic).toHaveBeenLastCalledWith('text retry', [], true, 'codex') +}) diff --git a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-pty-composer-send.ts index 2c8c82dc904..bf861f3bb10 100644 --- a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-pty-composer-send.ts +++ b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-pty-composer-send.ts @@ -123,14 +123,10 @@ export function useNativeChatPtyComposerSend(args: { } : launchSendOptions let pendingHandle: NativeChatSendHandle | null = null - let sourceEvidence = classification === 'chat' && isTuiAgent(args.agent) + let sourceEvidence = + classification === 'chat' && imagePaths.length === 0 && isTuiAgent(args.agent) const legacySend = (dispatchScope: NativeChatPtyDispatchScope) => - imagePaths.length > 0 - ? sendNativeChatMessageWithImageAttachments(args.agent, target.ptyId, text, imagePaths, { - ...sendOptions, - dispatchScope - }) - : sendNativeChatMessage(target.ptyId, text, { ...sendOptions, dispatchScope }) + sendNativeChatMessage(target.ptyId, text, { ...sendOptions, dispatchScope }) // Why: slash-like text must not silently drop its attached images. if (sourceEvidence && isTuiAgent(args.agent)) { pendingHandle = sendNativeChatHostPrompt({ @@ -139,7 +135,7 @@ export function useNativeChatPtyComposerSend(args: { worktreeId: chatWorkspaceByTabId(useAppStore.getState().tabsByWorktree, args.terminalTabId) ?? undefined, - prompt: { agent: args.agent, text, imagePaths }, + prompt: { agent: args.agent, text }, onLegacy: legacySend, onEvidenceMode: (source) => { sourceEvidence = source diff --git a/src/renderer/src/components/native-chat/session/terminal/delivery/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/session/terminal/delivery/native-chat-pending.test.ts index b6309543222..a936aa4b5b2 100644 --- a/src/renderer/src/components/native-chat/session/terminal/delivery/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/session/terminal/delivery/native-chat-pending.test.ts @@ -18,7 +18,7 @@ import { const scope = { paneKey: 'test', agent: 'opencode' } const session = 'session' -const full = '@/tmp/a.png one\ntwo\nthree ' +const full = 'one\ntwo\nthree' const caption = ' one\ntwo\nthree ' function row(text: string, id: string, transcriptOrder = 1): NativeChatMessage { return { @@ -48,26 +48,128 @@ function send(id: string, text: string, imagePaths?: string[]): NativeChatPendin } beforeEach(() => clearPendingSendCacheForTests()) +it('replaces failed legacy captions without merging different source requests or image-only paths', () => { + const legacy = { id: 'old', text: 'look', imagePaths: ['/tmp/a.png'], sentAt: 1 } + appendPendingSendCache(scope, { ...legacy, delivery: 'rejected' }) + appendPendingSendCache(scope, { ...legacy, id: 'live', text: 'neighbor' }) + expect( + appendPendingSendCache(scope, { ...legacy, id: 'new', imagePaths: ['/tmp/b.png'] }).map( + (entry) => entry.id + ) + ).toEqual(['live', 'new']) + writePendingSendCache(scope, [{ ...send('source', ' look '), delivery: 'rejected' }]) + expect(appendPendingSendCache(scope, legacy).map((entry) => entry.id)).toEqual(['source', 'old']) + expect( + appendPendingSendCache(scope, { ...legacy, id: 'raw', text: ' look ', imagePaths: [] }).map( + (entry) => entry.id + ) + ).toEqual(['old', 'raw']) + writePendingSendCache(scope, [{ ...legacy, text: '', delivery: 'rejected' }]) + expect( + appendPendingSendCache(scope, { + ...legacy, + id: 'different', + text: '', + imagePaths: ['/tmp/b.png'] + }).map((entry) => entry.id) + ).toEqual(['old', 'different']) + expect( + appendPendingSendCache(scope, { ...legacy, id: 'same', text: '' }).map((entry) => entry.id) + ).toEqual(['different', 'same']) +}) + +it.each([false, true])( + 'keeps source and compound image claims in chronological order, image first: %s', + (imageFirst) => { + const source = send('source', 'a b') + const group = [ + { ...send('image', 'a b', ['/tmp/a.png']), sourceAgent: undefined }, + { ...send('a', 'a'), sourceAgent: undefined }, + { ...send('b', 'b'), sourceAgent: undefined } + ] + const pending = imageFirst ? [...group, source] : [source, ...group] + const messages = [row('a b', 'x'), answer] + const remaining = prunePendingSends(pending, messages, session) + expect(remaining.map((entry) => entry.id)).toEqual( + imageFirst ? ['source'] : ['image', 'a', 'b'] + ) + writePendingSendCache(scope, remaining) + expect( + visibleNativeChatPendingSends(readPendingSendCache(scope), messages, session).map( + (entry) => entry.id + ) + ).toEqual(imageFirst ? ['source'] : ['image', 'a', 'b']) + expect( + prunePendingSends( + readPendingSendCache(scope), + [...messages, row('a b', 'y', 10), { ...answer, id: 'later-answer', transcriptOrder: 11 }], + session + ) + ).toEqual([]) + expect( + prunePendingSends([{ ...source, text: 'a b' }, group[0]], messages, session).map( + (entry) => entry.id + ) + ).toEqual(['source']) + } +) + +it.each(['a', 'b'])( + 'retains compound member %s’s advance window through partial retirement and remount', + (survivor) => { + const image = { + ...send('image', 'a b', ['/tmp/a.png']), + sourceAgent: undefined, + afterMessageId: undefined + } + const a = { + ...send('a', 'a'), + sourceAgent: undefined, + afterMessageId: undefined, + sentAt: survivor === 'a' ? 2000 : 1000 + } + const b = { + ...send('b', 'b'), + sourceAgent: undefined, + afterMessageId: undefined, + sentAt: survivor === 'b' ? 2000 : 1000 + } + const x = { ...row('a b', 'x'), timestamp: 2001 } + const early = { ...answer, timestamp: 1500 } + const remaining = prunePendingSends([image, a, b], [x, early], session) + expect(remaining.map((entry) => entry.id)).toEqual([survivor]) + writePendingSendCache(scope, remaining) + const remounted = readPendingSendCache(scope) + expect(visibleNativeChatPendingSends(remounted, [], session)).toEqual([]) + expect(prunePendingSends(remounted, [x, early], session).map((entry) => entry.id)).toEqual([ + survivor + ]) + expect( + prunePendingSends(remounted, [x, early, { ...answer, id: 'later', timestamp: 2002 }], session) + ).toEqual([]) + } +) + it('keeps a spent overlapping source unit excluded through pruning, cache remount and paging', () => { appendPendingSendCache(scope, send('plain', full)) - const pending = appendPendingSendCache(scope, send('image', caption, ['/tmp/a.png'])) + const pending = appendPendingSendCache(scope, send('padded', caption)) const receipt = row(full, 'shared') expect( visibleNativeChatPendingSends(pending, [receipt], session).map((entry) => entry.id) - ).toEqual(['image']) + ).toEqual(['padded']) writePendingSendCache(scope, prunePendingSends(pending, [receipt, answer], session)) const remounted = readPendingSendCache(scope) - expect(remounted.map((entry) => entry.id)).toEqual(['image']) + expect(remounted.map((entry) => entry.id)).toEqual(['padded']) expect(prunePendingSends(remounted, [], session)).toBe(remounted) expect( visibleNativeChatPendingSends(remounted, [receipt, answer], session).map((entry) => entry.id) - ).toEqual(['image']) + ).toEqual(['padded']) expect(prunePendingSends(remounted, [receipt, row(full, 'new', 2), answer], session)).toEqual([]) // An earlier unspent row first loaded later remains usable; the survivor's boundary was not advanced. expect( prunePendingSends( remounted, - [row('@/tmp/a.png one\ntwo\nthree', 'earlier', 0), receipt, answer], + [row(' one\ntwo\nthree ', 'earlier', 0), receipt, answer], session ) ).toEqual([]) diff --git a/src/renderer/src/components/native-chat/session/terminal/delivery/native-chat-pending.ts b/src/renderer/src/components/native-chat/session/terminal/delivery/native-chat-pending.ts index e7a3a6a72fa..f8e2b886acf 100644 --- a/src/renderer/src/components/native-chat/session/terminal/delivery/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/session/terminal/delivery/native-chat-pending.ts @@ -135,9 +135,15 @@ export function appendPendingSendCache( const requestKey = (input: NativeChatPendingSend) => JSON.stringify([input.text, input.imagePaths ?? []]) const contentKey = requestKey(entry) + const legacyKey = nativeChatPendingContentKey(entry) // Why: a resend replaces its failed copy; kept, that copy would claim the resend's row and pin it. const existing = readPendingSendCache(scope).filter( - (candidate) => !candidate.delivery || requestKey(candidate) !== contentKey + (candidate) => + !candidate.delivery || + (requestKey(candidate) !== contentKey && + (candidate.sourceAgent || + entry.sourceAgent || + nativeChatPendingContentKey(candidate) !== legacyKey)) ) const owner = pendingSendCache.get(pendingSendScopeKey(scope)) const boundary = owner?.boundary diff --git a/src/renderer/src/components/native-chat/session/terminal/delivery/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/session/terminal/delivery/use-native-chat-pending-delivery.test.tsx index eedc2b68878..345f7824600 100644 --- a/src/renderer/src/components/native-chat/session/terminal/delivery/use-native-chat-pending-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/session/terminal/delivery/use-native-chat-pending-delivery.test.tsx @@ -360,34 +360,45 @@ it('assigns identical legacy occurrences at the real capability fallback and nev expect(remounted.result.current.pending).toEqual([]) }) -it('transfers spent ownership to a reachable fallback glue witness, then retires its live dependencies', () => { - const first = mountOwned() - let source = '', - one = '', - two = '' - act(() => { - source = first.result.current.record('first second', undefined, false, 'opencode') - first.result.current.setSourceEvidence(source, true) - one = first.result.current.record('first', undefined, false, 'opencode') - two = first.result.current.record('second', undefined, false, 'opencode') - }) - const x = sourceRow('first second', 'x') - first.rerender({ messages: [x] }) - act(() => { - first.result.current.setSourceEvidence(one, false) - first.result.current.setSourceEvidence(two, false) - }) - const messages = [x, { ...boundary, id: 'answer' }] - first.rerender({ messages }) - expect(first.result.current.pending.map((entry) => entry.id)).toEqual([one, two]) - first.unmount() - const remounted = mountOwned(messages) - expect(remounted.result.current.pending.map((entry) => entry.id)).toEqual([one, two]) - remounted.rerender({ - messages: [...messages, sourceRow('first second', 'y'), { ...boundary, id: 'answer-y' }] - }) - expect(remounted.result.current.pending).toEqual([]) -}) +it.each([false, true])( + 'transfers spent ownership to reachable fallback legacy proof, image member: %s', + (image) => { + const first = mountOwned() + let source = '', + attachment = '', + one = '', + two = '' + act(() => { + source = first.result.current.record('first second', undefined, false, 'opencode') + first.result.current.setSourceEvidence(source, true) + if (image) { + attachment = first.result.current.record('first second', ['/tmp/image.png'], false) + } + one = first.result.current.record('first', undefined, false, 'opencode') + two = first.result.current.record('second', undefined, false, 'opencode') + }) + const x = sourceRow('first second', 'x') + first.rerender({ messages: [x] }) + act(() => { + first.result.current.setSourceEvidence(one, false) + first.result.current.setSourceEvidence(two, false) + }) + const messages = [x, { ...boundary, id: 'answer' }] + first.rerender({ messages }) + expect(first.result.current.pending.map((entry) => entry.id)).toEqual( + image ? [attachment, one, two] : [one, two] + ) + first.unmount() + const remounted = mountOwned(messages) + expect(remounted.result.current.pending.map((entry) => entry.id)).toEqual( + image ? [attachment, one, two] : [one, two] + ) + remounted.rerender({ + messages: [...messages, sourceRow('first second', 'y'), { ...boundary, id: 'answer-y' }] + }) + expect(remounted.result.current.pending).toEqual([]) + } +) it('releases dead fallback group dependencies and does not accumulate receipts behind an incompatible draft', () => { const mounted = mountOwned() diff --git a/src/renderer/src/components/native-chat/session/terminal/send/native-chat-host-prompt.ts b/src/renderer/src/components/native-chat/session/terminal/send/native-chat-host-prompt.ts index 467667f05a8..71f54eec28e 100644 --- a/src/renderer/src/components/native-chat/session/terminal/send/native-chat-host-prompt.ts +++ b/src/renderer/src/components/native-chat/session/terminal/send/native-chat-host-prompt.ts @@ -40,12 +40,7 @@ export function sendNativeChatHostPrompt({ onEvidenceMode: (source: boolean) => void }): NativeChatSendHandle { const deadline = performance.now() + OBSERVATION_MS - const bytes = - getTerminalInputByteLength(initialPrompt.text) + - (initialPrompt.imagePaths ?? []).reduce( - (sum, path) => sum + getTerminalInputByteLength(JSON.stringify(path)) + 1, - 0 - ) + const bytes = getTerminalInputByteLength(initialPrompt.text) const reservation = reserveNativeChatPtyDispatch(ptyId, bytes) if (!reservation) { return { diff --git a/src/renderer/src/components/native-chat/session/terminal/send/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/session/terminal/send/native-chat-runtime-image-send.ts index 6549cdac10e..5ea6e251d27 100644 --- a/src/renderer/src/components/native-chat/session/terminal/send/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/session/terminal/send/native-chat-runtime-image-send.ts @@ -94,7 +94,6 @@ export function sendNativeChatMessageWithImageAttachments( ) }, { - dispatchScope: options?.dispatchScope, onCancelUnsubmitted: () => clearUnsubmittedAgentInput(ptyId, options) } ) diff --git a/src/shared/native-chat/transcript/native-chat-input-content-key.ts b/src/shared/native-chat/transcript/native-chat-input-content-key.ts index 828cfd4f664..0ef5a65cc70 100644 --- a/src/shared/native-chat/transcript/native-chat-input-content-key.ts +++ b/src/shared/native-chat/transcript/native-chat-input-content-key.ts @@ -4,8 +4,6 @@ import { type NativeChatSourcePending } from './native-chat-input-content' import { resolveNativeChatTranscriptAgent } from '../native-chat-agent-support' -import { agentImagePastePath } from '../../agent-image-paste-path' -import { getAgentImageHandling } from '../../agent-image-paste' import { buildNativeChatPasteBytes } from '../input/native-chat-paste-bytes' import { BRACKETED_PASTE_START, @@ -44,9 +42,11 @@ function expectedSourceInput(pending: NativeChatSourcePending): ExpectedSourceIn if (cached) { return cached } + if (pending.imagePaths?.length) { + return { keys: [] } + } const provider = resolveNativeChatTranscriptAgent(pending.sourceAgent) - const paths = pending.imagePaths ?? [] - let body = paths.length > 0 && !pending.text.trim() ? '' : submittedText(pending.text) + let body = submittedText(pending.text) if ( provider === 'omp' && buildNativeChatPasteBytes(pending.text).startsWith(BRACKETED_PASTE_START) @@ -57,22 +57,6 @@ function expectedSourceInput(pending: NativeChatSourcePending): ExpectedSourceIn body = body.replace(/\r\n?/g, '\n') } let text = body - let images: readonly string[] = paths - if (getAgentImageHandling(pending.sourceAgent) !== 'attachment') { - text = [ - ...paths.map((path) => { - const mention = agentImagePastePath(pending.sourceAgent, path) - return provider === 'omp' ? ompPastedText(mention) : mention - }), - ...(body ? [body] : []) - ].join(' ') - images = [] - } else if (provider === 'codex') { - text = - paths.map((_, index) => `[Image #${index + 1}] `).join('') + - (paths.length && body ? ' ' : '') + - body - } if (provider === 'omp') { text = text.trim() } @@ -86,27 +70,13 @@ function expectedSourceInput(pending: NativeChatSourcePending): ExpectedSourceIn text = rustTrim(text) } const texts = - provider === 'opencode' - ? [false, true].map((full) => - [ - ...paths.map((path) => - opencodeFrame(submittedText(agentImagePastePath(pending.sourceAgent, path)), full) - ), - ...(body - ? [ - buildNativeChatPasteBytes(pending.text).startsWith(BRACKETED_PASTE_START) - ? opencodeFrame(body, full) - : body - ] - : []) - ].join(' ') - ) + provider === 'opencode' && + buildNativeChatPasteBytes(pending.text).startsWith(BRACKETED_PASTE_START) + ? [false, true].map((full) => opencodeFrame(body, full)) : [text] const expected = { keys: [ - ...new Set( - texts.map((value) => sourceKey(provider, nativeChatInputTextDigest(value), images)) - ) + ...new Set(texts.map((value) => sourceKey(provider, nativeChatInputTextDigest(value), []))) ] } pendingKeys.set(pending, expected) diff --git a/src/shared/native-chat/transcript/native-chat-pending-reconciliation.ts b/src/shared/native-chat/transcript/native-chat-pending-reconciliation.ts index 03e6f9c68f0..e7fc663e049 100644 --- a/src/shared/native-chat/transcript/native-chat-pending-reconciliation.ts +++ b/src/shared/native-chat/transcript/native-chat-pending-reconciliation.ts @@ -83,13 +83,17 @@ export function reconcileNativeChatPendingSends( members: [], proof: entry.receiptProof ?? 'source', glue: entry.receiptGlue, + glueMembers: new Set(), advanced: new Set() } locked.set(key, witness) } witness.members.push(index) + if (entry.receiptGlue) { + witness.glueMembers!.add(index) + } witness.mask |= pendingReceiptBit(index, pending.length) - const eligible = nativeChatReceiptWindow(entry, snapshot, sessionId, witness.glue) + const eligible = nativeChatReceiptWindow(entry, snapshot, sessionId, entry.receiptGlue) if ( witness.row.index >= 0 && snapshot.rows.some( @@ -229,7 +233,9 @@ export function commitNativeChatPendingReceipts sourceReceipt: receipt, receiptMembers, receiptProof, - receiptGlue: witness?.glue ?? entry.receiptGlue, + receiptGlue: witness + ? (witness.glueMembers?.has(pending.indexOf(entry)) ?? witness.glue) + : entry.receiptGlue, receiptDependencies: applicable.length ? applicable : undefined } }) diff --git a/src/shared/native-chat/transcript/native-chat-pending-sends.test.ts b/src/shared/native-chat/transcript/native-chat-pending-sends.test.ts index 64f92e7ec49..dc79742b6cf 100644 --- a/src/shared/native-chat/transcript/native-chat-pending-sends.test.ts +++ b/src/shared/native-chat/transcript/native-chat-pending-sends.test.ts @@ -42,6 +42,53 @@ function gluePending(id: string, text: string): NativeChatPendingSend { } } +it('preserves legacy image caption matching followed by residual text on the same row', () => { + const pending = [ + { ...gluePending('image', 'a b'), imagePaths: ['/tmp/a.png'] }, + gluePending('a', 'a'), + gluePending('b', 'b') + ] + const row = userMessage('image-row', 'a b', 6000) + const messages = [GLUE_BOUNDARY, row] + expect(pendingSendsAsMessages(pending, messages)).toEqual([]) + const retained = prunePendingSends(pending, messages) + expect(retained.map((entry) => entry.id)).toEqual(['image', 'a', 'b']) + expect( + prunePendingSends(retained, [...messages, assistantMessage('reply', 'done', 6100)]) + ).toEqual([]) +}) + +it('matches a legacy caption after the CLI echoes its attached file path', () => { + const pending = [{ ...gluePending('image', 'look'), imagePaths: ['/tmp/a.png'] }] + const messages = [ + GLUE_BOUNDARY, + userMessage('image-row', '@/tmp/a.png look', 6000), + assistantMessage('reply', 'done', 6100) + ] + expect(prunePendingSends(pending, messages)).toEqual([]) +}) + +it.each([false, true])( + 'requires a real row before skipping an intervening legacy obligation, other row first: %s', + (first) => { + const pending = [ + { ...gluePending('image', 'a b'), imagePaths: ['/tmp/a.png'] }, + gluePending('other', 'z'), + gluePending('a', 'a'), + gluePending('b', 'b') + ] + const x = userMessage('x', 'a b', 6000) + const z = userMessage('z', 'z', 6000) + const answer = assistantMessage('reply', 'done', 6100) + expect(prunePendingSends(pending, [GLUE_BOUNDARY, x, answer]).map((entry) => entry.id)).toEqual( + ['other', 'a', 'b'] + ) + expect( + prunePendingSends(pending, [GLUE_BOUNDARY, ...(first ? [z, x] : [x, z]), answer]) + ).toEqual([]) + } +) + describe('glued rapid sends', () => { // Every send carries its own boundary, including later sends in a glued row. const mixedAgeGlue = (): { messages: NativeChatMessage[]; pending: NativeChatPendingSend[] } => { diff --git a/src/shared/native-chat/transcript/native-chat-pending-witnesses.ts b/src/shared/native-chat/transcript/native-chat-pending-witnesses.ts index 7c78803d0be..051e6d8c316 100644 --- a/src/shared/native-chat/transcript/native-chat-pending-witnesses.ts +++ b/src/shared/native-chat/transcript/native-chat-pending-witnesses.ts @@ -28,6 +28,7 @@ export type NativeChatPendingWitness = { requires: number proof: 'source' | 'legacy' glue?: boolean + glueMembers?: Set advanced: Set } @@ -115,12 +116,14 @@ export function nativeChatPendingWitnesses( ) ) const candidates = new Map() + const exactLegacyRows = new Map>() function offer( row: NativeChatReceiptRow, members: number[], proof: 'source' | 'legacy', requires = 0, - glue = false + glue = false, + glueMembers?: Set ): void { if (options.onlyRows && !options.onlyRows.has(row.message.id)) { return @@ -145,11 +148,27 @@ export function nativeChatPendingWitnesses( return } const mask = members.reduce((value, index) => value | pendingReceiptBit(index, count), 0) - const signature = `${proof}:${mask}:${requires}:${glue}` + const partition = glueMembers + ? members + .map( + (index) => + `${index}:${glueMembers.has(index)}:${row.index < (glueMembers.has(index) ? lastGlueAdvance : lastAdvance)[index]}` + ) + .join(',') + : '' + const signature = `${proof}:${mask}:${requires}:${glue}:${partition}` const alternatives = candidates.get(signature) ?? [] if (alternatives.length >= count) { return } + const advanced = new Set( + members + .filter( + (index) => + row.index < ((glueMembers?.has(index) ?? glue) ? lastGlueAdvance : lastAdvance)[index] + ) + .map((index) => pending[index].id) + ) alternatives.push({ row, members, @@ -157,11 +176,8 @@ export function nativeChatPendingWitnesses( requires, proof, glue, - advanced: new Set( - members - .filter((index) => row.index < (glue ? lastGlueAdvance : lastAdvance)[index]) - .map((index) => pending[index].id) - ) + glueMembers, + advanced }) candidates.set(signature, alternatives) } @@ -171,6 +187,7 @@ export function nativeChatPendingWitnesses( ? [{ entry: { ...entry, sourceAgent: undefined }, index }] : [] ) + const hasLegacyImages = legacy.some(({ entry }) => entry.imagePaths?.length) for (const [index, entry] of pending.entries()) { const projected = { ...entry, sourceAgent: undefined } const key = nativeChatPendingMatchKey(projected) @@ -197,6 +214,11 @@ export function nativeChatPendingWitnesses( occurrence++ // Qualify in the original stream, before spent ownership is subtracted. if (occurrence >= (entry.sourceAgent ? 1 : rank)) { + if (hasLegacyImages && pending[index].sourceReceipt?.sessionId !== sessionId) { + const exact = exactLegacyRows.get(row.index) ?? new Set() + exact.add(index) + exactLegacyRows.set(row.index, exact) + } offer(row, [index], 'legacy') } } @@ -222,6 +244,40 @@ export function nativeChatPendingWitnesses( } for (let residual = 1; residual < 1 << legacy.length; residual++) { const remaining = legacy.filter((_, index) => residual & (1 << index)) + const requires = legacy.reduce( + (mask, member, index) => + residual & (1 << index) ? mask : mask | pendingReceiptBit(member.index, count), + 0 + ) + if (hasLegacyImages) { + const exact = remaining.filter(({ index }) => exactLegacyRows.get(row.index)?.has(index)) + const rest = remaining.filter(({ index }) => !exactLegacyRows.get(row.index)?.has(index)) + if ( + exact.length && + rest.length && + remaining.some(({ entry }) => entry.imagePaths?.length) + ) { + const glued = selectPendingIndicesRepresentedByUserRows( + rest.map(({ entry }) => entry), + [ + { + id: row.message.id, + text, + representablePendingIndices: new Set( + rest.flatMap(({ index }, at) => (glueWindows[index](row) ? [at] : [])) + ) + } + ] + ) + const glueMembers = new Set([...glued].map((at) => rest[at].index)) + const members = remaining.flatMap(({ index }) => + exactLegacyRows.get(row.index)?.has(index) || glueMembers.has(index) ? [index] : [] + ) + if (glueMembers.size && members.some((index) => pending[index].imagePaths?.length)) { + offer(row, members, 'legacy', requires, false, glueMembers) + } + } + } const represented = selectPendingIndicesRepresentedByUserRows( remaining.map(({ entry }) => entry), [ @@ -238,11 +294,6 @@ export function nativeChatPendingWitnesses( continue } const members = [...represented].map((at) => remaining[at].index) - const requires = legacy.reduce( - (mask, member, index) => - residual & (1 << index) ? mask : mask | pendingReceiptBit(member.index, count), - 0 - ) offer(row, members, 'legacy', requires, true) } } diff --git a/src/shared/rpc-contract/terminal-chat-prompt-params.ts b/src/shared/rpc-contract/terminal-chat-prompt-params.ts index c0178ba87d1..12386dd6bf1 100644 --- a/src/shared/rpc-contract/terminal-chat-prompt-params.ts +++ b/src/shared/rpc-contract/terminal-chat-prompt-params.ts @@ -5,15 +5,7 @@ import { isTuiAgent } from '../agent-launch/catalog/tui-agent-config' export const TerminalChatPromptParam = z .object({ agent: z.custom(isTuiAgent), - text: z.string(), - imagePaths: z - .array( - z - .string() - .min(1) - .refine((path) => !path.includes('\0')) - ) - .optional() + text: z.string() }) .strict() .optional() diff --git a/src/shared/runtime-terminal-send-contract.ts b/src/shared/runtime-terminal-send-contract.ts index 8be427a5b1a..32c515dd838 100644 --- a/src/shared/runtime-terminal-send-contract.ts +++ b/src/shared/runtime-terminal-send-contract.ts @@ -1,7 +1,7 @@ import type { WriteSettlement } from './terminal/pty/lifecycle/pty-write-settlement' import type { TuiAgent } from './agent-launch/catalog/tui-agent' -export type TerminalChatPrompt = { agent: TuiAgent; text: string; imagePaths?: string[] } +export type TerminalChatPrompt = { agent: TuiAgent; text: string } export type RuntimeTerminalSend = { handle: string