mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
126ff7071b
commit
5835201de1
9 changed files with 198 additions and 55 deletions
1
cli.py
1
cli.py
|
|
@ -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:
|
||||
|
|
|
|||
22
tests/cli/test_cli_queue_paste.py
Normal file
22
tests/cli/test_cli_queue_paste.py
Normal 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
|
||||
33
ui-tui/src/__tests__/queueSubmission.test.ts
Normal file
33
ui-tui/src/__tests__/queueSubmission.test.ts
Normal 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}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue