mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Keep image sending on the existing path while confirming text input
This commit is contained in:
@@ -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(
|
||||
|
||||
+95
@@ -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')
|
||||
})
|
||||
+4
-8
@@ -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
|
||||
|
||||
+108
-6
@@ -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([])
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+39
-28
@@ -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()
|
||||
|
||||
+1
-6
@@ -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 {
|
||||
|
||||
-1
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user