Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1c886bf9 | ||
|
|
e375c33f70 | ||
|
|
ac177cea87 | ||
|
|
ce50030634 | ||
|
|
f94363d1f0 | ||
|
|
0cbcc75935 | ||
|
|
0c0a707744 | ||
|
|
78122c52cf | ||
|
|
30340eae2f | ||
|
|
9c1bb8d2c7 | ||
|
|
aa52cd3b57 | ||
|
|
da9425bf9b | ||
|
|
8e629b9f38 | ||
|
|
be2c64be02 | ||
|
|
b8234e7599 | ||
|
|
3c231eb397 | ||
|
|
ea266f43e9 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.15.1",
|
||||
"package": "hermes-agent[acp]==0.16.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
//! the bootstrap-complete check.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
|
||||
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
|
||||
@@ -103,10 +105,37 @@ pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &dest)?;
|
||||
repair_macos_installer_helper(&dest);
|
||||
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn repair_macos_installer_helper(path: &Path) {
|
||||
// The staged helper may inherit quarantine from the downloaded installer.
|
||||
// Desktop later launches this exact file for in-app updates, so make it
|
||||
// executable before the update handoff reaches LaunchServices/Gatekeeper.
|
||||
let _ = Command::new("/usr/bin/xattr")
|
||||
.args(["-cr"])
|
||||
.arg(path)
|
||||
.status();
|
||||
|
||||
let verify = Command::new("/usr/bin/codesign")
|
||||
.arg("--verify")
|
||||
.arg(path)
|
||||
.status();
|
||||
|
||||
if !matches!(verify, Ok(status) if status.success()) {
|
||||
let _ = Command::new("/usr/bin/codesign")
|
||||
.args(["--force", "--sign", "-"])
|
||||
.arg(path)
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.cjs:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
|
||||
@@ -28,6 +28,7 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
@@ -1313,6 +1314,31 @@ function resolveUpdaterBinary() {
|
||||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
function repairMacUpdaterHelper(updater) {
|
||||
if (!IS_MAC || !updater) return
|
||||
|
||||
try {
|
||||
execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' })
|
||||
} catch (err) {
|
||||
rememberLog(`[updates] macOS updater helper quarantine repair skipped: ${err.message}`)
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' })
|
||||
return
|
||||
} catch {
|
||||
// Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper
|
||||
// does not block the staged updater before it can run.
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', updater], { stdio: 'ignore' })
|
||||
rememberLog('[updates] repaired macOS updater helper signature')
|
||||
} catch (err) {
|
||||
rememberLog(`[updates] macOS updater helper signature repair skipped: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Path to the venv shim whose lock decides whether `hermes update` can write
|
||||
// fresh entry points. On Windows this is the file the running backend
|
||||
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
|
||||
@@ -1473,6 +1499,7 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
|
||||
repairMacUpdaterHelper(updater)
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
@@ -3467,7 +3494,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
|
||||
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||||
return
|
||||
}
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
const body = serializeJsonBody(options.body)
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
||||
const request = electronNet.request({
|
||||
@@ -3477,8 +3504,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
|
||||
useSessionCookies: true,
|
||||
redirect: 'follow'
|
||||
})
|
||||
request.setHeader('Content-Type', 'application/json')
|
||||
if (body) request.setHeader('Content-Length', String(body.length))
|
||||
setJsonRequestHeaders(request)
|
||||
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Helpers for Electron net.request calls that ride the OAuth session partition.
|
||||
*
|
||||
* Electron's ClientRequest forbids app-set restricted headers such as
|
||||
* Content-Length. Let Chromium frame the body itself; only set the JSON content
|
||||
* type here.
|
||||
*/
|
||||
|
||||
function serializeJsonBody(body) {
|
||||
return body === undefined ? undefined : Buffer.from(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function setJsonRequestHeaders(request) {
|
||||
request.setHeader('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeJsonBody,
|
||||
setJsonRequestHeaders
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Tests for OAuth-session Electron net.request helpers.
|
||||
*
|
||||
* Run with: node --test electron/oauth-net-request.test.cjs
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
|
||||
test('serializeJsonBody returns undefined for absent bodies', () => {
|
||||
assert.equal(serializeJsonBody(undefined), undefined)
|
||||
})
|
||||
|
||||
test('serializeJsonBody JSON-encodes request bodies', () => {
|
||||
const body = serializeJsonBody({ archived: true })
|
||||
assert.ok(Buffer.isBuffer(body))
|
||||
assert.equal(body.toString('utf8'), '{"archived":true}')
|
||||
})
|
||||
|
||||
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
|
||||
const headers = []
|
||||
const request = {
|
||||
setHeader(name, value) {
|
||||
headers.push([name, value])
|
||||
}
|
||||
}
|
||||
|
||||
setJsonRequestHeaders(request)
|
||||
|
||||
assert.deepEqual(headers, [['Content-Type', 'application/json']])
|
||||
assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
|
||||
})
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
// No global setupFiles registers auto-cleanup, so unmount between tests —
|
||||
// otherwise a second render() leaks the first editor and getByTestId('editor')
|
||||
// matches multiple nodes.
|
||||
afterEach(cleanup)
|
||||
|
||||
// Faithful mirror of index.tsx's composer text wiring for IME input, driven
|
||||
// through REAL DOM composition + input events on a contentEditable.
|
||||
//
|
||||
// Regression repro for #39614: typing committed multi-character IME text (e.g.
|
||||
// Chinese "你好") used to leave the send button hidden. The input events fired
|
||||
// during composition carry uncommitted preedit text and are intentionally
|
||||
// skipped; Chromium then does NOT reliably emit a trailing input event after
|
||||
// compositionend on Windows IMEs, so the finalized text never reached composer
|
||||
// state and `hasPayload` stayed false until an unrelated edit forced a sync.
|
||||
// The fix flushes the live DOM text in onCompositionEnd.
|
||||
function Harness({ onPayload }: { onPayload: (hasPayload: boolean) => void }) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const composingRef = useRef(false)
|
||||
const draftRef = useRef('')
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
const next = editor.textContent ?? ''
|
||||
|
||||
if (next !== draftRef.current) {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}
|
||||
}
|
||||
|
||||
onPayload(draft.trim().length > 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable
|
||||
data-testid="editor"
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
}}
|
||||
onInput={event => {
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
ref={editorRef}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('composer IME composition — send button visibility (#39614)', () => {
|
||||
it('shows the send button after committing CJK text without a trailing edit', async () => {
|
||||
let hasPayload = false
|
||||
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
// Compose "你好" the way a Windows Chinese IME does: compositionstart, then
|
||||
// input events carrying uncommitted preedit text, then compositionend with
|
||||
// the committed text already in the DOM — and crucially NO input event
|
||||
// afterwards.
|
||||
await act(async () => {
|
||||
fireEvent.compositionStart(editor)
|
||||
editor.textContent = '你'
|
||||
fireEvent.input(editor)
|
||||
editor.textContent = '你好'
|
||||
fireEvent.input(editor)
|
||||
fireEvent.compositionEnd(editor)
|
||||
})
|
||||
|
||||
// Before the fix this was false (button hidden) until a further edit.
|
||||
expect(hasPayload).toBe(true)
|
||||
expect(editor.textContent).toBe('你好')
|
||||
})
|
||||
|
||||
it('also covers Japanese/Korean and any IME-composed script', async () => {
|
||||
let hasPayload = false
|
||||
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
for (const committed of ['こんにちは', '안녕하세요']) {
|
||||
await act(async () => {
|
||||
fireEvent.compositionStart(editor)
|
||||
editor.textContent = committed
|
||||
fireEvent.input(editor)
|
||||
fireEvent.compositionEnd(editor)
|
||||
})
|
||||
|
||||
expect(hasPayload).toBe(true)
|
||||
|
||||
// Clear for the next script.
|
||||
await act(async () => {
|
||||
editor.textContent = ''
|
||||
fireEvent.input(editor)
|
||||
})
|
||||
expect(hasPayload).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -24,9 +24,17 @@ import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
deriveUserHistory,
|
||||
isBrowsingHistory,
|
||||
resetBrowseState
|
||||
} from '@/store/composer-input-history'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
promoteQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
@@ -123,6 +131,7 @@ export function ChatBar({
|
||||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
@@ -136,12 +145,6 @@ export function ChatBar({
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Set when the user explicitly interrupts the running turn via the Stop
|
||||
// button (busy + empty composer). It suppresses the next busy→false
|
||||
// auto-drain so an explicit Stop actually halts instead of immediately
|
||||
// firing the head of the queue. The queue is preserved; the user resumes
|
||||
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
|
||||
const userInterruptedRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -198,6 +201,7 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
resetBrowseState(prev)
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
|
||||
@@ -549,16 +553,10 @@ export function ChatBar({
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend will
|
||||
// deliver the finalized text via a clean input event.
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = event.currentTarget
|
||||
|
||||
// Pull the live contentEditable text into draftRef + the AUI composer state
|
||||
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
||||
// and compositionend paths so committed IME text reaches state through either.
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
@@ -573,6 +571,17 @@ export function ChatBar({
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend flushes
|
||||
// the finalized text (see onCompositionEnd).
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
@@ -715,6 +724,74 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
if (event.key === 'ArrowUp') {
|
||||
const currentDraft = draftRef.current
|
||||
|
||||
// Editing a queued turn → walk to the older entry.
|
||||
if (queueEdit && stepQueuedEdit(-1)) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Empty composer + a queued turn → open the newest queued entry for edit
|
||||
// (the row's pencil), not a text recall. Enter saves it back to the queue.
|
||||
if (!currentDraft.trim() && !queueEdit && queuedPrompts.length > 0) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
beginQueuedEdit(queuedPrompts[queuedPrompts.length - 1]!)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Don't hijack a typed draft unless already browsing — they'd lose it.
|
||||
if (currentDraft.trim() && !isBrowsingHistory(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
loadIntoComposer(entry, $composerAttachments.get())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
// Editing a queued turn → walk to the newer entry (past the newest exits).
|
||||
if (queueEdit) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
stepQueuedEdit(1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Browsing sent history → step toward the present, restoring the draft.
|
||||
if (isBrowsingHistory(sessionId)) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
loadIntoComposer(result.text, $composerAttachments.get())
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -724,7 +801,32 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
||||
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
||||
if (busy && !hasComposerPayload) {
|
||||
return
|
||||
}
|
||||
|
||||
submitDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
// Editing a queued turn → Esc cancels the edit, restoring the prior draft.
|
||||
if (queueEdit) {
|
||||
event.preventDefault()
|
||||
exitQueuedEdit('cancel')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity).
|
||||
if (busy) {
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,6 +992,42 @@ export function ChatBar({
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
|
||||
// saving the in-progress edit on each step. Stepping newer past the last
|
||||
// entry exits edit mode and restores the pre-edit draft.
|
||||
const stepQueuedEdit = (direction: -1 | 1) => {
|
||||
if (!queueEdit) {
|
||||
return false
|
||||
}
|
||||
|
||||
const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
|
||||
const target = index + direction
|
||||
|
||||
if (index < 0 || target < 0) {
|
||||
return index >= 0 // at the oldest: swallow; missing entry: let it fall through
|
||||
}
|
||||
|
||||
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
|
||||
attachments: cloneAttachments($composerAttachments.get()),
|
||||
text: draftRef.current
|
||||
})
|
||||
|
||||
const next = queuedPrompts[target]
|
||||
|
||||
if (next) {
|
||||
setQueueEdit({ ...queueEdit, entryId: next.id })
|
||||
loadIntoComposer(next.text, next.attachments)
|
||||
} else {
|
||||
setQueueEdit(null)
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
}
|
||||
|
||||
triggerHaptic(saved ? 'success' : 'selection')
|
||||
focusInput()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
|
||||
if (!queueEdit) {
|
||||
return false
|
||||
@@ -958,13 +1096,14 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
removeQueuedPrompt(activeQueueSessionKey, entry.id)
|
||||
resetBrowseState(sessionId)
|
||||
|
||||
return true
|
||||
} finally {
|
||||
drainingQueueRef.current = false
|
||||
}
|
||||
},
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts]
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
|
||||
)
|
||||
|
||||
const drainNextQueued = useCallback(
|
||||
@@ -978,41 +1117,40 @@ export function ChatBar({
|
||||
)
|
||||
|
||||
const sendQueuedNow = useCallback(
|
||||
(id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)),
|
||||
[queueEdit, runDrain]
|
||||
(id: string) => {
|
||||
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
// Promote to the head, then interrupt. The gateway always emits a
|
||||
// settle (message.complete + session.info running:false) when the
|
||||
// turn unwinds, and the busy→false auto-drain below sends this entry.
|
||||
promoteQueuedPrompt(activeQueueSessionKey, id)
|
||||
triggerHaptic('selection')
|
||||
void Promise.resolve(onCancel())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return runDrain(entries => entries.find(e => e.id === id))
|
||||
},
|
||||
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
|
||||
)
|
||||
|
||||
// Auto-drain on busy → false (turn settled). An explicit user interrupt
|
||||
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
|
||||
// the user asked to halt, so we must not immediately re-send the queue.
|
||||
// The queued turns stay intact and the user resumes them on demand.
|
||||
// Auto-drain on busy → false (turn settled). Queued turns always flow once
|
||||
// the session is idle again — whether the turn finished naturally or the
|
||||
// user interrupted it. Interrupting to reach a queued message is the whole
|
||||
// point of the queue, so we never suppress the drain. To cancel queued
|
||||
// turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
// Clear the interrupt latch when a new turn starts (false → true). This
|
||||
// guards the sub-frame race where a Stop click lands after busy already
|
||||
// flipped false (button not yet unmounted): the stale latch can no longer
|
||||
// survive into the next turn and wrongly suppress its natural auto-drain.
|
||||
if (busy && !wasBusy) {
|
||||
userInterruptedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const interrupted = userInterruptedRef.current
|
||||
|
||||
// Consume the interrupt latch on any settle so a later natural completion
|
||||
// is not wrongly suppressed.
|
||||
if (!busy && wasBusy && interrupted) {
|
||||
userInterruptedRef.current = false
|
||||
}
|
||||
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
userInterrupted: interrupted,
|
||||
wasBusy
|
||||
})
|
||||
) {
|
||||
@@ -1053,12 +1191,8 @@ export function ChatBar({
|
||||
} else if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button: an explicit interrupt must actually halt the running
|
||||
// turn. Mark the interrupt so the busy→false auto-drain effect skips
|
||||
// re-sending the queue — otherwise a queued follow-up would fire the
|
||||
// instant we cancel and Stop would appear to "never work". Queued
|
||||
// turns are preserved; the user sends them on demand.
|
||||
userInterruptedRef.current = true
|
||||
// Stop button (the only way to reach here while busy with an empty
|
||||
// composer — empty Enter is short-circuited in the keydown handler).
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
@@ -1067,6 +1201,7 @@ export function ChatBar({
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
void onSubmit(submitted, { attachments })
|
||||
@@ -1136,6 +1271,7 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
await onSubmit(text)
|
||||
}
|
||||
@@ -1208,8 +1344,17 @@ export function ChatBar({
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={() => {
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
|
||||
// The input events fired *during* composition were skipped (they
|
||||
// carried uncommitted preedit text), and Chromium does NOT reliably
|
||||
// emit a trailing input event after compositionend on Windows IMEs.
|
||||
// Without flushing here, committed multi-character IME input (e.g.
|
||||
// Chinese "你好", Japanese, Korean) never reaches composer state, so
|
||||
// `hasComposerPayload` stays false and the send button stays hidden
|
||||
// until an unrelated edit forces a sync (#39614).
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
@@ -1284,7 +1429,11 @@ export function ChatBar({
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
<div className="relative z-6 mb-1 px-0.5">
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
// queue). Overlaps -mb-2 onto the surface's top border for a shared
|
||||
// edge; capped + scrollable. Overlays the chat instead of pushing it.
|
||||
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
|
||||
@@ -23,16 +23,16 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]">
|
||||
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-0.5 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
@@ -41,15 +41,16 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 px-1.5 pb-0.5">
|
||||
<div className="space-y-0.5 px-1 pb-0.5">
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-1',
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
|
||||
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
@@ -97,11 +98,11 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.sendQueuedNow}>
|
||||
<Tip label={sendLabel}>
|
||||
<Button
|
||||
aria-label={c.sendQueuedNow}
|
||||
aria-label={sendLabel}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import { fsReadFileDataUrl, selectPaths } from '@/lib/desktop-fs'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
type ComposerAttachment,
|
||||
@@ -36,6 +37,27 @@ function isImagePath(filePath: string): boolean {
|
||||
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
||||
}
|
||||
|
||||
// Thumbnail source for an attached image. Locally-held paths (drag/paste saves,
|
||||
// local picks) read off the client; when that fails on a remote backend the
|
||||
// path lives on the gateway host, so fall back to the gateway data-url read.
|
||||
async function loadImagePreviewDataUrl(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const local = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (local) {
|
||||
return local
|
||||
}
|
||||
} catch {
|
||||
// Path isn't on the client (remote-picked image) — try the gateway below.
|
||||
}
|
||||
|
||||
try {
|
||||
return await fsReadFileDataUrl(filePath)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface DroppedFile {
|
||||
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
|
||||
file?: File
|
||||
@@ -228,7 +250,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
const paths = await selectPaths({
|
||||
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: kind === 'folder'
|
||||
@@ -291,19 +313,13 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
attachToMain(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
const previewUrl = await loadImagePreviewDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image preview failed')
|
||||
|
||||
return true
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const attachImageBlob = useCallback(
|
||||
@@ -338,7 +354,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
const paths = await selectPaths({
|
||||
title: 'Attach images',
|
||||
defaultPath: currentCwd || undefined,
|
||||
filters: [
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { fsReadFileDataUrl, fsReadFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -179,21 +180,19 @@ function looksBinaryBytes(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
return await fsReadFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Back-compat for a running Electron process whose preload hasn't been
|
||||
// restarted since readFileText was added. readFileDataUrl already existed.
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
const dataUrl = await fsReadFileDataUrl(filePath)
|
||||
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
|
||||
const base64 = metadata.includes(';base64')
|
||||
const mimeType = metadata.replace(/;base64$/, '') || undefined
|
||||
@@ -441,7 +440,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
|
||||
try {
|
||||
if (isImage) {
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
const dataUrl = await fsReadFileDataUrl(filePath)
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
|
||||
@@ -68,6 +68,7 @@ import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { RemotePathPicker } from './remote-path-picker'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
@@ -672,6 +673,7 @@ export function DesktopController() {
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<RemotePathPicker />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
import { useGatewayBoot } from './use-gateway-boot'
|
||||
|
||||
// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
|
||||
// bug that drives the REAL useGatewayBoot hook + REAL HermesGateway through a
|
||||
// fake WebSocket we fully control. No Docker / no real port: from the desktop's
|
||||
// point of view a "remote VPS" is just a WebSocket that opens once and later
|
||||
// refuses to reopen, so that is exactly (and only) what we fake.
|
||||
//
|
||||
// The previous test (gateway-connecting-overlay.test.tsx) hand-set the stores
|
||||
// and asserted the overlays; this one proves the HOOK actually PRODUCES that
|
||||
// stuck store combo — closing the "inferred by reading code" gap on the
|
||||
// post-boot reconnect loop.
|
||||
|
||||
type Listener = (ev: unknown) => void
|
||||
|
||||
// Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect()
|
||||
// touches: readyState, add/removeEventListener('open'|'error'|'close'), close().
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1
|
||||
static CLOSED = 3
|
||||
// Flipped by the test: 'open' = next socket connects; 'fail' = next socket
|
||||
// errors (a dead remote). Mirrors a VPS going away after the first connect.
|
||||
static mode: 'open' | 'fail' = 'open'
|
||||
static instances: FakeWebSocket[] = []
|
||||
|
||||
readyState = 0
|
||||
private listeners: Record<string, Set<Listener>> = {}
|
||||
|
||||
constructor(public url: string) {
|
||||
FakeWebSocket.instances.push(this)
|
||||
const willOpen = FakeWebSocket.mode === 'open'
|
||||
// Resolve on the next microtask/macrotask so connect()'s promise wiring is
|
||||
// in place before open/error fires (matches real async socket handshake).
|
||||
setTimeout(() => {
|
||||
if (willOpen) {
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
this.emit('open', {})
|
||||
} else {
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.emit('error', {})
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
addEventListener(type: string, fn: Listener) {
|
||||
;(this.listeners[type] ??= new Set()).add(fn)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, fn: Listener) {
|
||||
this.listeners[type]?.delete(fn)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.emit('close', {})
|
||||
}
|
||||
|
||||
// Force-drop an open socket, as a sleeping laptop / restarted remote would.
|
||||
drop() {
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.emit('close', {})
|
||||
}
|
||||
|
||||
private emit(type: string, ev: unknown) {
|
||||
for (const fn of this.listeners[type] ?? []) fn(ev)
|
||||
}
|
||||
}
|
||||
|
||||
function fakeDesktop() {
|
||||
const conn = {
|
||||
authMode: 'token' as const,
|
||||
baseUrl: 'https://vps.example.com',
|
||||
profile: 'default',
|
||||
token: 't',
|
||||
wsUrl: 'wss://vps.example.com/api/ws?token=t'
|
||||
}
|
||||
|
||||
return {
|
||||
getConnection: vi.fn(async () => conn),
|
||||
getGatewayWsUrl: vi.fn(async () => conn.wsUrl),
|
||||
getBootProgress: vi.fn(async () => ({
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: '',
|
||||
phase: 'init',
|
||||
progress: 0,
|
||||
running: true,
|
||||
timestamp: Date.now()
|
||||
})),
|
||||
onBootProgress: vi.fn(() => () => undefined),
|
||||
onBackendExit: vi.fn(() => () => undefined),
|
||||
onPowerResume: vi.fn(() => () => undefined),
|
||||
onWindowStateChanged: vi.fn(() => () => undefined),
|
||||
touchBackend: vi.fn(async () => undefined),
|
||||
profile: { get: vi.fn(async () => ({ profile: 'default' })) }
|
||||
}
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: () => undefined,
|
||||
onConnectionReady: () => undefined,
|
||||
onGatewayReady: () => undefined,
|
||||
refreshHermesConfig: async () => undefined,
|
||||
refreshSessions: async () => undefined
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
FakeWebSocket.mode = 'open'
|
||||
FakeWebSocket.instances = []
|
||||
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
|
||||
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()
|
||||
$gatewayState.set('idle')
|
||||
$desktopBoot.set({
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: '',
|
||||
phase: 'init',
|
||||
progress: 0,
|
||||
running: true,
|
||||
timestamp: Date.now(),
|
||||
visible: true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
// Let pending microtasks (awaits) AND the queued 0ms socket open/error fire.
|
||||
async function flushAsync() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
}
|
||||
|
||||
// Drive the exponential backoff forward by its full cap so the next scheduled
|
||||
// reconnect attempt actually runs (1s,2s,4s,8s,15s,15s…). Returns after the
|
||||
// attempt's async work settles.
|
||||
async function advanceBackoff() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => {
|
||||
it('INITIAL boot against a dead VPS: getConnection hangs (waitForHermes) → app sits in the connecting combo, then fails', async () => {
|
||||
// The report's actual path: a fresh launch pointed at an unreachable VPS.
|
||||
// startHermes()'s remote branch awaits waitForHermes() for 45s before it
|
||||
// throws, so the renderer's `await desktop.getConnection()` stays pending
|
||||
// that whole window. During it: gatewayState is still 'idle' (connect was
|
||||
// never reached) and boot.error is null → connecting=true → the fullscreen
|
||||
// CONNECTING overlay, latched, blocking Settings.
|
||||
let rejectConn: (e: Error) => void = () => undefined
|
||||
const desktop = fakeDesktop()
|
||||
desktop.getConnection = vi.fn(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectConn = reject
|
||||
})
|
||||
)
|
||||
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
|
||||
|
||||
render(<Harness />)
|
||||
await flushAsync()
|
||||
|
||||
// getConnection is still pending — the dead-VPS wait. No socket was ever
|
||||
// created, gatewayState never left idle, boot.error is null.
|
||||
expect(FakeWebSocket.instances).toHaveLength(0)
|
||||
expect($gatewayState.get()).not.toBe('open')
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
// ^ connecting === true here → fullscreen CONNECTING, no Settings.
|
||||
|
||||
// After ~45s waitForHermes gives up and getConnection rejects → boot()
|
||||
// catch → failDesktopBoot → the BootFailureOverlay recovery surface.
|
||||
await act(async () => {
|
||||
rejectConn(new Error('Hermes backend did not become ready: timeout'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect($desktopBoot.get().error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => {
|
||||
render(<Harness />)
|
||||
await flushAsync()
|
||||
|
||||
// Initial boot connected.
|
||||
expect($gatewayState.get()).toBe('open')
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
|
||||
// The remote VPS goes away: drop the live socket, and make every reopen
|
||||
// fail from here on.
|
||||
FakeWebSocket.mode = 'fail'
|
||||
act(() => FakeWebSocket.instances[0].drop())
|
||||
await flushAsync()
|
||||
|
||||
// Burn a couple backoff cycles BEFORE the escalation threshold (<6 attempts,
|
||||
// ~the first ~15s). This is the window where stock and fixed behave the
|
||||
// same: socket down, hook retrying, gatewayState non-open, boot.error still
|
||||
// null → CONNECTING covers the screen with no recovery surface. (Past ~45s
|
||||
// the fix raises boot.error; that's asserted in the next test.)
|
||||
await advanceBackoff()
|
||||
|
||||
expect($gatewayState.get()).not.toBe('open')
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
// It is actively retrying, not idle — more sockets were minted.
|
||||
expect(FakeWebSocket.instances.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('FIX: after the prolonged drop the hook raises a recoverable boot error (the escape hatch)', async () => {
|
||||
render(<Harness />)
|
||||
await flushAsync()
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
|
||||
FakeWebSocket.mode = 'fail'
|
||||
act(() => FakeWebSocket.instances[0].drop())
|
||||
await flushAsync()
|
||||
|
||||
// Walk the backoff past the >=6 attempt threshold (~45s of failures).
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await advanceBackoff()
|
||||
}
|
||||
|
||||
// The hook surfaced the recoverable error → BootFailureOverlay (Use local
|
||||
// gateway / Sign in / Retry) becomes reachable instead of CONNECTING.
|
||||
expect($desktopBoot.get().error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FIX: a successful reconnect clears the recoverable error', async () => {
|
||||
render(<Harness />)
|
||||
await flushAsync()
|
||||
|
||||
FakeWebSocket.mode = 'fail'
|
||||
act(() => FakeWebSocket.instances[0].drop())
|
||||
await flushAsync()
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await advanceBackoff()
|
||||
}
|
||||
expect($desktopBoot.get().error).toBeTruthy()
|
||||
|
||||
// The remote comes back: next reconnect attempt opens.
|
||||
FakeWebSocket.mode = 'open'
|
||||
await advanceBackoff()
|
||||
|
||||
expect($gatewayState.get()).toBe('open')
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { HermesReadDirEntry } from '@/global'
|
||||
import { fsReadDir } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $remotePathPicker, resolveRemotePathPicker } from '@/store/remote-path-picker'
|
||||
|
||||
function parentDir(path: string): string | null {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
|
||||
if (idx <= 0) {
|
||||
return idx === 0 ? '/' : null
|
||||
}
|
||||
|
||||
return trimmed.slice(0, idx)
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
return (
|
||||
path
|
||||
.replace(/[\\/]+$/, '')
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? path
|
||||
)
|
||||
}
|
||||
|
||||
// Browses the GATEWAY filesystem (via fs.list) so users on a remote backend can
|
||||
// pick files/folders that exist on the agent host rather than their own machine.
|
||||
// Mirrors the native selectPaths contract: resolves with absolute gateway paths
|
||||
// (or [] when cancelled).
|
||||
export function RemotePathPicker() {
|
||||
const request = useStore($remotePathPicker)
|
||||
|
||||
if (!request) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <RemotePathPickerDialog key={request.id} />
|
||||
}
|
||||
|
||||
function RemotePathPickerDialog() {
|
||||
const request = useStore($remotePathPicker)
|
||||
const options = request?.options ?? {}
|
||||
const directoriesMode = Boolean(options.directories)
|
||||
const allowMultiple = options.multiple !== false && !directoriesMode
|
||||
|
||||
const [dir, setDir] = useState<string>(options.defaultPath ?? '')
|
||||
const [entries, setEntries] = useState<HermesReadDirEntry[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const allowedExtensions = useMemo(() => {
|
||||
const exts = (options.filters ?? []).flatMap(filter => filter.extensions)
|
||||
|
||||
return exts.length > 0 ? new Set(exts.map(ext => ext.toLowerCase().replace(/^\./, ''))) : null
|
||||
}, [options.filters])
|
||||
|
||||
const load = useCallback(async (target: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await fsReadDir(target)
|
||||
|
||||
setDir(result.path ?? target)
|
||||
setEntries(result.entries ?? [])
|
||||
setError(result.error ?? null)
|
||||
setSelected(new Set())
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Loads the initial directory. `load` is stable and defaultPath is fixed for
|
||||
// this keyed instance, so this runs once; navigation calls `load` directly.
|
||||
useEffect(() => {
|
||||
void load(options.defaultPath ?? '')
|
||||
}, [load, options.defaultPath])
|
||||
|
||||
const visibleEntries = useMemo(() => {
|
||||
return entries.filter(entry => {
|
||||
if (entry.isDirectory) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (directoriesMode) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!allowedExtensions) {
|
||||
return true
|
||||
}
|
||||
|
||||
const ext = baseName(entry.name).split('.').pop()?.toLowerCase() ?? ''
|
||||
|
||||
return allowedExtensions.has(ext)
|
||||
})
|
||||
}, [allowedExtensions, directoriesMode, entries])
|
||||
|
||||
const cancel = useCallback(() => resolveRemotePathPicker([]), [])
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
if (directoriesMode) {
|
||||
resolveRemotePathPicker([dir])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (selected.size > 0) {
|
||||
resolveRemotePathPicker([...selected])
|
||||
}
|
||||
}, [dir, directoriesMode, selected])
|
||||
|
||||
const onEntryClick = useCallback(
|
||||
(entry: HermesReadDirEntry) => {
|
||||
if (entry.isDirectory) {
|
||||
void load(entry.path)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (directoriesMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowMultiple) {
|
||||
resolveRemotePathPicker([entry.path])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
|
||||
if (next.has(entry.path)) {
|
||||
next.delete(entry.path)
|
||||
} else {
|
||||
next.add(entry.path)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
},
|
||||
[allowMultiple, directoriesMode, load]
|
||||
)
|
||||
|
||||
const parent = parentDir(dir)
|
||||
const title = options.title || (directoriesMode ? 'Select a folder' : 'Select files')
|
||||
const confirmLabel = directoriesMode ? 'Use this folder' : `Attach${selected.size > 1 ? ` (${selected.size})` : ''}`
|
||||
const confirmDisabled = directoriesMode ? !dir : selected.size === 0
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && cancel()} open>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-(--ui-text-tertiary)">
|
||||
<Button
|
||||
aria-label="Up one folder"
|
||||
disabled={!parent || loading}
|
||||
onClick={() => parent && void load(parent)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="arrow-up" size="0.9rem" />
|
||||
</Button>
|
||||
<span className="truncate font-mono" title={dir}>
|
||||
{dir || '…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-72 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-background/40">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-destructive">
|
||||
Could not read this folder ({error}).
|
||||
</div>
|
||||
) : visibleEntries.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)">
|
||||
{directoriesMode ? 'No subfolders here.' : 'No matching files here.'}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{visibleEntries.map(entry => {
|
||||
const isSelected = selected.has(entry.path)
|
||||
|
||||
return (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-(--chrome-action-hover)',
|
||||
isSelected && 'bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={() => onEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon
|
||||
className={entry.isDirectory ? 'text-(--ui-accent)' : 'text-(--ui-text-tertiary)'}
|
||||
name={entry.isDirectory ? 'folder' : 'file'}
|
||||
size="0.95rem"
|
||||
/>
|
||||
<span className="flex-1 truncate">{entry.name}</span>
|
||||
{!entry.isDirectory && isSelected && <Codicon name="check" size="0.9rem" />}
|
||||
{entry.isDirectory && <Codicon name="chevron-right" size="0.85rem" />}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={confirmDisabled} onClick={confirm} type="button">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
import { fsGitRoot, fsReadDir, fsReadFileDataUrl } from '@/lib/desktop-fs'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
|
||||
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = clean(start)
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
cached = fsGitRoot(key)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
const listing = await fsReadDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
const text = decodeDataUrl(await fsReadFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
@@ -138,11 +131,7 @@ async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, di
|
||||
}
|
||||
|
||||
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
|
||||
if (!window.hermesDesktop) {
|
||||
return { entries: [], error: 'no-bridge' }
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
const result = await fsReadDir(dirPath)
|
||||
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { selectPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -68,7 +69,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
const selected = await selectPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
|
||||
@@ -437,11 +437,18 @@ export function useMessageStream({
|
||||
|
||||
const completedState = updateSessionState(sessionId, state => {
|
||||
// Late completion from an already-cancelled turn: cancelRun has
|
||||
// already finalized the bubble and added the [interrupted] marker;
|
||||
// re-running the dedupe below would erase that marker and replace
|
||||
// the partial with the (just-cancelled) full text.
|
||||
// already finalized the bubble (kept the partial text, dropped it if
|
||||
// empty). Re-running the dedupe below would replace the partial with
|
||||
// the just-cancelled full text, so we settle and bail instead.
|
||||
if (state.interrupted) {
|
||||
return state
|
||||
return {
|
||||
...state,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false,
|
||||
pendingBranchGroup: null,
|
||||
streamId: null
|
||||
}
|
||||
}
|
||||
|
||||
const streamId = state.streamId
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { SessionInfo } from '@/types/hermes'
|
||||
import { usePromptActions } from './use-prompt-actions'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
transcribeAudio: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -39,27 +41,31 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
submitText: (text: string) => Promise<boolean>
|
||||
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
||||
}
|
||||
|
||||
function Harness({
|
||||
busyRef,
|
||||
onReady,
|
||||
onSeedState,
|
||||
refreshSessions,
|
||||
requestGateway
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
onReady: (handle: HarnessHandle) => void
|
||||
onSeedState?: (state: Record<string, unknown>) => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const busyRef = { current: false }
|
||||
const localBusyRef = busyRef ?? { current: false }
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: async () => true,
|
||||
busyRef,
|
||||
busyRef: localBusyRef,
|
||||
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
|
||||
handleSkinCommand: () => '',
|
||||
refreshSessions,
|
||||
@@ -67,8 +73,18 @@ function Harness({
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft: () => undefined,
|
||||
sttEnabled: false,
|
||||
updateSessionState: (_sessionId, updater) =>
|
||||
updater({ messages: [], busy: false, awaitingResponse: false } as never)
|
||||
updateSessionState: (_sessionId, updater) => {
|
||||
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
||||
const next = updater({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never) as unknown as Record<string, unknown>
|
||||
onSeedState?.(next)
|
||||
|
||||
return next as never
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -164,3 +180,82 @@ describe('usePromptActions /title', () => {
|
||||
expect($sessions.get()[0]?.title).toBe('Old title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions submit / queue drain semantics', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clears a leftover interrupted flag on a fresh submit (so the new turn streams)', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('hello after a stop')
|
||||
|
||||
// The optimistic seed must reset interrupted:false even though the prior
|
||||
// session state had interrupted:true — otherwise the message stream drops
|
||||
// every delta of this brand-new turn.
|
||||
expect(seeds.length).toBeGreaterThan(0)
|
||||
expect(seeds.every(s => s.interrupted === false)).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'hello after a stop'
|
||||
})
|
||||
})
|
||||
|
||||
it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => {
|
||||
// busyRef lags $busy by one effect tick on the busy→false settle edge, so a
|
||||
// drained queue send would otherwise hit the busy guard and silently no-op.
|
||||
const busyRef = { current: true }
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
busyRef={busyRef}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('queued message', { fromQueue: true })
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'queued message'
|
||||
})
|
||||
})
|
||||
|
||||
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
|
||||
const busyRef = { current: true }
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
busyRef={busyRef}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('should be blocked')
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
attachmentDisplayText,
|
||||
INTERRUPTED_MARKER,
|
||||
parseCommandDispatch,
|
||||
parseSlashCommand,
|
||||
pathLabel,
|
||||
@@ -178,6 +177,42 @@ export function usePromptActions({
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
// Remote gateways (e.g. a VPS over tailscale) cannot see the client's local
|
||||
// filesystem, so a path-based `image.attach` fails with "image not found".
|
||||
// Fall back to uploading the bytes the Electron client already holds.
|
||||
const uploadImageAttachmentBytes = useCallback(
|
||||
async (sessionId: string, attachment: ComposerAttachment): Promise<ImageAttachResponse | null> => {
|
||||
const path = attachment.path
|
||||
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
let data = attachment.previewUrl
|
||||
|
||||
if (!data && window.hermesDesktop?.readFileDataUrl) {
|
||||
try {
|
||||
data = await window.hermesDesktop.readFileDataUrl(path)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
|
||||
session_id: sessionId,
|
||||
filename: pathLabel(path),
|
||||
data
|
||||
})
|
||||
|
||||
return result.attached ? result : null
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const syncImageAttachmentsForSubmit = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
@@ -192,14 +227,28 @@ export function usePromptActions({
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
let result: ImageAttachResponse | null = null
|
||||
|
||||
if (!result.attached) {
|
||||
try {
|
||||
const pathResult = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
|
||||
if (pathResult.attached) {
|
||||
result = pathResult
|
||||
}
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
result = await uploadImageAttachmentBytes(sessionId, attachment)
|
||||
}
|
||||
|
||||
if (!result?.attached) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
throw new Error(result.message || `Could not attach ${label}`)
|
||||
throw new Error(result?.message || `Could not attach ${label}`)
|
||||
}
|
||||
|
||||
const attachedPath = result.path || attachment.path
|
||||
@@ -215,7 +264,7 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
[requestGateway, uploadImageAttachmentBytes]
|
||||
)
|
||||
|
||||
const submitPromptText = useCallback(
|
||||
@@ -237,7 +286,11 @@ export function usePromptActions({
|
||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(hasImage ? 'What do you see in this image?' : '')
|
||||
|
||||
if (!text || busyRef.current) {
|
||||
// Queue drains fire on the busy→false settle edge, where busyRef (synced
|
||||
// from $busy by a separate effect) may still read true — honoring it would
|
||||
// bounce the drained send. The drain lock serializes them; the user path
|
||||
// keeps the guard so a stray Enter mid-turn can't double-submit.
|
||||
if (!text || (!options?.fromQueue && busyRef.current)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -270,7 +323,10 @@ export function usePromptActions({
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: state.interrupted
|
||||
// Fresh submit = new turn — clear any leftover interrupt flag, else
|
||||
// mutateStream/completeAssistantMessage drop every delta of this turn
|
||||
// (what made drained-after-interrupt sends go silent).
|
||||
interrupted: false
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
@@ -531,6 +587,7 @@ export function usePromptActions({
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
@@ -689,24 +746,24 @@ export function usePromptActions({
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
|
||||
const finalizeMessages = (messages: ChatMessage[]) =>
|
||||
messages.map(message =>
|
||||
message.pending
|
||||
? {
|
||||
...message,
|
||||
parts: chatMessageText(message).trim()
|
||||
? appendTextPart(message.parts, INTERRUPTED_MARKER)
|
||||
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
|
||||
pending: false
|
||||
}
|
||||
: message
|
||||
)
|
||||
// Interrupting keeps whatever was already generated and just
|
||||
// stops — no "[interrupted]" marker. A pending/streaming message with no
|
||||
// body text is dropped entirely so we never leave an empty bubble behind.
|
||||
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
|
||||
messages
|
||||
.filter(
|
||||
message =>
|
||||
!((message.pending || message.id === streamId) && !chatMessageText(message).trim())
|
||||
)
|
||||
.map(message =>
|
||||
message.pending || message.id === streamId ? { ...message, pending: false } : message
|
||||
)
|
||||
|
||||
if (!sessionId) {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setMessages(finalizeMessages($messages.get()))
|
||||
|
||||
return
|
||||
@@ -715,24 +772,12 @@ export function usePromptActions({
|
||||
updateSessionState(sessionId, state => {
|
||||
const streamId = state.streamId
|
||||
|
||||
const messages = streamId
|
||||
? state.messages.map(message =>
|
||||
message.id === streamId
|
||||
? {
|
||||
...message,
|
||||
parts: chatMessageText(message).trim()
|
||||
? appendTextPart(message.parts, INTERRUPTED_MARKER)
|
||||
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
|
||||
pending: false
|
||||
}
|
||||
: message
|
||||
)
|
||||
: finalizeMessages(state.messages)
|
||||
const messages = finalizeMessages(state.messages, streamId)
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages,
|
||||
busy: false,
|
||||
busy: true,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
@@ -743,6 +788,8 @@ export function usePromptActions({
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch (err) {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
notifyError(err, 'Stop failed')
|
||||
}
|
||||
}, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState])
|
||||
|
||||
@@ -117,10 +117,6 @@ function messageContentText(content: unknown): string {
|
||||
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
||||
}
|
||||
|
||||
const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i
|
||||
|
||||
const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim())
|
||||
|
||||
export const Thread: FC<{
|
||||
clampToComposer?: boolean
|
||||
cwd?: string | null
|
||||
@@ -220,7 +216,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isPlaceholder = messageStatus === 'running' && content.length === 0
|
||||
const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText])
|
||||
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
|
||||
|
||||
if (isPlaceholder) {
|
||||
@@ -236,10 +231,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
ref={enterRef}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
|
||||
interruptedOnly && 'text-[0.8rem] leading-5 text-muted-foreground/82'
|
||||
)}
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
@@ -260,7 +252,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
{messageText.trim().length > 0 && !interruptedOnly && (
|
||||
{messageText.trim().length > 0 && (
|
||||
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { $gatewayState, setGatewayState } from '@/store/session'
|
||||
|
||||
import { BootFailureOverlay } from './boot-failure-overlay'
|
||||
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
|
||||
|
||||
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
|
||||
// report. The connecting overlay (z-1200, full-screen, pointer-events on) is
|
||||
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
|
||||
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
|
||||
// "Retry" — only renders when `boot.error` is set.
|
||||
//
|
||||
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
|
||||
// INITIAL boot() throws. After the first successful connect (bootCompleted),
|
||||
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
|
||||
// against the dead remote and never sets boot.error. So gatewayState sits at
|
||||
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
|
||||
// never appears, settings unreachable.
|
||||
|
||||
function resetStores() {
|
||||
setGatewayState('idle')
|
||||
$desktopBoot.set({
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: 'ready',
|
||||
phase: 'renderer.ready',
|
||||
progress: 100,
|
||||
running: false,
|
||||
timestamp: Date.now(),
|
||||
visible: false
|
||||
})
|
||||
$desktopOnboarding.set({
|
||||
configured: true,
|
||||
flow: { status: 'idle' },
|
||||
mode: 'oauth',
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(resetStores)
|
||||
afterEach(cleanup)
|
||||
|
||||
// The connecting overlay renders "CONN" + a scrambled tail inside one
|
||||
// uppercase span; match that node specifically so the recovery overlay's
|
||||
// "Lost connection…" copy doesn't read as a false positive.
|
||||
const isConnectingShown = () =>
|
||||
screen.queryAllByText((_, el) => /^CONN[/\\|\-_=+<>~:*A-Z]*$/.test(el?.textContent?.trim() ?? '')).length > 0
|
||||
const isRecoveryShown = () =>
|
||||
Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i))
|
||||
|
||||
describe('connecting overlay vs recovery surface', () => {
|
||||
it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => {
|
||||
// failDesktopBoot() ran: error set, gateway never opened.
|
||||
$desktopBoot.set({ ...$desktopBoot.get(), error: 'Hermes backend did not become ready', running: false, visible: true })
|
||||
setGatewayState('error')
|
||||
|
||||
render(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
|
||||
expect(isRecoveryShown()).toBe(true)
|
||||
// Connecting overlay bows out when boot.error is set.
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
})
|
||||
|
||||
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
|
||||
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
|
||||
setGatewayState('open')
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
|
||||
// 2. The remote VPS socket drops (sleep/wake, remote restart, network).
|
||||
// bootCompleted is true, so useGatewayBoot routes this through
|
||||
// scheduleReconnect() — boot.error stays NULL.
|
||||
setGatewayState('closed')
|
||||
rerender(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
|
||||
// The connecting overlay reappears and latches...
|
||||
expect(isConnectingShown()).toBe(true)
|
||||
// ...with NO recovery surface, because boot.error was never set.
|
||||
expect(isRecoveryShown()).toBe(false)
|
||||
|
||||
// 3. Reconnect loops forever against the dead remote: gatewayState bounces
|
||||
// closed → error → closed, boot.error never gets set. The user is
|
||||
// pinned on CONNECTING with no path to Settings indefinitely.
|
||||
setGatewayState('error')
|
||||
rerender(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
expect(isConnectingShown()).toBe(true)
|
||||
expect(isRecoveryShown()).toBe(false)
|
||||
})
|
||||
|
||||
it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => {
|
||||
// Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of
|
||||
// failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI
|
||||
// from the dead-end CONNECTING overlay to the recovery surface.
|
||||
setGatewayState('error')
|
||||
$desktopBoot.set({
|
||||
...$desktopBoot.get(),
|
||||
error: 'Lost connection to the Hermes gateway and could not reconnect.',
|
||||
running: false,
|
||||
visible: true
|
||||
})
|
||||
|
||||
render(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
|
||||
// Escape hatch is now reachable; the connecting overlay bows out.
|
||||
expect(isRecoveryShown()).toBe(true)
|
||||
expect(screen.getByText(/use local gateway/i)).toBeTruthy()
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
})
|
||||
})
|
||||
Vendored
+3
@@ -372,6 +372,9 @@ export interface HermesReadDirEntry {
|
||||
export interface HermesReadDirResult {
|
||||
entries: HermesReadDirEntry[]
|
||||
error?: string
|
||||
// Absolute directory the entries were read from. Set by the gateway `fs.list`
|
||||
// RPC (remote backends); the local Electron readDir omits it.
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewFileChanged {
|
||||
|
||||
@@ -698,6 +698,7 @@ export const en: Translations = {
|
||||
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
|
||||
editingInComposer: 'Editing in composer',
|
||||
editQueued: 'Edit queued turn',
|
||||
sendQueuedNext: 'Send queued turn next',
|
||||
sendQueuedNow: 'Send queued turn now',
|
||||
deleteQueued: 'Delete queued turn',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
|
||||
@@ -580,6 +580,7 @@ export interface Translations {
|
||||
attachments: (count: number) => string
|
||||
editingInComposer: string
|
||||
editQueued: string
|
||||
sendQueuedNext: string
|
||||
sendQueuedNow: string
|
||||
deleteQueued: string
|
||||
previewUnavailable: string
|
||||
|
||||
@@ -827,6 +827,7 @@ export const zh: Translations = {
|
||||
attachments: count => `${count} 个附件`,
|
||||
editingInComposer: '正在输入框中编辑',
|
||||
editQueued: '编辑排队回合',
|
||||
sendQueuedNext: '下一个发送排队回合',
|
||||
sendQueuedNow: '立即发送排队回合',
|
||||
deleteQueued: '删除排队回合',
|
||||
previewUnavailable: '预览不可用',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } fro
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
|
||||
|
||||
export const INTERRUPTED_MARKER = '\n\n_[interrupted]_'
|
||||
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
|
||||
export const BUILTIN_PERSONALITIES = [
|
||||
'helpful',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { resolveRemotePathPicker } from '@/store/remote-path-picker'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { fsGitRoot, fsReadDir, fsReadFileDataUrl, isRemoteBackend, selectPaths } from './desktop-fs'
|
||||
|
||||
const request = vi.fn()
|
||||
const readDir = vi.fn()
|
||||
const readFileDataUrl = vi.fn()
|
||||
const gitRoot = vi.fn()
|
||||
const desktopSelectPaths = vi.fn()
|
||||
|
||||
function setRemote(remote: boolean) {
|
||||
$connection.set(remote ? ({ mode: 'remote' } as never) : null)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
readDir.mockReset()
|
||||
readFileDataUrl.mockReset()
|
||||
gitRoot.mockReset()
|
||||
desktopSelectPaths.mockReset()
|
||||
$gateway.set({ request } as unknown as HermesGateway)
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
readDir,
|
||||
readFileDataUrl,
|
||||
gitRoot,
|
||||
selectPaths: desktopSelectPaths
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$connection.set(null)
|
||||
$gateway.set(null)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
describe('desktop-fs facade', () => {
|
||||
it('routes reads to local IPC when not remote', async () => {
|
||||
setRemote(false)
|
||||
readDir.mockResolvedValue({ entries: [] })
|
||||
|
||||
await fsReadDir('/p')
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/p')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(isRemoteBackend()).toBe(false)
|
||||
})
|
||||
|
||||
it('routes directory listing to fs.list when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ entries: [], path: '/srv' })
|
||||
|
||||
const result = await fsReadDir('/srv')
|
||||
|
||||
expect(request).toHaveBeenCalledWith('fs.list', { path: '/srv' })
|
||||
expect(result.path).toBe('/srv')
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unwraps the data url from fs.read_data_url when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ dataUrl: 'data:image/png;base64,AAAA' })
|
||||
|
||||
expect(await fsReadFileDataUrl('/srv/x.png')).toBe('data:image/png;base64,AAAA')
|
||||
expect(request).toHaveBeenCalledWith('fs.read_data_url', { path: '/srv/x.png' })
|
||||
})
|
||||
|
||||
it('returns gateway git root when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ root: '/srv/repo' })
|
||||
|
||||
expect(await fsGitRoot('/srv/repo/a')).toBe('/srv/repo')
|
||||
})
|
||||
|
||||
it('uses the native picker locally and the remote picker when remote', async () => {
|
||||
setRemote(false)
|
||||
desktopSelectPaths.mockResolvedValue(['/local/a.png'])
|
||||
expect(await selectPaths({ title: 'pick' })).toEqual(['/local/a.png'])
|
||||
|
||||
setRemote(true)
|
||||
const pending = selectPaths({ title: 'pick' })
|
||||
resolveRemotePathPicker(['/srv/a.png'])
|
||||
expect(await pending).toEqual(['/srv/a.png'])
|
||||
// Remote selection never touches the native dialog.
|
||||
expect(desktopSelectPaths).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { openRemotePathPicker } from '@/store/remote-path-picker'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
// On a remote gateway (e.g. a VPS over tailscale) the agent's filesystem lives
|
||||
// on the server, but the Electron IPC helpers only see the client machine. This
|
||||
// facade routes reads + path selection through gateway `fs.*` RPCs when remote,
|
||||
// and falls back to local Electron IPC against a locally-spawned backend.
|
||||
export const isRemoteBackend = (): boolean => $connection.get()?.mode === 'remote'
|
||||
|
||||
function gw<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<T>(method, params)
|
||||
}
|
||||
|
||||
const unavailable = (): never => {
|
||||
throw new Error('File reading is unavailable')
|
||||
}
|
||||
|
||||
export function fsReadDir(path: string): Promise<HermesReadDirResult> {
|
||||
if (isRemoteBackend()) {
|
||||
return gw('fs.list', { path })
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readDir?.(path) ?? Promise.resolve({ entries: [], error: 'no-bridge' })
|
||||
}
|
||||
|
||||
export function fsReadFileText(path: string): Promise<HermesReadFileTextResult> {
|
||||
if (isRemoteBackend()) {
|
||||
return gw('fs.read_text', { path })
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readFileText?.(path) ?? unavailable()
|
||||
}
|
||||
|
||||
export async function fsReadFileDataUrl(path: string): Promise<string> {
|
||||
if (isRemoteBackend()) {
|
||||
return (await gw<{ dataUrl?: string }>('fs.read_data_url', { path })).dataUrl ?? unavailable()
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readFileDataUrl?.(path) ?? unavailable()
|
||||
}
|
||||
|
||||
export async function fsGitRoot(path: string): Promise<string | null> {
|
||||
if (isRemoteBackend()) {
|
||||
return (await gw<{ root?: string | null }>('fs.git_root', { path })).root ?? null
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.gitRoot?.(path) ?? null
|
||||
}
|
||||
|
||||
export async function selectPaths(options: HermesSelectPathsOptions = {}): Promise<string[]> {
|
||||
if (isRemoteBackend()) {
|
||||
return openRemotePathPicker(options)
|
||||
}
|
||||
|
||||
return (await window.hermesDesktop?.selectPaths?.(options)) ?? []
|
||||
}
|
||||
@@ -15,6 +15,7 @@ describe('desktop slash command curation', () => {
|
||||
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/version')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/yolo')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ const DESKTOP_COMMAND_META = [
|
||||
['/title', 'Rename the current session'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session'],
|
||||
['/version', 'Show Hermes Agent version'],
|
||||
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
|
||||
] as const
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$perSessionBrowse,
|
||||
browseBackward,
|
||||
browseForward,
|
||||
deriveUserHistory,
|
||||
isBrowsingHistory,
|
||||
resetBrowseState
|
||||
} from './composer-input-history'
|
||||
|
||||
const SESSION_A = 'session-a'
|
||||
const SESSION_B = 'session-b'
|
||||
|
||||
// Newest-first user text ring, what the caller passes to browse*.
|
||||
const HISTORY = ['third', 'second', 'first']
|
||||
|
||||
const MSG = (role: string, text: string) => ({ id: '', role, text })
|
||||
|
||||
beforeEach(() => {
|
||||
$perSessionBrowse.set({})
|
||||
})
|
||||
|
||||
describe('deriveUserHistory', () => {
|
||||
it('returns user messages newest-first with empty/whitespace skipped', () => {
|
||||
const messages = [
|
||||
MSG('user', ' '),
|
||||
MSG('assistant', 'hi'),
|
||||
MSG('user', 'first'),
|
||||
MSG('user', 'second')
|
||||
]
|
||||
|
||||
expect(deriveUserHistory(messages, m => m.text)).toEqual(['second', 'first'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('browseBackward', () => {
|
||||
it('returns null when history is empty', () => {
|
||||
expect(browseBackward(SESSION_A, '', [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the most recent entry on first press and saves the draft', () => {
|
||||
const result = browseBackward(SESSION_A, 'unsent draft', HISTORY)
|
||||
|
||||
expect(result).toBe('third')
|
||||
expect($perSessionBrowse.get()[SESSION_A]!.draftSnapshot).toBe('unsent draft')
|
||||
})
|
||||
|
||||
it('moves to older entries on subsequent presses and stops at the oldest', () => {
|
||||
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('third')
|
||||
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('second')
|
||||
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('first')
|
||||
expect(browseBackward(SESSION_A, '', HISTORY)).toBeNull()
|
||||
})
|
||||
|
||||
it('uses caller-provided history, not a mirrored ring', () => {
|
||||
// The store never owns the ring — the caller passes it every press.
|
||||
// If the ring changes between presses (e.g. a new message was sent),
|
||||
// the next press sees the updated ring and the cursor continues
|
||||
// from where it was within it.
|
||||
expect(browseBackward(SESSION_A, '', ['youngest', 'older'])).toBe('youngest')
|
||||
|
||||
// Caller added a new message; ring is now [brand-new, youngest, older].
|
||||
// Cursor was at 0, next press advances to 1 -> "youngest".
|
||||
expect(
|
||||
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
|
||||
).toBe('youngest')
|
||||
|
||||
// One more press -> "older".
|
||||
expect(
|
||||
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
|
||||
).toBe('older')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browseForward', () => {
|
||||
it('returns null when not browsing', () => {
|
||||
expect(browseForward(SESSION_A, HISTORY)).toBeNull()
|
||||
})
|
||||
|
||||
it('moves toward the present', () => {
|
||||
browseBackward(SESSION_A, 'draft', HISTORY) // cursor 0 -> 'third'
|
||||
browseBackward(SESSION_A, '', HISTORY) // cursor 1 -> 'second'
|
||||
|
||||
expect(browseForward(SESSION_A, HISTORY)).toEqual({
|
||||
text: 'third',
|
||||
returnedToPresent: false
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the saved draft and resets when reaching the present', () => {
|
||||
browseBackward(SESSION_A, 'my original draft', HISTORY)
|
||||
|
||||
const result = browseForward(SESSION_A, HISTORY)
|
||||
|
||||
expect(result).toEqual({ text: 'my original draft', returnedToPresent: true })
|
||||
expect(isBrowsingHistory(SESSION_A)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-session isolation', () => {
|
||||
it('tracks cursor and draft independently per session', () => {
|
||||
browseBackward(SESSION_A, 'draft-a', HISTORY)
|
||||
browseBackward(SESSION_A, '', HISTORY) // older
|
||||
|
||||
browseBackward(SESSION_B, 'draft-b', HISTORY)
|
||||
|
||||
const a = $perSessionBrowse.get()[SESSION_A]!
|
||||
const b = $perSessionBrowse.get()[SESSION_B]!
|
||||
|
||||
expect(a.cursor).toBe(1)
|
||||
expect(a.draftSnapshot).toBe('draft-a')
|
||||
expect(b.cursor).toBe(0)
|
||||
expect(b.draftSnapshot).toBe('draft-b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetBrowseState', () => {
|
||||
it('clears cursor and draft snapshot', () => {
|
||||
browseBackward(SESSION_A, 'draft', HISTORY)
|
||||
resetBrowseState(SESSION_A)
|
||||
|
||||
const s = $perSessionBrowse.get()[SESSION_A]!
|
||||
|
||||
expect(s.cursor).toBe(-1)
|
||||
expect(s.draftSnapshot).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session switch behavior', () => {
|
||||
it('resets the previous session cursor and lets the new session derive its own ring', () => {
|
||||
// Session A: user browsed into the past
|
||||
browseBackward(SESSION_A, '', HISTORY)
|
||||
expect(isBrowsingHistory(SESSION_A)).toBe(true)
|
||||
|
||||
// Caller switches to session B; resets A's browse state
|
||||
resetBrowseState(SESSION_A)
|
||||
|
||||
// Session B's ring is derived from B's messages, not A's
|
||||
const sessionBMessages = [MSG('user', 'hello-b'), MSG('user', 'world-b')]
|
||||
const sessionBHistory = deriveUserHistory(sessionBMessages, m => m.text)
|
||||
|
||||
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('world-b')
|
||||
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('hello-b')
|
||||
expect(isBrowsingHistory(SESSION_A)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
/**
|
||||
* Per-session input history browse state.
|
||||
*
|
||||
* The user-text ring is **derived from the live session messages** on each
|
||||
* keypress — it is not mirrored anywhere. This keeps a single source of truth
|
||||
* and avoids the entire class of seeding/dedup bugs that come from trying to
|
||||
* keep a parallel ring in sync with submit/queue/voice paths.
|
||||
*
|
||||
* We only persist the cursor and the saved draft:
|
||||
* - `cursor` — index into the derived user-text ring (0 = newest, larger = older).
|
||||
* `-1` means "not browsing".
|
||||
* - `draftSnapshot` — the composer text at the moment the user started
|
||||
* browsing, so ArrowDown back to the "present" restores it.
|
||||
*/
|
||||
export interface SessionBrowseState {
|
||||
cursor: number
|
||||
draftSnapshot: string
|
||||
}
|
||||
|
||||
const $perSessionBrowse = atom<Record<string, SessionBrowseState>>({})
|
||||
|
||||
function ensure(sessionId: string): SessionBrowseState {
|
||||
const all = { ...$perSessionBrowse.get() }
|
||||
let s = all[sessionId]
|
||||
|
||||
if (!s) {
|
||||
s = { cursor: -1, draftSnapshot: '' }
|
||||
all[sessionId] = s
|
||||
$perSessionBrowse.set(all)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
function persist() {
|
||||
$perSessionBrowse.set({ ...$perSessionBrowse.get() })
|
||||
}
|
||||
|
||||
function valid(sessionId: string | null | undefined): sessionId is string {
|
||||
return typeof sessionId === 'string' && sessionId.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the user-text ring (newest first) from session messages.
|
||||
* The caller is responsible for providing already-session-scoped messages.
|
||||
*/
|
||||
export function deriveUserHistory<T extends { role: string }>(
|
||||
messages: readonly T[],
|
||||
getText: (m: T) => string
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!
|
||||
|
||||
if (m.role !== 'user') {continue}
|
||||
|
||||
const t = getText(m).trim()
|
||||
|
||||
if (t) {out.push(t)}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Start browsing backward, or step to the next older entry.
|
||||
* Returns the text to place in the composer, or null if already at the oldest
|
||||
* entry (or the ring is empty).
|
||||
*/
|
||||
export function browseBackward(
|
||||
sessionId: string | null | undefined,
|
||||
currentDraft: string,
|
||||
history: readonly string[]
|
||||
): string | null {
|
||||
if (!valid(sessionId) || history.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const s = ensure(sessionId)
|
||||
|
||||
if (s.cursor === -1) {
|
||||
s.draftSnapshot = currentDraft
|
||||
s.cursor = 0
|
||||
} else if (s.cursor < history.length - 1) {
|
||||
s.cursor += 1
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
persist()
|
||||
|
||||
return history[s.cursor]!
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse forward toward the present. When reaching the "newest" entry the
|
||||
* saved draft is restored and the cursor resets.
|
||||
*/
|
||||
export function browseForward(
|
||||
sessionId: string | null | undefined,
|
||||
history: readonly string[]
|
||||
): { text: string; returnedToPresent: boolean } | null {
|
||||
if (!valid(sessionId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const s = ensure(sessionId)
|
||||
|
||||
if (s.cursor === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (s.cursor > 0) {
|
||||
s.cursor -= 1
|
||||
persist()
|
||||
|
||||
return { text: history[s.cursor]!, returnedToPresent: false }
|
||||
}
|
||||
|
||||
// At newest; moving forward restores the saved draft.
|
||||
const text = s.draftSnapshot
|
||||
s.cursor = -1
|
||||
s.draftSnapshot = ''
|
||||
persist()
|
||||
|
||||
return { text, returnedToPresent: true }
|
||||
}
|
||||
|
||||
/** Clear browse state for a session (e.g. on session switch or new submit). */
|
||||
export function resetBrowseState(sessionId: string | null | undefined) {
|
||||
if (!valid(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const all = { ...$perSessionBrowse.get() }
|
||||
const existing = all[sessionId]
|
||||
|
||||
if (!existing) {return}
|
||||
|
||||
all[sessionId] = { cursor: -1, draftSnapshot: '' }
|
||||
$perSessionBrowse.set(all)
|
||||
}
|
||||
|
||||
/** True if the user is currently browsing history for this session. */
|
||||
export function isBrowsingHistory(sessionId: string | null | undefined): boolean {
|
||||
if (!valid(sessionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const s = $perSessionBrowse.get()[sessionId]
|
||||
|
||||
return s ? s.cursor >= 0 : false
|
||||
}
|
||||
|
||||
export { $perSessionBrowse }
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
dequeueQueuedPrompt,
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
promoteQueuedPrompt,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt,
|
||||
@@ -63,6 +64,20 @@ describe('composer queue store', () => {
|
||||
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two'])
|
||||
})
|
||||
|
||||
it('promotes a queued entry to the front', () => {
|
||||
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
|
||||
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
|
||||
const third = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'third' })
|
||||
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).not.toBeNull()
|
||||
expect(third).not.toBeNull()
|
||||
|
||||
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(true)
|
||||
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['third', 'first', 'second'])
|
||||
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('updates queued text and attachment snapshot', () => {
|
||||
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' })
|
||||
const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')]
|
||||
@@ -103,26 +118,22 @@ describe('composer queue store', () => {
|
||||
})
|
||||
|
||||
describe('shouldAutoDrainOnSettle', () => {
|
||||
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
|
||||
const base = { isBusy: false, queueLength: 1, wasBusy: true }
|
||||
|
||||
it('drains the next queued prompt when a turn completes naturally', () => {
|
||||
it('drains the next queued prompt when a turn settles', () => {
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
|
||||
// Regression: previously the Stop button "never worked" because cancelling
|
||||
// a turn flipped busy → false and the queue immediately re-fired its head.
|
||||
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
|
||||
it('drains after an interrupt — the settle edge is the same', () => {
|
||||
// Interrupting to reach a queued message is the point of the queue; the
|
||||
// gateway emits the same settle whether the turn finished or was stopped.
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not drain when the queue is empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain when interrupted even if the queue is also empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores steady busy state (no true → false transition)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
|
||||
})
|
||||
|
||||
@@ -137,6 +137,26 @@ export const removeQueuedPrompt = (key: string | null | undefined, id: string):
|
||||
return true
|
||||
}
|
||||
|
||||
export const promoteQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (!sid) {
|
||||
return false
|
||||
}
|
||||
|
||||
const queue = queueFor(sid)
|
||||
const index = queue.findIndex(e => e.id === id)
|
||||
|
||||
if (index <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const entry = queue[index]!
|
||||
writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)])
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const updateQueuedPrompt = (
|
||||
key: string | null | undefined,
|
||||
id: string,
|
||||
@@ -194,33 +214,26 @@ export interface AutoDrainSettleInput {
|
||||
wasBusy: boolean
|
||||
isBusy: boolean
|
||||
queueLength: number
|
||||
userInterrupted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the composer should auto-drain the next queued prompt when a
|
||||
* turn settles (busy transitions true → false).
|
||||
*
|
||||
* The queue auto-advances when a turn *completes naturally*, but must NOT
|
||||
* advance when the user *explicitly interrupted* the turn via the Stop button.
|
||||
* Conflating the two made the Stop button appear to "never work": cancelling a
|
||||
* turn flipped busy → false, the queue immediately re-fired its head, and the
|
||||
* agent kept running. An explicit interrupt means stop — the queued turns are
|
||||
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
|
||||
* per-row "send now" arrow).
|
||||
* Queued turns always advance once the session is idle again, whether the turn
|
||||
* finished naturally or the user interrupted it. Interrupting to reach a queued
|
||||
* message is the whole point of the queue, so we never suppress the drain. The
|
||||
* gateway guarantees a settle (message.complete + session.info running:false)
|
||||
* even after an interrupt, so this single edge reliably advances the queue. To
|
||||
* cancel queued turns the user deletes them from the panel.
|
||||
*/
|
||||
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
|
||||
const { isBusy, queueLength, userInterrupted, wasBusy } = params
|
||||
const { isBusy, queueLength, wasBusy } = params
|
||||
|
||||
// Only react to a true → false transition; ignore steady state and entry.
|
||||
if (isBusy || !wasBusy) {
|
||||
return false
|
||||
}
|
||||
|
||||
// An explicit Stop suppresses exactly one auto-drain.
|
||||
if (userInterrupted) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueLength > 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { HermesSelectPathsOptions } from '@/global'
|
||||
|
||||
export interface RemotePathPickerRequest {
|
||||
id: number
|
||||
options: HermesSelectPathsOptions
|
||||
resolve: (paths: string[]) => void
|
||||
}
|
||||
|
||||
// Holds the currently open remote path-picker request, if any. The picker
|
||||
// modal subscribes and resolves the promise when the user confirms or cancels.
|
||||
// Used only when the desktop is connected to a remote gateway, where the native
|
||||
// OS dialog (which browses the client machine) is the wrong filesystem.
|
||||
export const $remotePathPicker = atom<RemotePathPickerRequest | null>(null)
|
||||
|
||||
let nextRequestId = 0
|
||||
|
||||
export function openRemotePathPicker(options: HermesSelectPathsOptions = {}): Promise<string[]> {
|
||||
// Only one picker at a time; cancel any prior request.
|
||||
const previous = $remotePathPicker.get()
|
||||
|
||||
if (previous) {
|
||||
previous.resolve([])
|
||||
}
|
||||
|
||||
return new Promise<string[]>(resolve => {
|
||||
$remotePathPicker.set({ id: (nextRequestId += 1), options, resolve })
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRemotePathPicker(paths: string[]): void {
|
||||
const request = $remotePathPicker.get()
|
||||
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
|
||||
$remotePathPicker.set(null)
|
||||
request.resolve(paths)
|
||||
}
|
||||
@@ -9015,6 +9015,10 @@ class HermesCLI:
|
||||
elif canonical == "update":
|
||||
if self._handle_update_command():
|
||||
return False
|
||||
elif canonical == "version":
|
||||
from hermes_cli.main import _print_version_info
|
||||
|
||||
_print_version_info(check_updates=True)
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "image":
|
||||
|
||||
@@ -7932,6 +7932,8 @@ class GatewayRunner:
|
||||
return await self._handle_profile_command(event)
|
||||
if _cmd_def_inner.name == "update":
|
||||
return await self._handle_update_command(event)
|
||||
if _cmd_def_inner.name == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
# Catch-all: any other recognized slash command reached the
|
||||
# running-agent guard. Reject gracefully rather than falling
|
||||
@@ -8288,6 +8290,9 @@ class GatewayRunner:
|
||||
if canonical == "update":
|
||||
return await self._handle_update_command(event)
|
||||
|
||||
if canonical == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
if canonical == "debug":
|
||||
return await self._handle_debug_command(event)
|
||||
|
||||
@@ -10913,6 +10918,12 @@ class GatewayRunner:
|
||||
return event.platform_update_id <= recorded_uid
|
||||
|
||||
|
||||
async def _handle_version_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /version — show the running Hermes Agent version."""
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
return format_banner_version_label()
|
||||
|
||||
async def _handle_help_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /help command - list available commands."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
|
||||
@@ -14,8 +14,8 @@ Provides subcommands for:
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.15.1"
|
||||
__release_date__ = "2026.5.29"
|
||||
__version__ = "0.16.0"
|
||||
__release_date__ = "2026.6.5"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
@@ -216,6 +216,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||||
cli_only=True, args_hint="<path>"),
|
||||
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
|
||||
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
|
||||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
|
||||
|
||||
# Exit
|
||||
@@ -349,6 +350,7 @@ ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
||||
"steer",
|
||||
"stop",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+3
-1
@@ -6647,7 +6647,9 @@ def cmd_import(args):
|
||||
|
||||
|
||||
def _print_version_info(*, check_updates: bool = True) -> None:
|
||||
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
print(format_banner_version_label())
|
||||
print(f"Project: {PROJECT_ROOT}")
|
||||
|
||||
# Show Python version
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-agent"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
|
||||
readme = "README.md"
|
||||
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
|
||||
|
||||
@@ -40,6 +40,8 @@ IGNORED_PATTERNS = [
|
||||
re.compile(r"^Claude", re.IGNORECASE),
|
||||
re.compile(r"^Copilot$", re.IGNORECASE),
|
||||
re.compile(r"^Cursor(\s+Agent)?$", re.IGNORECASE),
|
||||
re.compile(r"^Codex$", re.IGNORECASE),
|
||||
re.compile(r"^github-advanced-security(\[bot\])?$", re.IGNORECASE),
|
||||
re.compile(r"^GitHub\s*Actions?$", re.IGNORECASE),
|
||||
re.compile(r"^github-actions(\[bot\])?$", re.IGNORECASE),
|
||||
re.compile(r"^dependabot", re.IGNORECASE),
|
||||
|
||||
@@ -1167,6 +1167,16 @@ AUTHOR_MAP = {
|
||||
"chenzeshi@live.com": "chen1749144759",
|
||||
"mor.aleksandr@yahoo.com": "MorAlekss",
|
||||
"276649498+ztexydt-cqh@users.noreply.github.com": "ztexydt-cqh",
|
||||
# v0.16.0 additions
|
||||
"teknium@nous.dev": "teknium1",
|
||||
"alaamohanad169@gmail.com": "alaamohanad169-ship-it",
|
||||
"archer@ouyangdeMac-mini.local": "Archerouyang", # display name 欧阳
|
||||
"batosk2@gmail.com": "Sarbai", # git email for PR #33438 author (display: Брагарник Дмитро)
|
||||
"info@aminvakil.com": "aminvakil",
|
||||
"nikpolale@gmail.com": "polnikale",
|
||||
"sarveshagl1327@gmail.com": "sarvesh1327", # salvaged via #38655
|
||||
"sohyuanchin@gmail.com": "wysie",
|
||||
"bedirhan@codeway.co": "bedirhancode",
|
||||
"ash@users.noreply.github.com": "ash",
|
||||
"andrewho.sf@gmail.com": "andrewhosf",
|
||||
# April 2026 Honcho bug-fix consolidation (#15381)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tests for the /version slash command."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command
|
||||
|
||||
|
||||
def test_version_command_is_registered():
|
||||
cmd = resolve_command("version")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "version"
|
||||
assert cmd.category == "Info"
|
||||
assert resolve_command("v") is cmd
|
||||
|
||||
|
||||
def test_version_is_gateway_known():
|
||||
assert "version" in GATEWAY_KNOWN_COMMANDS
|
||||
assert "v" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
|
||||
def test_process_command_version_prints_version_info():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
|
||||
with patch("hermes_cli.main._print_version_info") as mock_print:
|
||||
assert cli_obj.process_command("/version") is True
|
||||
|
||||
mock_print.assert_called_once_with(check_updates=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for gateway /version command."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
|
||||
def test_gateway_version_command_returns_release_line():
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
result = asyncio.run(GatewayRunner._handle_version_command(None, None)) # type: ignore[arg-type]
|
||||
assert result == format_banner_version_label()
|
||||
@@ -336,13 +336,19 @@ class TestSlackNativeSlashes:
|
||||
)
|
||||
|
||||
def test_includes_aliases_as_first_class_slashes(self):
|
||||
"""Aliases (/btw, /bg, /reset, /q) must be registered as standalone
|
||||
slashes — this is the whole point of native-slashes parity."""
|
||||
"""Aliases (/btw, /bg, /reset) must be registered as standalone
|
||||
slashes — this is the whole point of native-slashes parity.
|
||||
|
||||
Note: Slack's manifest hard-caps slash commands at 50
|
||||
(``_SLACK_MAX_SLASH_COMMANDS``). Canonical names win slots first,
|
||||
then aliases, so the lowest-priority aliases can be clamped off
|
||||
once the registry fills the cap (e.g. ``/q`` once ``/version``
|
||||
landed). The surviving aliases below still prove alias parity;
|
||||
anything dropped remains reachable via ``/hermes <command>``."""
|
||||
names = {n for n, _d, _h in slack_native_slashes()}
|
||||
assert "btw" in names
|
||||
assert "bg" in names
|
||||
assert "reset" in names
|
||||
assert "q" in names
|
||||
|
||||
def test_telegram_parity(self):
|
||||
"""Every Telegram bot command must be registerable on Slack too.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Regression tests for the rg/grep error guard in content search.
|
||||
|
||||
The guard in ``_search_with_rg`` / ``_search_with_grep`` had two defects on
|
||||
``origin/main`` (see PR replacing #39710):
|
||||
|
||||
1. **Unreachable on a hard error.** Both methods pipe the search through
|
||||
``| head`` with no ``pipefail``, so the pipeline reported head's exit code
|
||||
(0), masking rg/grep's error code (2). The guard never fired, and the
|
||||
error text — merged into stdout by ``_exec`` (``stderr=subprocess.STDOUT``)
|
||||
— was parsed as bogus match lines instead of being surfaced.
|
||||
|
||||
2. **Would have nuked partial results if it ever did fire.** A broad
|
||||
``exit_code == 2`` check discards real matches whenever rg/grep also hit a
|
||||
non-fatal error (e.g. one unreadable file in a tree that otherwise
|
||||
matched), which both tools signal with exit 2.
|
||||
|
||||
The fix adds ``set -o pipefail`` so the real exit code propagates, splits
|
||||
tool diagnostics from match output by *shape*, and only surfaces an error
|
||||
when exit==2 AND no usable match payload remains.
|
||||
|
||||
These tests drive the real methods through the real local terminal backend.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.file_operations import (
|
||||
ShellFileOperations,
|
||||
_split_tool_diagnostics,
|
||||
)
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
|
||||
def _ops(root):
|
||||
return ShellFileOperations(LocalEnvironment(cwd=str(root)), cwd=str(root))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def match_tree(tmp_path):
|
||||
"""A tree with several files all containing 'needle'."""
|
||||
for i in range(5):
|
||||
(tmp_path / f"f{i}.txt").write_text(f"needle line {i}\n")
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def partial_error_tree(tmp_path):
|
||||
"""A tree with matches plus one unreadable file (forces exit 2 + matches)."""
|
||||
for i in range(4):
|
||||
(tmp_path / f"f{i}.txt").write_text(f"needle line {i}\n")
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
locked = sub / "locked.txt"
|
||||
locked.write_text("needle in locked\n")
|
||||
os.chmod(locked, 0o000)
|
||||
yield tmp_path
|
||||
os.chmod(locked, 0o755) # let pytest clean up tmp_path
|
||||
|
||||
|
||||
# Run every test once per available backend method.
|
||||
_METHODS = ["_search_with_grep"]
|
||||
if shutil.which("rg"):
|
||||
_METHODS.append("_search_with_rg")
|
||||
|
||||
|
||||
def _search(ops, method, pattern, path, **kw):
|
||||
fn = getattr(ops, method)
|
||||
return fn(pattern, str(path), kw.get("file_glob"), kw.get("limit", 50),
|
||||
kw.get("offset", 0), kw.get("output_mode", "content"),
|
||||
kw.get("context", 0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", _METHODS)
|
||||
class TestSearchErrorGuard:
|
||||
def test_happy_path_returns_matches(self, method, match_tree):
|
||||
res = _search(_ops(match_tree), method, "needle", match_tree)
|
||||
assert res.error is None
|
||||
assert len(res.matches) == 5
|
||||
|
||||
def test_hard_error_is_surfaced(self, method, match_tree):
|
||||
# An invalid regex makes rg/grep exit 2 with only diagnostics in
|
||||
# stdout. The guard MUST surface it — not return empty matches.
|
||||
res = _search(_ops(match_tree), method, "[", match_tree)
|
||||
assert res.error is not None, "search error was silently swallowed"
|
||||
assert "Search failed" in res.error
|
||||
assert not res.matches
|
||||
|
||||
def test_partial_error_keeps_matches(self, method, partial_error_tree):
|
||||
# rg/grep exit 2 because of the unreadable file, but the readable
|
||||
# files matched. Those matches must be preserved, not discarded.
|
||||
res = _search(_ops(partial_error_tree), method, "needle", partial_error_tree)
|
||||
assert res.error is None, f"partial error wrongly surfaced: {res.error!r}"
|
||||
assert len(res.matches) >= 4
|
||||
|
||||
def test_no_match_is_empty_not_error(self, method, match_tree):
|
||||
res = _search(_ops(match_tree), method, "zzznomatchzzz", match_tree)
|
||||
assert res.error is None
|
||||
assert not res.matches
|
||||
|
||||
def test_truncation_no_false_error(self, method, tmp_path):
|
||||
# head truncates a large result set. With pipefail, grep exits 141
|
||||
# (SIGPIPE) on truncation; the strict `== 2` guard must ignore it.
|
||||
big = tmp_path / "big.txt"
|
||||
big.write_text("".join(f"needle {i}\n" for i in range(3000)))
|
||||
res = _search(_ops(tmp_path), method, "needle", tmp_path, limit=5)
|
||||
assert res.error is None, f"truncated success wrongly errored: {res.error!r}"
|
||||
assert len(res.matches) == 5
|
||||
|
||||
def test_files_only_excludes_diagnostics(self, method, partial_error_tree):
|
||||
# files_only mode must not list a diagnostic line as a fake file path.
|
||||
res = _search(_ops(partial_error_tree), method, "needle",
|
||||
partial_error_tree, output_mode="files_only")
|
||||
assert res.error is None
|
||||
assert res.files, "expected matching files"
|
||||
assert all("Permission denied" not in f and "locked.txt" not in f
|
||||
for f in res.files), f"diagnostic leaked into files: {res.files}"
|
||||
|
||||
def test_count_mode_with_partial_error(self, method, partial_error_tree):
|
||||
res = _search(_ops(partial_error_tree), method, "needle",
|
||||
partial_error_tree, output_mode="count")
|
||||
assert res.error is None
|
||||
assert res.total_count >= 4
|
||||
|
||||
|
||||
class TestSplitToolDiagnostics:
|
||||
"""Unit coverage for the shape-based diagnostic/payload splitter."""
|
||||
|
||||
def test_pure_error_has_empty_payload(self):
|
||||
out = "rg: regex parse error:\n (?:[)\n ^\nerror: unclosed character class\n"
|
||||
diagnostics, payload = _split_tool_diagnostics(out)
|
||||
assert payload.strip() == ""
|
||||
assert "regex parse error" in diagnostics
|
||||
|
||||
def test_partial_error_separates_matches(self):
|
||||
out = ("rg: sub/locked.txt: Permission denied (os error 13)\n"
|
||||
"a.txt:1:needle here\nb.txt:2:needle there\n")
|
||||
diagnostics, payload = _split_tool_diagnostics(out)
|
||||
assert "Permission denied" in diagnostics
|
||||
assert "a.txt:1:needle here" in payload
|
||||
assert "b.txt:2:needle there" in payload
|
||||
assert "Permission denied" not in payload
|
||||
|
||||
def test_files_only_is_payload(self):
|
||||
diagnostics, payload = _split_tool_diagnostics("src/a.py\nsrc/b.py\n")
|
||||
assert diagnostics == ""
|
||||
assert payload == "src/a.py\nsrc/b.py"
|
||||
|
||||
def test_count_lines_are_payload(self):
|
||||
diagnostics, payload = _split_tool_diagnostics("src/a.py:3\nsrc/b.py:1\n")
|
||||
assert diagnostics == ""
|
||||
assert "src/a.py:3" in payload
|
||||
|
||||
def test_context_lines_and_separator_are_payload(self):
|
||||
out = "a.py:5:hit\na.py-6-after\n--\nb.py:9:hit\n"
|
||||
diagnostics, payload = _split_tool_diagnostics(out)
|
||||
assert diagnostics == ""
|
||||
assert "--" in payload
|
||||
assert "a.py-6-after" in payload
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Tests for the remote-browsing filesystem RPCs (fs.*) and image.attach_bytes.
|
||||
|
||||
These power the desktop app when it talks to a gateway on a remote host (e.g. a
|
||||
VPS over tailscale): the Files sidebar and path pickers browse the gateway's
|
||||
filesystem via fs.list / fs.read_text / fs.read_data_url / fs.git_root, and
|
||||
locally-held images are pushed to the gateway via image.attach_bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# 1x1 transparent PNG.
|
||||
_PNG_1x1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
yield home
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(hermes_home):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"hermes_cli.env_loader": MagicMock(),
|
||||
"hermes_cli.banner": MagicMock(),
|
||||
},
|
||||
):
|
||||
mod = importlib.import_module("tui_gateway.server")
|
||||
yield mod
|
||||
mod._sessions.clear()
|
||||
mod._pending.clear()
|
||||
mod._answers.clear()
|
||||
mod._methods.clear()
|
||||
importlib.reload(mod)
|
||||
|
||||
|
||||
def _call(server, method: str, params: dict) -> dict:
|
||||
return server.handle_request({"id": "1", "method": method, "params": params})
|
||||
|
||||
|
||||
# ── fs.list ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fs_list_returns_sorted_entries(server, tmp_path):
|
||||
work = tmp_path / "proj"
|
||||
work.mkdir()
|
||||
(work / "b_file.txt").write_text("x")
|
||||
(work / "a_dir").mkdir()
|
||||
(work / "node_modules").mkdir() # hidden by filter
|
||||
|
||||
resp = _call(server, "fs.list", {"path": str(work)})
|
||||
result = resp["result"]
|
||||
|
||||
assert result["path"] == str(work.resolve())
|
||||
names = [e["name"] for e in result["entries"]]
|
||||
# Directories first, then files; node_modules filtered out.
|
||||
assert names == ["a_dir", "b_file.txt"]
|
||||
assert result["entries"][0]["isDirectory"] is True
|
||||
assert result["entries"][1]["isDirectory"] is False
|
||||
|
||||
|
||||
def test_fs_list_missing_dir_reports_error(server, tmp_path):
|
||||
resp = _call(server, "fs.list", {"path": str(tmp_path / "nope")})
|
||||
|
||||
assert resp["result"]["entries"] == []
|
||||
assert resp["result"]["error"] == "ENOENT"
|
||||
|
||||
|
||||
# ── fs.read_text ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fs_read_text_reads_file(server, tmp_path):
|
||||
target = tmp_path / "hello.py"
|
||||
target.write_text("print('hi')\n")
|
||||
|
||||
resp = _call(server, "fs.read_text", {"path": str(target)})
|
||||
result = resp["result"]
|
||||
|
||||
assert result["text"] == "print('hi')\n"
|
||||
assert result["language"] == "python"
|
||||
assert result["binary"] is False
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
def test_fs_read_text_missing_file_errors(server, tmp_path):
|
||||
resp = _call(server, "fs.read_text", {"path": str(tmp_path / "gone.txt")})
|
||||
|
||||
assert resp["error"]["code"] == 4016
|
||||
|
||||
|
||||
def test_fs_read_text_flags_binary(server, tmp_path):
|
||||
target = tmp_path / "blob.bin"
|
||||
target.write_bytes(b"\x00\x01\x02\x03")
|
||||
|
||||
result = _call(server, "fs.read_text", {"path": str(target)})["result"]
|
||||
|
||||
assert result["binary"] is True
|
||||
|
||||
|
||||
# ── fs.read_data_url ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fs_read_data_url_encodes_file(server, tmp_path):
|
||||
target = tmp_path / "pixel.png"
|
||||
target.write_bytes(_PNG_1x1)
|
||||
|
||||
result = _call(server, "fs.read_data_url", {"path": str(target)})["result"]
|
||||
|
||||
assert result["dataUrl"].startswith("data:image/png;base64,")
|
||||
encoded = result["dataUrl"].split(",", 1)[1]
|
||||
assert base64.b64decode(encoded) == _PNG_1x1
|
||||
|
||||
|
||||
def test_fs_read_data_url_rejects_oversized(server, tmp_path):
|
||||
target = tmp_path / "big.bin"
|
||||
target.write_bytes(b"x")
|
||||
# Patch the cap below the file size to exercise the guard deterministically.
|
||||
with patch.object(server, "_FS_DATA_URL_MAX_BYTES", 0):
|
||||
resp = _call(server, "fs.read_data_url", {"path": str(target)})
|
||||
|
||||
assert resp["error"]["code"] == 4017
|
||||
|
||||
|
||||
# ── fs.git_root ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fs_git_root_walks_up(server, tmp_path):
|
||||
(tmp_path / ".git").mkdir()
|
||||
nested = tmp_path / "a" / "b"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
result = _call(server, "fs.git_root", {"path": str(nested)})["result"]
|
||||
|
||||
assert result["root"] == str(tmp_path.resolve())
|
||||
|
||||
|
||||
def test_fs_git_root_none_when_absent(server, tmp_path):
|
||||
result = _call(server, "fs.git_root", {"path": str(tmp_path)})["result"]
|
||||
|
||||
assert result["root"] is None
|
||||
|
||||
|
||||
# ── image.attach_bytes ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def image_session(server):
|
||||
"""A session that bypasses agent build so _sess() resolves cleanly."""
|
||||
sid = "sid-img"
|
||||
# Non-empty so _sess_nowait()'s truthiness check treats it as present.
|
||||
server._sessions[sid] = {"image_counter": 0}
|
||||
with patch.object(server, "_start_agent_build", lambda *a, **k: None), patch.object(
|
||||
server, "_wait_agent", lambda s, rid: None
|
||||
):
|
||||
yield sid
|
||||
|
||||
|
||||
def test_image_attach_bytes_writes_and_attaches(server, hermes_home, image_session):
|
||||
data_url = "data:image/png;base64," + base64.b64encode(_PNG_1x1).decode()
|
||||
|
||||
resp = _call(
|
||||
server,
|
||||
"image.attach_bytes",
|
||||
{"session_id": image_session, "data": data_url, "filename": "shot.png"},
|
||||
)
|
||||
result = resp["result"]
|
||||
|
||||
assert result["attached"] is True
|
||||
saved = Path(result["path"])
|
||||
assert saved.exists()
|
||||
assert saved.read_bytes() == _PNG_1x1
|
||||
assert saved.suffix == ".png"
|
||||
# Lands under the gateway's HERMES_HOME, not the client.
|
||||
assert str(saved).startswith(str(hermes_home / "images"))
|
||||
assert server._sessions[image_session]["attached_images"] == [str(saved)]
|
||||
|
||||
|
||||
def test_image_attach_bytes_infers_extension_from_mime(server, image_session):
|
||||
data_url = "data:image/webp;base64," + base64.b64encode(_PNG_1x1).decode()
|
||||
|
||||
result = _call(
|
||||
server,
|
||||
"image.attach_bytes",
|
||||
{"session_id": image_session, "data": data_url},
|
||||
)["result"]
|
||||
|
||||
assert Path(result["path"]).suffix == ".webp"
|
||||
|
||||
|
||||
def test_image_attach_bytes_rejects_empty(server, image_session):
|
||||
resp = _call(
|
||||
server,
|
||||
"image.attach_bytes",
|
||||
{"session_id": image_session, "data": ""},
|
||||
)
|
||||
|
||||
assert resp["error"]["code"] == 4015
|
||||
+106
-18
@@ -285,6 +285,63 @@ class ExecuteResult:
|
||||
exit_code: int = 0
|
||||
|
||||
|
||||
def _split_tool_diagnostics(output: str) -> tuple[str, str]:
|
||||
"""Separate rg/grep diagnostic lines from real match output.
|
||||
|
||||
``_exec`` runs commands with ``stderr=subprocess.STDOUT``, so error and
|
||||
warning text from ``rg``/``grep`` is interleaved with match lines in a
|
||||
single stream. Diagnostics must not be parsed as matches, and on a hard
|
||||
failure they are the error message to surface.
|
||||
|
||||
Returns ``(diagnostics, payload)`` where ``payload`` contains only lines
|
||||
that look like real search output — a match line (``file:line:content``),
|
||||
a files-only path, a count line, or a context line/separator. Everything
|
||||
else (tool-prefixed errors, rg's multi-line ``regex parse error`` block
|
||||
with its indented carets, blank lines) is folded into ``diagnostics``.
|
||||
|
||||
Classifying by *shape* rather than by error prefix is what lets the
|
||||
exit-2 guard distinguish a pure failure (no usable payload → surface the
|
||||
error) from a partial failure (some files matched, one was unreadable →
|
||||
keep the matches). It also means error text can never be mis-parsed as a
|
||||
match, a latent bug that predates the exit-code fix.
|
||||
"""
|
||||
diagnostics: list[str] = []
|
||||
payload: list[str] = []
|
||||
for line in output.split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
# Tool diagnostics always carry the "<tool>: " prefix (e.g.
|
||||
# "rg: <file>: Permission denied", "grep: Invalid regular
|
||||
# expression", "rg: regex parse error:"). Check this first: a real
|
||||
# match path can legitimately contain "-<digit>" (e.g. a tmp dir like
|
||||
# ".../pytest-686/..."), which the shape regex would otherwise treat
|
||||
# as a match line.
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("rg: ") or stripped.startswith("grep: "):
|
||||
diagnostics.append(line)
|
||||
continue
|
||||
# Otherwise classify by output shape. rg's regex-parse-error block
|
||||
# also emits an indented caret line and a trailing "error: ..." line
|
||||
# with no tool prefix; neither matches a search-output shape, so they
|
||||
# fall through to diagnostics.
|
||||
# match / count : "<path>:<...>" (has a colon; rg -c uses path:count)
|
||||
# files_only : "<path>" (no whitespace, no leading colon)
|
||||
# context line : "<path>-<line>-" or the "--" group separator
|
||||
if line == "--" or _SEARCH_OUTPUT_RE.match(line):
|
||||
payload.append(line)
|
||||
else:
|
||||
diagnostics.append(line)
|
||||
return '\n'.join(diagnostics), '\n'.join(payload)
|
||||
|
||||
|
||||
# A real rg/grep output line starts with a path token and is followed by a
|
||||
# ``:`` (match/count), a ``-`` (context), or nothing (files_only). Tool
|
||||
# diagnostics ("rg: ...", "grep: ...", "error: ...", indented carets) never
|
||||
# match because the path token forbids whitespace and a leading tool prefix
|
||||
# like "rg" is followed by ": " (space) which the negated class rejects.
|
||||
_SEARCH_OUTPUT_RE = re.compile(r'^([A-Za-z]:)?[^\s:][^\n]*?[:\-]\d|^[^\s:][^\s]*$')
|
||||
|
||||
|
||||
def _parse_search_context_line(line: str) -> tuple[str, int, str] | None:
|
||||
"""Parse grep/rg context output in ``path-line-content`` format.
|
||||
|
||||
@@ -2038,24 +2095,40 @@ class ShellFileOperations(FileOperations):
|
||||
fetch_limit = limit + offset + 200 if context > 0 else limit + offset
|
||||
cmd_parts.extend(["|", "head", "-n", str(fetch_limit)])
|
||||
|
||||
cmd = " ".join(cmd_parts)
|
||||
# `set -o pipefail` so rg's exit status propagates through `| head`.
|
||||
# Without it the pipeline reports head's status (0), masking rg's
|
||||
# error code (2) and making the guard below unreachable. rg handles a
|
||||
# truncating head cleanly (exit 0 on SIGPIPE), so pipefail does not
|
||||
# introduce false errors on a successful-but-truncated search.
|
||||
cmd = "set -o pipefail; " + " ".join(cmd_parts)
|
||||
result = self._exec(cmd, timeout=60)
|
||||
|
||||
# rg exit codes: 0=matches found, 1=no matches, 2=error
|
||||
if result.exit_code == 2 and not result.stdout.strip():
|
||||
error_msg = result.stderr.strip() if hasattr(result, 'stderr') and result.stderr else "Search error"
|
||||
|
||||
# _exec merges stderr into stdout (stderr=subprocess.STDOUT), so rg's
|
||||
# diagnostic lines ("rg: <file>: <error>", "rg: regex parse error:")
|
||||
# are interleaved with match output. Split them out: diagnostics must
|
||||
# not be parsed as matches, and on a hard error they ARE the message.
|
||||
diagnostics, payload = _split_tool_diagnostics(result.stdout)
|
||||
|
||||
# rg exit codes: 0=matches found, 1=no matches, 2=error. rg returns 2
|
||||
# even on partial errors (e.g. one unreadable file in a tree that
|
||||
# otherwise matched), so only surface an error when exit==2 AND no
|
||||
# usable match payload remains. Otherwise we keep the real matches.
|
||||
if result.exit_code == 2 and not payload.strip():
|
||||
error_msg = diagnostics.strip() or result.stdout.strip() or "Search error"
|
||||
return SearchResult(error=f"Search failed: {error_msg}", total_count=0)
|
||||
|
||||
|
||||
# Parse the diagnostic-free payload so error text never becomes a match.
|
||||
stdout = payload
|
||||
# Parse results based on output mode
|
||||
if output_mode == "files_only":
|
||||
all_files = [f for f in result.stdout.strip().split('\n') if f]
|
||||
all_files = [f for f in stdout.strip().split('\n') if f]
|
||||
total = len(all_files)
|
||||
page = all_files[offset:offset + limit]
|
||||
return SearchResult(files=page, total_count=total)
|
||||
|
||||
elif output_mode == "count":
|
||||
counts = {}
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
for line in stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.rsplit(':', 1)
|
||||
if len(parts) == 2:
|
||||
@@ -2074,7 +2147,7 @@ class ShellFileOperations(FileOperations):
|
||||
# so naive split(":") breaks. Use regex to handle both platforms.
|
||||
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
|
||||
matches = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
for line in stdout.strip().split('\n'):
|
||||
if not line or line == "--":
|
||||
continue
|
||||
|
||||
@@ -2138,23 +2211,38 @@ class ShellFileOperations(FileOperations):
|
||||
fetch_limit = limit + offset + (200 if context > 0 else 0)
|
||||
cmd_parts.extend(["|", "head", "-n", str(fetch_limit)])
|
||||
|
||||
cmd = " ".join(cmd_parts)
|
||||
# `set -o pipefail` so grep's exit status propagates through `| head`
|
||||
# (without it the pipeline reports head's 0, masking grep's error 2).
|
||||
# A truncating head makes grep exit 141 (SIGPIPE) on an otherwise
|
||||
# successful search; the strict `== 2` guard below ignores that, so
|
||||
# pipefail does not turn truncated results into false errors.
|
||||
cmd = "set -o pipefail; " + " ".join(cmd_parts)
|
||||
result = self._exec(cmd, timeout=60)
|
||||
|
||||
# grep exit codes: 0=matches found, 1=no matches, 2=error
|
||||
if result.exit_code == 2 and not result.stdout.strip():
|
||||
error_msg = result.stderr.strip() if hasattr(result, 'stderr') and result.stderr else "Search error"
|
||||
|
||||
# _exec merges stderr into stdout, so grep's diagnostic lines
|
||||
# ("grep: <file>: <error>") are interleaved with matches. Split them
|
||||
# out so they're never parsed as matches and so a hard error has a
|
||||
# clean message.
|
||||
diagnostics, payload = _split_tool_diagnostics(result.stdout)
|
||||
|
||||
# grep exit codes: 0=matches found, 1=no matches, 2=error. grep
|
||||
# returns 2 on partial errors (e.g. an unreadable file) even when
|
||||
# other files matched, so only surface an error when exit==2 AND no
|
||||
# usable match payload remains.
|
||||
if result.exit_code == 2 and not payload.strip():
|
||||
error_msg = diagnostics.strip() or result.stdout.strip() or "Search error"
|
||||
return SearchResult(error=f"Search failed: {error_msg}", total_count=0)
|
||||
|
||||
|
||||
stdout = payload
|
||||
if output_mode == "files_only":
|
||||
all_files = [f for f in result.stdout.strip().split('\n') if f]
|
||||
all_files = [f for f in stdout.strip().split('\n') if f]
|
||||
total = len(all_files)
|
||||
page = all_files[offset:offset + limit]
|
||||
return SearchResult(files=page, total_count=total)
|
||||
|
||||
elif output_mode == "count":
|
||||
counts = {}
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
for line in stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.rsplit(':', 1)
|
||||
if len(parts) == 2:
|
||||
@@ -2172,7 +2260,7 @@ class ShellFileOperations(FileOperations):
|
||||
# so naive split(":") breaks. Use regex to handle both platforms.
|
||||
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
|
||||
matches = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
for line in stdout.strip().split('\n'):
|
||||
if not line or line == "--":
|
||||
continue
|
||||
|
||||
|
||||
@@ -4980,6 +4980,249 @@ def _(rid, params: dict) -> dict:
|
||||
return _err(rid, 5027, str(e))
|
||||
|
||||
|
||||
_DATA_URL_MIME_EXT = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp",
|
||||
"image/tiff": ".tiff",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/x-icon": ".ico",
|
||||
"image/vnd.microsoft.icon": ".ico",
|
||||
}
|
||||
|
||||
|
||||
@method("image.attach_bytes")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Attach an image uploaded as bytes (base64 / data URL).
|
||||
|
||||
Unlike ``image.attach`` (which resolves a path on the gateway host), this
|
||||
writes the client-supplied bytes into ``$HERMES_HOME/images`` on the
|
||||
gateway. Used by the desktop app when the gateway is remote (e.g. a VPS)
|
||||
and the UI file picker yields a path that only exists on the client.
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
raw = str(params.get("data", "") or "").strip()
|
||||
if not raw:
|
||||
return _err(rid, 4015, "data required")
|
||||
|
||||
from cli import _IMAGE_EXTENSIONS
|
||||
|
||||
mime = ""
|
||||
payload = raw
|
||||
m = re.match(r"^data:([^;,]*)(;base64)?,(.*)$", raw, re.DOTALL)
|
||||
if m:
|
||||
mime = (m.group(1) or "").strip().lower()
|
||||
payload = m.group(3) or ""
|
||||
try:
|
||||
blob = base64.b64decode(payload, validate=False)
|
||||
except Exception:
|
||||
return _err(rid, 4016, "invalid image data")
|
||||
if not blob:
|
||||
return _err(rid, 4016, "empty image data")
|
||||
|
||||
ext = Path(str(params.get("filename", "") or "")).suffix.lower()
|
||||
if ext not in _IMAGE_EXTENSIONS:
|
||||
ext = _DATA_URL_MIME_EXT.get(mime, "")
|
||||
if ext not in _IMAGE_EXTENSIONS:
|
||||
ext = ".png"
|
||||
|
||||
session["image_counter"] = session.get("image_counter", 0) + 1
|
||||
img_dir = _hermes_home / "images"
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
img_path = (
|
||||
img_dir
|
||||
/ f"upload_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}{ext}"
|
||||
)
|
||||
try:
|
||||
img_path.write_bytes(blob)
|
||||
except Exception as e:
|
||||
session["image_counter"] = max(0, session["image_counter"] - 1)
|
||||
return _err(rid, 5027, str(e))
|
||||
|
||||
session.setdefault("attached_images", []).append(str(img_path))
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"attached": True,
|
||||
"path": str(img_path),
|
||||
"count": len(session["attached_images"]),
|
||||
"text": f"[User attached image: {img_path.name}]",
|
||||
**_image_meta(img_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Filesystem browsing RPCs (fs.*) run on the GATEWAY host. The desktop app uses
|
||||
# them when connected to a remote gateway (e.g. a VPS over tailscale) so the
|
||||
# Files sidebar and path pickers browse the agent's filesystem rather than the
|
||||
# client's. Shapes mirror the Electron `hermes:fs:*` IPC handlers so the same
|
||||
# renderer components consume either source unchanged.
|
||||
_FS_READDIR_HIDDEN = frozenset({
|
||||
".git", ".hg", ".svn", ".cache", ".next", ".turbo", ".venv",
|
||||
"__pycache__", "build", "dist", "node_modules", "target", "venv",
|
||||
})
|
||||
_FS_TEXT_READ_MAX_BYTES = 512 * 1024
|
||||
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
|
||||
_FS_LANGUAGE_BY_EXT = {
|
||||
".c": "c", ".conf": "ini", ".cpp": "cpp", ".css": "css", ".csv": "csv",
|
||||
".go": "go", ".graphql": "graphql", ".h": "c", ".hpp": "cpp",
|
||||
".html": "html", ".ini": "ini", ".java": "java", ".js": "javascript",
|
||||
".json": "json", ".jsx": "jsx", ".kt": "kotlin", ".lua": "lua",
|
||||
".md": "markdown", ".php": "php", ".py": "python", ".rb": "ruby",
|
||||
".rs": "rust", ".sh": "shell", ".sql": "sql", ".swift": "swift",
|
||||
".toml": "toml", ".ts": "typescript", ".tsx": "tsx", ".xml": "xml",
|
||||
".yaml": "yaml", ".yml": "yaml",
|
||||
}
|
||||
|
||||
|
||||
def _fs_resolve(params: dict, *, default_to_cwd: bool = False) -> Path:
|
||||
"""Resolve an fs.* path param on the gateway host.
|
||||
|
||||
Relative paths resolve against the session/terminal cwd (same base as path
|
||||
completions). When ``default_to_cwd`` and no path is given, returns the cwd.
|
||||
"""
|
||||
raw = str(params.get("path", "") or "").strip()
|
||||
base = _completion_cwd(params)
|
||||
if not raw:
|
||||
return Path(base)
|
||||
expanded = os.path.expanduser(os.path.expandvars(raw))
|
||||
p = Path(expanded)
|
||||
if not p.is_absolute():
|
||||
p = Path(base) / p
|
||||
return p
|
||||
|
||||
|
||||
def _fs_stat_file(rid, params: dict):
|
||||
"""Resolve + stat a regular file. Returns (resolved, stat, None) or (None, None, err)."""
|
||||
target = _fs_resolve(params)
|
||||
try:
|
||||
resolved = target.resolve()
|
||||
st = resolved.stat()
|
||||
except FileNotFoundError:
|
||||
return None, None, _err(rid, 4016, f"file not found: {target}")
|
||||
except OSError as e:
|
||||
return None, None, _err(rid, 5027, str(e))
|
||||
if not resolved.is_file():
|
||||
return None, None, _err(rid, 4016, f"not a file: {resolved}")
|
||||
return resolved, st, None
|
||||
|
||||
|
||||
@method("fs.list")
|
||||
def _(rid, params: dict) -> dict:
|
||||
import errno as _errno
|
||||
|
||||
target = _fs_resolve(params, default_to_cwd=True)
|
||||
try:
|
||||
resolved = target.resolve()
|
||||
except Exception:
|
||||
resolved = target
|
||||
try:
|
||||
entries = []
|
||||
with os.scandir(resolved) as it:
|
||||
for entry in it:
|
||||
if entry.name in _FS_READDIR_HIDDEN:
|
||||
continue
|
||||
try:
|
||||
is_dir = entry.is_dir()
|
||||
except OSError:
|
||||
is_dir = False
|
||||
entries.append(
|
||||
{
|
||||
"name": entry.name,
|
||||
"path": str(Path(resolved) / entry.name),
|
||||
"isDirectory": is_dir,
|
||||
}
|
||||
)
|
||||
entries.sort(key=lambda e: (0 if e["isDirectory"] else 1, e["name"].lower()))
|
||||
return _ok(rid, {"path": str(resolved), "entries": entries})
|
||||
except OSError as e:
|
||||
code = _errno.errorcode.get(getattr(e, "errno", None), "read-error")
|
||||
return _ok(rid, {"path": str(resolved), "entries": [], "error": code})
|
||||
|
||||
|
||||
@method("fs.read_text")
|
||||
def _(rid, params: dict) -> dict:
|
||||
import mimetypes
|
||||
|
||||
resolved, st, err = _fs_stat_file(rid, params)
|
||||
if err:
|
||||
return err
|
||||
|
||||
to_read = min(st.st_size, _FS_TEXT_READ_MAX_BYTES)
|
||||
try:
|
||||
with open(resolved, "rb") as f:
|
||||
chunk = f.read(to_read)
|
||||
except OSError as e:
|
||||
return _err(rid, 5027, str(e))
|
||||
|
||||
binary = b"\x00" in chunk[:4096]
|
||||
text = "" if binary else chunk.decode("utf-8", errors="replace")
|
||||
mime = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"binary": binary,
|
||||
"byteSize": st.st_size,
|
||||
"language": _FS_LANGUAGE_BY_EXT.get(resolved.suffix.lower(), "text"),
|
||||
"mimeType": mime,
|
||||
"path": str(resolved),
|
||||
"text": text,
|
||||
"truncated": st.st_size > _FS_TEXT_READ_MAX_BYTES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("fs.read_data_url")
|
||||
def _(rid, params: dict) -> dict:
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
resolved, st, err = _fs_stat_file(rid, params)
|
||||
if err:
|
||||
return err
|
||||
if st.st_size > _FS_DATA_URL_MAX_BYTES:
|
||||
return _err(rid, 4017, f"file too large: {resolved.name}")
|
||||
|
||||
try:
|
||||
data = resolved.read_bytes()
|
||||
except OSError as e:
|
||||
return _err(rid, 5027, str(e))
|
||||
mime = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return _ok(rid, {"path": str(resolved), "dataUrl": f"data:{mime};base64,{b64}"})
|
||||
|
||||
|
||||
@method("fs.git_root")
|
||||
def _(rid, params: dict) -> dict:
|
||||
target = _fs_resolve(params, default_to_cwd=True)
|
||||
try:
|
||||
start = target.resolve()
|
||||
if start.is_file():
|
||||
start = start.parent
|
||||
except Exception:
|
||||
start = target
|
||||
current = start
|
||||
for _ in range(50):
|
||||
try:
|
||||
if (current / ".git").exists():
|
||||
return _ok(rid, {"root": str(current)})
|
||||
except OSError:
|
||||
return _ok(rid, {"root": None})
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return _ok(rid, {"root": None})
|
||||
|
||||
|
||||
@method("image.detach")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess(params, rid)
|
||||
|
||||
@@ -1114,6 +1114,43 @@ describe('createGatewayEventHandler', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keepBusy interrupt holds busy until the gateway settles and suppresses the cancelled turn’s final_response', () => {
|
||||
// Force-send: interrupt holds busy so the drain waits for the real settle
|
||||
// instead of racing it (the race duplicated the bubble, leaked a "queued: …"
|
||||
// note, and surfaced the cancelled turn's "Operation interrupted…" reply).
|
||||
const appended: Msg[] = []
|
||||
const ctx = buildCtx(appended)
|
||||
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
|
||||
const onEvent = createGatewayEventHandler(ctx)
|
||||
|
||||
patchUiState({ sid: 'sess-1' })
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({ payload: { text: 'thinking…' }, type: 'reasoning.delta' } as any)
|
||||
expect(getUiState().busy).toBe(true)
|
||||
|
||||
turnController.interruptTurn(
|
||||
{ appendMessage: (msg: Msg) => appended.push(msg), gw: ctx.gateway.gw, sid: 'sess-1', sys: ctx.system.sys },
|
||||
{ keepBusy: true }
|
||||
)
|
||||
|
||||
// Held busy: the drain effect keys off busy→false, so it must not fire yet.
|
||||
expect(getUiState().busy).toBe(true)
|
||||
|
||||
// The cancelled turn settles with a backend interrupted final_response.
|
||||
const before = appended.length
|
||||
onEvent({
|
||||
payload: { text: 'Operation interrupted: waiting for model response (4.1s elapsed).' },
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
|
||||
// Settle flips busy false (the single drain edge) and the backend
|
||||
// "Operation interrupted…" line is suppressed (not appended).
|
||||
expect(getUiState().busy).toBe(false)
|
||||
expect(appended.slice(before).some(m => typeof m.text === 'string' && m.text.includes('Operation interrupted'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('persists an abandoned (timed-out) clarify into the transcript when the clarify tool completes', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
@@ -182,7 +182,12 @@ class TurnController {
|
||||
resetFlowOverlays()
|
||||
}
|
||||
|
||||
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
|
||||
// `keepBusy` holds the session busy after interrupting so a queued message
|
||||
// drains on the gateway's real settle edge (message.complete, suppressed
|
||||
// while `interrupted`) instead of racing the still-unwinding turn — the race
|
||||
// duplicated the user bubble, leaked a "queued: …" note, and surfaced the
|
||||
// cancelled turn's "[interrupted]" reply.
|
||||
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps, opts: { keepBusy?: boolean } = {}) {
|
||||
this.interrupted = true
|
||||
gw.request<SessionInterruptResponse>('session.interrupt', { session_id: sid }).catch(() => {})
|
||||
|
||||
@@ -218,9 +223,17 @@ class TurnController {
|
||||
sys('interrupted')
|
||||
}
|
||||
|
||||
patchUiState({ status: 'interrupted' })
|
||||
this.clearStatusTimer()
|
||||
|
||||
if (opts.keepBusy) {
|
||||
// `idle()` already cleared busy; re-assert it so the drain waits for settle.
|
||||
patchUiState({ busy: true, status: 'interrupting…' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
patchUiState({ status: 'interrupted' })
|
||||
|
||||
this.statusTimer = setTimeout(() => {
|
||||
this.statusTimer = null
|
||||
patchUiState({ status: 'ready' })
|
||||
|
||||
@@ -220,25 +220,28 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
// - 'steer' : inject into the current turn via session.steer; falls
|
||||
// back to queue when steer is rejected (no agent / no
|
||||
// tool window).
|
||||
// - 'interrupt' (default): cancel the in-flight turn, then send the
|
||||
// new text as a fresh prompt so it actually moves.
|
||||
// - 'interrupt' (default): queue the text + interrupt with `keepBusy`; the
|
||||
// busy→false settle edge drains it once (desktop parity).
|
||||
// No optimistic send → no duplicate bubble / race note.
|
||||
//
|
||||
// `opts.fallbackToFront` controls whether a steer fallback re-inserts
|
||||
// at the front of the queue (used by the queue-edit path to preserve
|
||||
// a picked item's position); the mainline submit path always appends.
|
||||
// `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 } = {}) => {
|
||||
const live = getUiState()
|
||||
const mode = live.busyInputMode
|
||||
|
||||
const fallback = (note: string) => {
|
||||
const enqueueText = () => {
|
||||
if (opts.fallbackToFront) {
|
||||
composerRefs.queueRef.current.unshift(full)
|
||||
composerActions.syncQueue()
|
||||
} else {
|
||||
composerActions.enqueue(full)
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = (note: string) => {
|
||||
enqueueText()
|
||||
sys(note)
|
||||
}
|
||||
|
||||
@@ -260,25 +263,14 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
return
|
||||
}
|
||||
|
||||
// 'interrupt' (default): tear down the current turn, then send.
|
||||
// `interruptTurn` fires `session.interrupt` without awaiting; if
|
||||
// the gateway is still mid-response when `prompt.submit` lands,
|
||||
// `send()`'s catch path re-queues with a "queued: ..." sys note
|
||||
// (`isSessionBusyError`) — so a lost race degrades to queue
|
||||
// semantics, not a dropped message.
|
||||
// 'interrupt': queue + interrupt(keepBusy); the settle edge drains it once.
|
||||
enqueueText()
|
||||
|
||||
if (live.sid) {
|
||||
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
|
||||
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: true })
|
||||
}
|
||||
|
||||
if (hasInterpolation(full)) {
|
||||
patchUiState({ busy: true })
|
||||
|
||||
return interpolate(full, send)
|
||||
}
|
||||
|
||||
send(full)
|
||||
},
|
||||
[appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
|
||||
[appendMessage, composerActions, composerRefs, gw, sys]
|
||||
)
|
||||
|
||||
const dispatchSubmission = useCallback(
|
||||
@@ -380,7 +372,11 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
lastEmptyAt.current = now
|
||||
|
||||
if (doubleTap && live.busy && live.sid) {
|
||||
return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
|
||||
// Force-send: keep busy when a message is queued so the settle edge
|
||||
// drains it once (no race). Empty queue = plain Stop → 'ready'.
|
||||
const hasQueued = composerRefs.queueRef.current.length > 0
|
||||
|
||||
return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: hasQueued })
|
||||
}
|
||||
|
||||
if (doubleTap && live.sid && composerRefs.queueRef.current.length) {
|
||||
|
||||
Reference in New Issue
Block a user