fix: keep queued paste payloads atomic (#74797)

Co-authored-by: eloklam <22125285+eloklam@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Yi Lok Enoch Lam 2026-07-31 18:04:59 +02:00 committed by GitHub
parent 126ff7071b
commit 5835201de1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 198 additions and 55 deletions

1
cli.py
View file

@ -9986,6 +9986,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Extract prompt after "/queue " or "/q "
parts = cmd_original.split(None, 1)
payload = parts[1].strip() if len(parts) > 1 else ""
payload = self._expand_paste_references(payload)
if not payload:
_cprint(" Usage: /queue <prompt>")
else:

View file

@ -0,0 +1,22 @@
"""Regression tests for collapsed paste references passed to /queue."""
from queue import Queue
from unittest.mock import patch
from cli import HermesCLI
def test_queue_expands_collapsed_paste_reference(tmp_path):
pasted = "first\nmiddle\nlast"
paste_file = tmp_path / "paste.txt"
paste_file.write_text(pasted, encoding="utf-8")
placeholder = f"[Pasted text #1: 3 lines → {paste_file}]"
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._agent_running = False
cli_obj._pending_input = Queue()
cli_obj._pending_resume_sessions = None
with patch("cli._cprint"):
assert cli_obj.process_command(f"/queue {placeholder}") is True
assert cli_obj._pending_input.get_nowait() == pasted

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import type { ComposerToken } from '../app/interfaces.js'
import { expandPasteTokens, queueItemFromSlash } from '../app/useSubmission.js'
import { imageToken } from '../domain/attachments.js'
describe('/queue collapsed paste submission', () => {
it('keeps the collapsed argument for display and the full multiline payload for execution', () => {
const display = '[[ first.. [3 lines] .. last ]]'
expect(queueItemFromSlash(`/queue ${display}`, '/queue first\nmiddle\nlast')).toEqual({
display,
text: 'first\nmiddle\nlast'
})
})
it('supports the /q alias and rejects an empty queue command', () => {
expect(queueItemFromSlash('/q [[ payload ]]', '/q complete payload')).toEqual({
display: '[[ payload ]]',
text: 'complete payload'
})
expect(queueItemFromSlash('/queue', '/queue')).toBeUndefined()
})
it('expands paste tokens without consuming image tokens', () => {
const paste: ComposerToken = { kind: 'paste', label: '[[ paste [2 lines] ]]', text: 'one\ntwo' }
const image: ComposerToken = { kind: 'image', index: 1, label: imageToken(1), path: '/tmp/image.png' }
expect(expandPasteTokens([paste, image])(`${paste.label} and ${image.label}`)).toBe(
`one\ntwo and ${image.label}`
)
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { removeAtInPlace } from '../hooks/useQueue.js'
import { prependQueueItem, queueItem, removeAtInPlace, takeQueueItem } from '../hooks/useQueue.js'
describe('removeAtInPlace', () => {
it('removes the item at the given index in place', () => {
@ -26,3 +26,31 @@ describe('removeAtInPlace', () => {
expect(arr).toEqual([])
})
})
describe('queue items', () => {
it('keeps execution text and collapsed display together through edit and requeue', () => {
const display = '[[ first.. [3 lines] .. last ]]'
const text = 'first\nmiddle\nlast'
const queue = [queueItem(text, display), queueItem('next')]
const edited = takeQueueItem(queue, 0, `before ${display} after`)
expect(edited).toEqual({
display: `before ${display} after`,
text: `before ${text} after`
})
expect(queue).toEqual([queueItem('next')])
prependQueueItem(queue, edited!)
expect(queue[0]).toEqual({
display: `before ${display} after`,
text: `before ${text} after`
})
})
it('treats a rewritten collapsed label as literal edited text', () => {
const queue = [queueItem('full payload', '[[ collapsed ]]')]
expect(takeQueueItem(queue, 0, 'replacement')).toEqual(queueItem('replacement'))
})
})

View file

@ -12,6 +12,7 @@ import type {
SubscriptionStateResponse,
SubscriptionUpgradeResponse
} from '../gatewayTypes.js'
import type { QueueItem } from '../hooks/useQueue.js'
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
import type { RpcResult } from '../lib/rpc.js'
import type { ActiveWidget } from '../sdk/types.js'
@ -369,19 +370,19 @@ export interface ComposerActions {
attachImagePath: (path: string) => void
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
enqueue: (text: string, display?: string) => void
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
openEditor: () => Promise<void>
prependQueue: (item: QueueItem) => void
pushHistory: (text: string) => void
removeQueue: (index: number) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
setComposerTokens: StateSetter<ComposerToken[]>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setQueueEdit: (index: null | number) => void
syncQueue: () => void
takeQueue: (index: number, editedDisplay?: string) => QueueItem | undefined
/** Reconcile attached payloads against tokens still present in the text. */
syncTokens: (value: string) => void
}
@ -390,7 +391,7 @@ export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<string[]>
queueRef: MutableRefObject<QueueItem[]>
submitRef: MutableRefObject<(value: string) => void>
tokensRef: MutableRefObject<ComposerToken[]>
}
@ -502,10 +503,10 @@ export interface SlashHandlerContext {
composer: {
attachClipboardImage: () => void
attachImagePath: (path: string) => void
enqueue: (text: string) => void
enqueue: (text: string, display?: string) => void
hasSelection: boolean
openEditor: () => Promise<void>
queueRef: MutableRefObject<string[]>
queueRef: MutableRefObject<QueueItem[]>
selection: SelectionApi
setInput: StateSetter<string>
}

View file

@ -135,10 +135,10 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions
queueEditIdx,
enqueue,
dequeue,
prependQ,
removeQ,
replaceQ,
setQueueEdit,
syncQueue
takeQ
} = useQueue()
const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory()
@ -435,16 +435,16 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions
enqueue,
handleTextPaste,
openEditor,
prependQueue: prependQ,
pushHistory,
removeQueue: removeQ,
replaceQueue: replaceQ,
setCompIdx,
setComposerTokens,
setHistoryIdx,
setInput,
setInputBuf,
setQueueEdit,
syncQueue,
takeQueue: takeQ,
syncTokens
}),
[
@ -455,15 +455,15 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions
enqueue,
handleTextPaste,
openEditor,
prependQ,
pushHistory,
removeQ,
replaceQ,
setCompIdx,
setComposerTokens,
setHistoryIdx,
setInput,
setQueueEdit,
syncQueue,
takeQ,
syncTokens
]
)

View file

@ -239,7 +239,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
cActions.setQueueEdit(index)
cActions.setHistoryIdx(null)
cActions.setInput(cRefs.queueRef.current[index] ?? '')
cActions.setInput(cRefs.queueRef.current[index]?.display ?? '')
return true
}

View file

@ -2,14 +2,15 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { TYPING_IDLE_MS } from '../config/timing.js'
import { expandTokens } from '../domain/attachments.js'
import { completionToApplyOnSubmit, looksLikeSlashCommand } from '../domain/slash.js'
import { completionToApplyOnSubmit, looksLikeSlashCommand, parseSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { SessionSteerResponse, ShellExecResponse } from '../gatewayTypes.js'
import { queueItem, type QueueItem } from '../hooks/useQueue.js'
import { asRpcResult } from '../lib/rpc.js'
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
import type { Msg } from '../types.js'
import type { ComposerActions, ComposerRefs, ComposerState } from './interfaces.js'
import type { ComposerActions, ComposerRefs, ComposerState, ComposerToken } from './interfaces.js'
import { submitPrompt } from './submissionCore.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
@ -19,6 +20,21 @@ const DOUBLE_ENTER_MS = 450
const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) =>
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
export const expandPasteTokens = (tokens: ComposerToken[]) =>
expandTokens(tokens.filter(token => token.kind === 'paste'))
const slashArgument = (command: string) => /^\/\S+\s+([\s\S]+)$/.exec(command)?.[1] ?? ''
export const queueItemFromSlash = (displayCommand: string, expandedCommand: string): QueueItem | undefined => {
const display = slashArgument(displayCommand)
if (!display.trim()) {
return undefined
}
return queueItem(slashArgument(expandedCommand), display)
}
export function useSubmission(opts: UseSubmissionOptions) {
const { appendMessage, composerActions, composerRefs, composerState, gw, setLastUserMsg, slashRef, submitRef, sys } =
opts
@ -157,16 +173,15 @@ export function useSubmission(opts: UseSubmissionOptions) {
// `opts.fallbackToFront` re-inserts at the queue head (queue-edit picks keep
// their position); the mainline submit path appends.
const handleBusyInput = useCallback(
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
(item: QueueItem, opts: { fallbackToFront?: boolean } = {}) => {
const live = getUiState()
const mode = live.busyInputMode
const enqueueText = () => {
if (opts.fallbackToFront) {
composerRefs.queueRef.current.unshift(full)
composerActions.syncQueue()
composerActions.prependQueue(item)
} else {
composerActions.enqueue(full)
composerActions.enqueue(item.text, item.display)
}
}
@ -176,11 +191,11 @@ export function useSubmission(opts: UseSubmissionOptions) {
}
if (mode === 'queue') {
return composerActions.enqueue(full)
return enqueueText()
}
if (mode === 'steer' && live.sid) {
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: full })
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: item.text })
.then(raw => {
const r = asRpcResult<SessionSteerResponse>(raw)
@ -197,9 +212,9 @@ export function useSubmission(opts: UseSubmissionOptions) {
// the agent is in model generation, tool execution, or an older runtime.
// Reuse the normal submit pipeline so the correction gets its user bubble
// and file-drop interpolation exactly once.
send(full)
send(item.text)
},
[composerActions, composerRefs, gw, send, sys]
[composerActions, gw, send, sys]
)
const dispatchSubmission = useCallback(
@ -214,11 +229,24 @@ export function useSubmission(opts: UseSubmissionOptions) {
// Idempotent on token-free text, so re-submitting a recalled entry is
// stable.
const toHistory = expandTokens(composerRefs.tokensRef.current)(full)
const queuePayload = expandPasteTokens(composerRefs.tokensRef.current)(full)
if (looksLikeSlashCommand(full)) {
appendMessage({ kind: 'slash', role: 'system', text: full })
composerActions.pushHistory(toHistory)
slashRef.current(full)
const parsed = parseSlashCommand(full)
const queued =
parsed.name === 'queue' || parsed.name === 'q' ? queueItemFromSlash(full, queuePayload) : undefined
if (queued) {
composerActions.enqueue(queued.text, queued.display)
sys(`queued: "${queued.display.slice(0, 50)}${queued.display.length > 50 ? '…' : ''}"`)
} else {
slashRef.current(full)
}
composerActions.clearIn()
return
@ -244,9 +272,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
composerActions.clearIn()
if (editIdx !== null) {
composerActions.replaceQueue(editIdx, full)
const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0]
composerActions.syncQueue()
const picked = composerActions.takeQueue(editIdx, full)
composerActions.setQueueEdit(null)
if (!picked || !live.sid) {
@ -258,21 +284,19 @@ export function useSubmission(opts: UseSubmissionOptions) {
// silently going back to the queue. handleBusyInput resolves
// mode-specific behavior (interrupt-and-send, steer, or queue).
if (getUiState().busyInputMode === 'queue') {
composerRefs.queueRef.current.unshift(picked)
return composerActions.syncQueue()
return composerActions.prependQueue(picked)
}
return handleBusyInput(picked, { fallbackToFront: true })
}
return sendQueued(picked)
return sendQueued(picked.text)
}
composerActions.pushHistory(toHistory)
if (getUiState().busy) {
return handleBusyInput(full)
return handleBusyInput(queueItem(full))
}
if (hasInterpolation(full)) {
@ -292,7 +316,8 @@ export function useSubmission(opts: UseSubmissionOptions) {
send,
sendQueued,
shellExec,
slashRef
slashRef,
sys
]
)
@ -324,8 +349,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
if (doubleTap && live.sid && composerRefs.queueRef.current.length) {
const next = composerActions.dequeue()
composerActions.syncQueue()
if (next) {
composerActions.setQueueEdit(null)
dispatchSubmission(next)

View file

@ -1,5 +1,33 @@
import { useCallback, useRef, useState } from 'react'
export interface QueueItem {
display: string
text: string
}
export const queueItem = (text: string, display = text): QueueItem => ({ display, text })
export function prependQueueItem(queue: QueueItem[], item: QueueItem): void {
queue.unshift(item)
}
export function takeQueueItem(queue: QueueItem[], index: number, editedDisplay?: string): QueueItem | undefined {
if (index < 0 || index >= queue.length) {
return undefined
}
const [item] = queue.splice(index, 1)
if (!item || editedDisplay === undefined) {
return item
}
return {
display: editedDisplay,
text: editedDisplay.includes(item.display) ? editedDisplay.replace(item.display, item.text) : editedDisplay
}
}
// Mutates `arr` in place; returned reference is the same input array, kept
// so callers can chain. Use `Array.prototype.toSpliced` if you need a copy.
export function removeAtInPlace<T>(arr: T[], i: number): T[] {
@ -13,12 +41,12 @@ export function removeAtInPlace<T>(arr: T[], i: number): T[] {
}
export function useQueue() {
const queueRef = useRef<string[]>([])
const queueRef = useRef<QueueItem[]>([])
const [queuedDisplay, setQueuedDisplay] = useState<string[]>([])
const queueEditRef = useRef<number | null>(null)
const [queueEditIdx, setQueueEditIdx] = useState<number | null>(null)
const syncQueue = useCallback(() => setQueuedDisplay([...queueRef.current]), [])
const syncQueue = useCallback(() => setQueuedDisplay(queueRef.current.map(item => item.display)), [])
const setQueueEdit = useCallback((idx: number | null) => {
queueEditRef.current = idx
@ -26,51 +54,58 @@ export function useQueue() {
}, [])
const enqueue = useCallback(
(text: string) => {
queueRef.current.push(text)
(text: string, display = text) => {
queueRef.current.push(queueItem(text, display))
syncQueue()
},
[syncQueue]
)
const prependQ = useCallback(
(item: QueueItem) => {
prependQueueItem(queueRef.current, item)
syncQueue()
},
[syncQueue]
)
const dequeue = useCallback(() => {
const head = queueRef.current.shift()
const head = queueRef.current.shift()?.text
syncQueue()
return head
}, [syncQueue])
const replaceQ = useCallback(
(i: number, text: string) => {
queueRef.current[i] = text
syncQueue()
const takeQ = useCallback(
(i: number, editedDisplay?: string) => {
const item = takeQueueItem(queueRef.current, i, editedDisplay)
if (item) {
syncQueue()
}
return item
},
[syncQueue]
)
const removeQ = useCallback(
(i: number) => {
const before = queueRef.current.length
removeAtInPlace(queueRef.current, i)
if (queueRef.current.length !== before) {
syncQueue()
}
takeQ(i)
},
[syncQueue]
[takeQ]
)
return {
dequeue,
enqueue,
prependQ,
queueEditIdx,
queueEditRef,
queueRef,
queuedDisplay,
removeQ,
replaceQ,
setQueueEdit,
syncQueue
takeQ
}
}