Keep image sending on the existing path while confirming text input

This commit is contained in:
Brennan Benson
2026-11-12 15:50:07 -08:00
parent cd2196b617
commit cdd0b46d4e
17 changed files with 433 additions and 197 deletions
@@ -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)
@@ -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<string, unknown>, 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([])
@@ -186,7 +186,6 @@ describe('nativeChat.readSession clientKind truncation gating', () => {
const pending = {
id: 'pending',
text,
imagePaths: images,
sourceAgent: 'codex' as const,
sentAt: 0
}
@@ -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(
@@ -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')
})
@@ -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
@@ -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([])
@@ -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
@@ -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()
@@ -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 {
@@ -94,7 +94,6 @@ export function sendNativeChatMessageWithImageAttachments(
)
},
{
dispatchScope: options?.dispatchScope,
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(ptyId, options)
}
)
@@ -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)
@@ -83,13 +83,17 @@ export function reconcileNativeChatPendingSends(
members: [],
proof: entry.receiptProof ?? 'source',
glue: entry.receiptGlue,
glueMembers: new Set<number>(),
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<T extends NativeChatPendingSend>
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
}
})
@@ -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[] } => {
@@ -28,6 +28,7 @@ export type NativeChatPendingWitness = {
requires: number
proof: 'source' | 'legacy'
glue?: boolean
glueMembers?: Set<number>
advanced: Set<string>
}
@@ -115,12 +116,14 @@ export function nativeChatPendingWitnesses(
)
)
const candidates = new Map<string, NativeChatPendingWitness[]>()
const exactLegacyRows = new Map<number, Set<number>>()
function offer(
row: NativeChatReceiptRow,
members: number[],
proof: 'source' | 'legacy',
requires = 0,
glue = false
glue = false,
glueMembers?: Set<number>
): 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<number>()
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)
}
}
@@ -5,15 +5,7 @@ import { isTuiAgent } from '../agent-launch/catalog/tui-agent-config'
export const TerminalChatPromptParam = z
.object({
agent: z.custom<TuiAgent>(isTuiAgent),
text: z.string(),
imagePaths: z
.array(
z
.string()
.min(1)
.refine((path) => !path.includes('\0'))
)
.optional()
text: z.string()
})
.strict()
.optional()
+1 -1
View File
@@ -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