Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 237807ad3a Include git SHA in /version output via banner label helper.
Reuses format_banner_version_label() so CLI, TUI, gateway, and desktop show upstream/local commit when available.
2026-06-05 19:39:58 -05:00
Brooklyn Nicholson d95c76aa37 Add /version slash command across CLI, gateway, TUI, and desktop.
Surfaces Hermes Agent version info on demand without leaving chat; works mid-run like /help and /update.
2026-06-05 19:38:32 -05:00
46 changed files with 237 additions and 2690 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.16.0",
"version": "0.15.1",
"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.16.0",
"package": "hermes-agent[acp]==0.15.1",
"args": ["hermes-acp"]
}
}
@@ -17,8 +17,6 @@
//! 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.
@@ -105,37 +103,10 @@ 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')
+3 -29
View File
@@ -28,7 +28,6 @@ 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,
@@ -1314,31 +1313,6 @@ 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.
@@ -1499,7 +1473,6 @@ 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()
@@ -3494,7 +3467,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const body = serializeJsonBody(options.body)
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const request = electronNet.request({
@@ -3504,7 +3477,8 @@ function fetchJsonViaOauthSession(url, options = {}) {
useSessionCookies: true,
redirect: 'follow'
})
setJsonRequestHeaders(request)
request.setHeader('Content-Type', 'application/json')
if (body) request.setHeader('Content-Length', String(body.length))
let timedOut = false
const timer = setTimeout(() => {
@@ -1,20 +0,0 @@
/**
* 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
}
@@ -1,34 +0,0 @@
/**
* 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)
})
+1 -1
View File
@@ -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 electron/oauth-net-request.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",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -1,108 +0,0 @@
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)
}
})
})
+50 -199
View File
@@ -24,17 +24,9 @@ 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,
@@ -131,7 +123,6 @@ 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(
@@ -145,6 +136,12 @@ 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)
@@ -201,7 +198,6 @@ export function ChatBar({
return
}
resetBrowseState(prev)
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
@@ -553,10 +549,16 @@ export function ChatBar({
}
}, [trigger])
// 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) => {
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
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
}
@@ -571,17 +573,6 @@ 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
@@ -724,74 +715,6 @@ 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()
@@ -801,32 +724,7 @@ 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())
}
}
}
@@ -992,42 +890,6 @@ 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
@@ -1096,14 +958,13 @@ export function ChatBar({
}
removeQueuedPrompt(activeQueueSessionKey, entry.id)
resetBrowseState(sessionId)
return true
} finally {
drainingQueueRef.current = false
}
},
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
[activeQueueSessionKey, onSubmit, queuedPrompts]
)
const drainNextQueued = useCallback(
@@ -1117,40 +978,41 @@ export function ChatBar({
)
const sendQueuedNow = useCallback(
(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]
(id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)),
[queueEdit, runDrain]
)
// 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.
// 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.
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
})
) {
@@ -1191,8 +1053,12 @@ export function ChatBar({
} else if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
// 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
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
@@ -1201,7 +1067,6 @@ export function ChatBar({
} else if (draft.trim() || attachments.length > 0) {
const submitted = draft
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
void onSubmit(submitted, { attachments })
@@ -1271,7 +1136,6 @@ export function ChatBar({
}
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
await onSubmit(text)
}
@@ -1344,17 +1208,8 @@ export function ChatBar({
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onCompositionEnd={event => {
onCompositionEnd={() => {
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
@@ -1429,11 +1284,7 @@ export function ChatBar({
)}
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
{activeQueueSessionKey && queuedPrompts.length > 0 && (
// 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">
<div className="relative z-6 mb-1 px-0.5">
<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(true)
const [collapsed, setCollapsed] = useState(false)
if (entries.length === 0) {
return null
}
return (
<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">
<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]">
<button
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"
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"
onClick={() => setCollapsed(open => !open)}
type="button"
>
@@ -41,16 +41,15 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
</button>
{!collapsed && (
<div className="space-y-0.5 px-1 pb-0.5">
<div className="space-y-0.5 px-1.5 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-0.5',
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-1',
'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'
)}
@@ -98,11 +97,11 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<Pencil size={11} />
</Button>
</Tip>
<Tip label={sendLabel}>
<Tip label={c.sendQueuedNow}>
<Button
aria-label={sendLabel}
aria-label={c.sendQueuedNow}
className="h-5 w-5 rounded-md"
disabled={isEditing}
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
@@ -3,7 +3,6 @@ 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,
@@ -37,27 +36,6 @@ 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
@@ -250,7 +228,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
const paths = await selectPaths({
const paths = await window.hermesDesktop?.selectPaths({
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
defaultPath: currentCwd || undefined,
directories: kind === 'folder'
@@ -313,13 +291,19 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
attachToMain(baseAttachment)
const previewUrl = await loadImagePreviewDataUrl(filePath)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, 'Image preview failed')
return true
}
return true
}, [])
const attachImageBlob = useCallback(
@@ -354,7 +338,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
)
const pickImages = useCallback(async () => {
const paths = await selectPaths({
const paths = await window.hermesDesktop?.selectPaths({
title: 'Attach images',
defaultPath: currentCwd || undefined,
filters: [
@@ -12,7 +12,6 @@ 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'
@@ -180,19 +179,21 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
try {
return await fsReadFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(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 fsReadFileDataUrl(filePath)
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
const base64 = metadata.includes(';base64')
const mimeType = metadata.replace(/;base64$/, '') || undefined
@@ -440,7 +441,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
try {
if (isImage) {
const dataUrl = await fsReadFileDataUrl(filePath)
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })
@@ -68,7 +68,6 @@ 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'
@@ -673,7 +672,6 @@ export function DesktopController() {
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<RemotePathPicker />
{settingsOpen && (
<Suspense fallback={null}>
@@ -1,265 +0,0 @@
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()
})
})
-234
View File
@@ -1,234 +0,0 @@
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,7 +1,6 @@
import ignore from 'ignore'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { fsGitRoot, fsReadDir, fsReadFileDataUrl } from '@/lib/desktop-fs'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -64,11 +63,15 @@ 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 = fsGitRoot(key)
cached = window.hermesDesktop.gitRoot(key)
gitRootCache.set(key, cached)
}
@@ -77,14 +80,18 @@ 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 fsReadDir(dir)
const listing = await window.hermesDesktop.readDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await fsReadFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -131,7 +138,11 @@ async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, di
}
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
const result = await fsReadDir(dirPath)
if (!window.hermesDesktop) {
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
}
+1 -2
View File
@@ -6,7 +6,6 @@ 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'
@@ -69,7 +68,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await selectPaths({
const selected = await window.hermesDesktop?.selectPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -437,18 +437,11 @@ export function useMessageStream({
const completedState = updateSessionState(sessionId, state => {
// Late completion from an already-cancelled turn: cancelRun has
// 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.
// 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.
if (state.interrupted) {
return {
...state,
awaitingResponse: false,
busy: false,
needsInput: false,
pendingBranchGroup: null,
streamId: null
}
return state
}
const streamId = state.streamId
@@ -9,8 +9,6 @@ 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()
}))
@@ -41,31 +39,27 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
interface HarnessHandle {
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
submitText: (text: string) => 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 localBusyRef = busyRef ?? { current: false }
const busyRef = { current: false }
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
activeSessionIdRef,
branchCurrentSession: async () => true,
busyRef: localBusyRef,
busyRef,
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
handleSkinCommand: () => '',
refreshSessions,
@@ -73,18 +67,8 @@ function Harness({
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
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
}
updateSessionState: (_sessionId, updater) =>
updater({ messages: [], busy: false, awaitingResponse: false } as never)
})
useEffect(() => {
@@ -180,82 +164,3 @@ 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,9 +2,10 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
INTERRUPTED_MARKER,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
@@ -177,42 +178,6 @@ 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,
@@ -227,28 +192,14 @@ export function usePromptActions({
continue
}
let result: ImageAttachResponse | null = null
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
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) {
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
@@ -264,7 +215,7 @@ export function usePromptActions({
}
}
},
[requestGateway, uploadImageAttachmentBytes]
[requestGateway]
)
const submitPromptText = useCallback(
@@ -286,11 +237,7 @@ export function usePromptActions({
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
// 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)) {
if (!text || busyRef.current) {
return false
}
@@ -323,10 +270,7 @@ export function usePromptActions({
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
// 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
interrupted: state.interrupted
}),
selectedStoredSessionIdRef.current
)
@@ -587,7 +531,6 @@ export function usePromptActions({
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
@@ -746,24 +689,24 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
// 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
)
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
)
if (!sessionId) {
setMutableRef(busyRef, false)
setBusy(false)
setMessages(finalizeMessages($messages.get()))
return
@@ -772,12 +715,24 @@ export function usePromptActions({
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = finalizeMessages(state.messages, 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)
return {
...state,
messages,
busy: true,
busy: false,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
@@ -788,8 +743,6 @@ 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,6 +117,10 @@ 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
@@ -216,6 +220,7 @@ 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) {
@@ -231,7 +236,10 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
ref={enterRef}
>
<div
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"
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'
)}
data-slot="aui_assistant-message-content"
>
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
@@ -252,7 +260,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
{messageText.trim().length > 0 && (
{messageText.trim().length > 0 && !interruptedOnly && (
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
)}
</MessagePrimitive.Root>
@@ -1,143 +0,0 @@
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)
})
})
-3
View File
@@ -372,9 +372,6 @@ 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 {
-1
View File
@@ -698,7 +698,6 @@ 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',
-1
View File
@@ -580,7 +580,6 @@ export interface Translations {
attachments: (count: number) => string
editingInComposer: string
editQueued: string
sendQueuedNext: string
sendQueuedNow: string
deleteQueued: string
previewUnavailable: string
-1
View File
@@ -827,7 +827,6 @@ export const zh: Translations = {
attachments: count => `${count} 个附件`,
editingInComposer: '正在输入框中编辑',
editQueued: '编辑排队回合',
sendQueuedNext: '下一个发送排队回合',
sendQueuedNow: '立即发送排队回合',
deleteQueued: '删除排队回合',
previewUnavailable: '预览不可用',
+1
View File
@@ -7,6 +7,7 @@ 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',
-91
View File
@@ -1,91 +0,0 @@
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)
})
})
-64
View File
@@ -1,64 +0,0 @@
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)) ?? []
}
@@ -1,147 +0,0 @@
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)
})
})
@@ -1,158 +0,0 @@
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 }
+10 -21
View File
@@ -7,7 +7,6 @@ import {
dequeueQueuedPrompt,
enqueueQueuedPrompt,
getQueuedPrompts,
promoteQueuedPrompt,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
updateQueuedPrompt,
@@ -64,20 +63,6 @@ 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')]
@@ -118,22 +103,26 @@ describe('composer queue store', () => {
})
describe('shouldAutoDrainOnSettle', () => {
const base = { isBusy: false, queueLength: 1, wasBusy: true }
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
it('drains the next queued prompt when a turn settles', () => {
it('drains the next queued prompt when a turn completes naturally', () => {
expect(shouldAutoDrainOnSettle(base)).toBe(true)
})
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 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('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)
})
+14 -27
View File
@@ -137,26 +137,6 @@ 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,
@@ -214,26 +194,33 @@ 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).
*
* 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.
* 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).
*/
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
const { isBusy, queueLength, wasBusy } = params
const { isBusy, queueLength, userInterrupted, 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
}
@@ -1,41 +0,0 @@
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)
}
+2 -2
View File
@@ -14,8 +14,8 @@ Provides subcommands for:
import os
import sys
__version__ = "0.16.0"
__release_date__ = "2026.6.5"
__version__ = "0.15.1"
__release_date__ = "2026.5.29"
def _ensure_utf8():
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
version = "0.16.0"
version = "0.15.1"
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
-2
View File
@@ -40,8 +40,6 @@ 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),
-10
View File
@@ -1167,16 +1167,6 @@ 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)
+3 -9
View File
@@ -336,19 +336,13 @@ class TestSlackNativeSlashes:
)
def test_includes_aliases_as_first_class_slashes(self):
"""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>``."""
"""Aliases (/btw, /bg, /reset, /q) must be registered as standalone
slashes this is the whole point of native-slashes parity."""
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.
-160
View File
@@ -1,160 +0,0 @@
"""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
-211
View File
@@ -1,211 +0,0 @@
"""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
+18 -106
View File
@@ -285,63 +285,6 @@ 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.
@@ -2095,40 +2038,24 @@ class ShellFileOperations(FileOperations):
fetch_limit = limit + offset + 200 if context > 0 else limit + offset
cmd_parts.extend(["|", "head", "-n", str(fetch_limit)])
# `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)
cmd = " ".join(cmd_parts)
result = self._exec(cmd, timeout=60)
# _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"
# 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"
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 stdout.strip().split('\n') if f]
all_files = [f for f in result.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 stdout.strip().split('\n'):
for line in result.stdout.strip().split('\n'):
if ':' in line:
parts = line.rsplit(':', 1)
if len(parts) == 2:
@@ -2147,7 +2074,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 stdout.strip().split('\n'):
for line in result.stdout.strip().split('\n'):
if not line or line == "--":
continue
@@ -2211,38 +2138,23 @@ class ShellFileOperations(FileOperations):
fetch_limit = limit + offset + (200 if context > 0 else 0)
cmd_parts.extend(["|", "head", "-n", str(fetch_limit)])
# `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)
cmd = " ".join(cmd_parts)
result = self._exec(cmd, timeout=60)
# _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"
# 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"
return SearchResult(error=f"Search failed: {error_msg}", total_count=0)
stdout = payload
if output_mode == "files_only":
all_files = [f for f in stdout.strip().split('\n') if f]
all_files = [f for f in result.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 stdout.strip().split('\n'):
for line in result.stdout.strip().split('\n'):
if ':' in line:
parts = line.rsplit(':', 1)
if len(parts) == 2:
@@ -2260,7 +2172,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 stdout.strip().split('\n'):
for line in result.stdout.strip().split('\n'):
if not line or line == "--":
continue
-243
View File
@@ -4980,249 +4980,6 @@ 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,43 +1114,6 @@ describe('createGatewayEventHandler', () => {
}
})
it('keepBusy interrupt holds busy until the gateway settles and suppresses the cancelled turns 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))
+2 -15
View File
@@ -182,12 +182,7 @@ class TurnController {
resetFlowOverlays()
}
// `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 } = {}) {
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
this.interrupted = true
gw.request<SessionInterruptResponse>('session.interrupt', { session_id: sid }).catch(() => {})
@@ -223,16 +218,8 @@ class TurnController {
sys('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.clearStatusTimer()
this.statusTimer = setTimeout(() => {
this.statusTimer = null
+23 -19
View File
@@ -220,28 +220,25 @@ 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): queue the text + interrupt with `keepBusy`; the
// busy→false settle edge drains it once (desktop parity).
// No optimistic send → no duplicate bubble / race note.
// - 'interrupt' (default): cancel the in-flight turn, then send the
// new text as a fresh prompt so it actually moves.
//
// `opts.fallbackToFront` re-inserts at the queue head (queue-edit picks keep
// their position); the mainline submit path appends.
// `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.
const handleBusyInput = useCallback(
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
const live = getUiState()
const mode = live.busyInputMode
const enqueueText = () => {
const fallback = (note: string) => {
if (opts.fallbackToFront) {
composerRefs.queueRef.current.unshift(full)
composerActions.syncQueue()
} else {
composerActions.enqueue(full)
}
}
const fallback = (note: string) => {
enqueueText()
sys(note)
}
@@ -263,14 +260,25 @@ export function useSubmission(opts: UseSubmissionOptions) {
return
}
// 'interrupt': queue + interrupt(keepBusy); the settle edge drains it once.
enqueueText()
// '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.
if (live.sid) {
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: true })
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
}
if (hasInterpolation(full)) {
patchUiState({ busy: true })
return interpolate(full, send)
}
send(full)
},
[appendMessage, composerActions, composerRefs, gw, sys]
[appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
)
const dispatchSubmission = useCallback(
@@ -372,11 +380,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
lastEmptyAt.current = now
if (doubleTap && live.busy && live.sid) {
// 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 })
return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
}
if (doubleTap && live.sid && composerRefs.queueRef.current.length) {
Generated
+1 -1
View File
@@ -1390,7 +1390,7 @@ wheels = [
[[package]]
name = "hermes-agent"
version = "0.16.0"
version = "0.15.1"
source = { editable = "." }
dependencies = [
{ name = "croniter" },